diff --git a/apps/core/src/components/affine/setting-modal/workspace-setting/index.tsx b/apps/core/src/components/affine/setting-modal/workspace-setting/index.tsx index 50659b588c..85f49105f8 100644 --- a/apps/core/src/components/affine/setting-modal/workspace-setting/index.tsx +++ b/apps/core/src/components/affine/setting-modal/workspace-setting/index.tsx @@ -2,6 +2,7 @@ import { pushNotificationAtom } from '@affine/component/notification-center'; import { WorkspaceSubPath } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; +import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/react'; import { useSetAtom } from 'jotai'; import { useAtomValue } from 'jotai'; @@ -24,7 +25,11 @@ export const WorkspaceSetting = ({ workspaceId }: { workspaceId: string }) => { const { jumpToSubPath, jumpToIndex } = useNavigateHelper(); const [currentWorkspace] = useCurrentWorkspace(); + const workspace = useWorkspace(workspaceId); + const [workspaceName] = useBlockSuiteWorkspaceName( + workspace.blockSuiteWorkspace + ); const workspaces = useAtomValue(rootWorkspacesMetadataAtom); const pushNotification = useSetAtom(pushNotificationAtom); @@ -74,13 +79,19 @@ export const WorkspaceSetting = ({ workspaceId }: { workspaceId: string }) => { const handleLeaveWorkspace = useCallback(async () => { closeAndJumpOut(); - await leaveWorkspace(workspaceId); + await leaveWorkspace(workspaceId, workspaceName); pushNotification({ title: 'Successfully leave', type: 'success', }); - }, [closeAndJumpOut, leaveWorkspace, pushNotification, workspaceId]); + }, [ + closeAndJumpOut, + leaveWorkspace, + pushNotification, + workspaceId, + workspaceName, + ]); const onTransformWorkspace = useOnTransformWorkspace(); // const handleDelete = useCallback(async () => { diff --git a/apps/core/src/hooks/affine/use-leave-workspace.ts b/apps/core/src/hooks/affine/use-leave-workspace.ts index eb208896da..b630512ea5 100644 --- a/apps/core/src/hooks/affine/use-leave-workspace.ts +++ b/apps/core/src/hooks/affine/use-leave-workspace.ts @@ -12,10 +12,13 @@ export function useLeaveWorkspace() { }); return useCallback( - async (workspaceId: string) => { + async (workspaceId: string, workspaceName: string) => { deleteWorkspaceMeta(workspaceId); + await leaveWorkspace({ workspaceId, + workspaceName, + sendLeaveMail: true, }); }, [deleteWorkspaceMeta, leaveWorkspace] diff --git a/apps/core/src/pages/invite.tsx b/apps/core/src/pages/invite.tsx index 10b639caa0..9322b80059 100644 --- a/apps/core/src/pages/invite.tsx +++ b/apps/core/src/pages/invite.tsx @@ -35,6 +35,7 @@ export const loader: LoaderFunction = async args => { variables: { workspaceId: res.getInviteInfo.workspace.id, inviteId, + sendAcceptMail: true, }, }).catch(console.error); diff --git a/apps/server/src/modules/auth/mailer/mail.service.ts b/apps/server/src/modules/auth/mailer/mail.service.ts index 6c86afded7..13d753143b 100644 --- a/apps/server/src/modules/auth/mailer/mail.service.ts +++ b/apps/server/src/modules/auth/mailer/mail.service.ts @@ -160,4 +160,50 @@ export class MailService { html, }); } + async sendAcceptedEmail( + to: string, + { + inviteeName, + workspaceName, + }: { + inviteeName: string; + workspaceName: string; + } + ) { + const title = `${inviteeName} accepted your invitation`; + + const html = emailTemplate({ + title, + content: `${inviteeName} has joined ${workspaceName}`, + }); + return this.sendMail({ + from: this.config.auth.email.sender, + to, + subject: title, + html, + }); + } + async sendLeaveWorkspaceEmail( + to: string, + { + inviteeName, + workspaceName, + }: { + inviteeName: string; + workspaceName: string; + } + ) { + const title = `${inviteeName} left ${workspaceName}`; + + const html = emailTemplate({ + title, + content: `${inviteeName} has left your workspace`, + }); + return this.sendMail({ + from: this.config.auth.email.sender, + to, + subject: title, + html, + }); + } } diff --git a/apps/server/src/modules/auth/mailer/template.ts b/apps/server/src/modules/auth/mailer/template.ts index cfcba00d4a..e911f88bd4 100644 --- a/apps/server/src/modules/auth/mailer/template.ts +++ b/apps/server/src/modules/auth/mailer/template.ts @@ -7,8 +7,8 @@ export const emailTemplate = ({ }: { title: string; content: string; - buttonContent: string; - buttonUrl: string; + buttonContent?: string; + buttonUrl?: string; subContent?: string; }) => { return ` @@ -59,7 +59,9 @@ export const emailTemplate = ({ " >${content} - + ${ + buttonContent && buttonUrl + ? ` @@ -88,7 +90,9 @@ export const emailTemplate = ({ - + ` + : '' + } ${ subContent ? ` diff --git a/apps/server/src/modules/workspaces/resolver.ts b/apps/server/src/modules/workspaces/resolver.ts index 1118983063..7fc5115ad9 100644 --- a/apps/server/src/modules/workspaces/resolver.ts +++ b/apps/server/src/modules/workspaces/resolver.ts @@ -109,6 +109,8 @@ export class InvitationType { workspace!: InvitationWorkspaceType; @Field({ description: 'User information' }) user!: UserType; + @Field({ description: 'Invitee information' }) + invitee!: UserType; } @InputType() @@ -514,6 +516,17 @@ export class WorkspaceResolver { user: true, }, }); + const invitee = await this.prisma.userWorkspacePermission.findUniqueOrThrow( + { + where: { + id: inviteId, + workspaceId: permission.workspaceId, + }, + include: { + user: true, + }, + } + ); let avatar = ''; @@ -532,6 +545,7 @@ export class WorkspaceResolver { id: permission.workspaceId, }, user: owner.user, + invitee: invitee.user, }; } @@ -550,8 +564,28 @@ export class WorkspaceResolver { @Public() async acceptInviteById( @Args('workspaceId') workspaceId: string, - @Args('inviteId') inviteId: string + @Args('inviteId') inviteId: string, + @Args('sendAcceptMail', { nullable: true }) sendAcceptMail: boolean ) { + const { + invitee, + user: inviter, + workspace, + } = await this.getInviteInfo(inviteId); + + if (!inviter || !invitee) { + throw new ForbiddenException( + `can not find inviter/invitee by inviteId: ${inviteId}` + ); + } + + if (sendAcceptMail) { + await this.mailer.sendAcceptedEmail(inviter.email, { + inviteeName: invitee.name, + workspaceName: workspace.name, + }); + } + return this.permissionProvider.acceptById(workspaceId, inviteId); } @@ -566,10 +600,35 @@ export class WorkspaceResolver { @Mutation(() => Boolean) async leaveWorkspace( @CurrentUser() user: UserType, - @Args('workspaceId') workspaceId: string + @Args('workspaceId') workspaceId: string, + @Args('workspaceName') workspaceName: string, + @Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean ) { await this.permissionProvider.check(workspaceId, user.id); + const owner = await this.prisma.userWorkspacePermission.findFirstOrThrow({ + where: { + workspaceId, + type: Permission.Owner, + }, + include: { + user: true, + }, + }); + + if (!owner.user) { + throw new ForbiddenException( + `can not find owner by workspaceId: ${workspaceId}` + ); + } + + if (sendLeaveMail) { + await this.mailer.sendLeaveWorkspaceEmail(owner.user.email, { + workspaceName, + inviteeName: user.name, + }); + } + return this.permissionProvider.revoke(workspaceId, user.id); } diff --git a/apps/server/src/schema.gql b/apps/server/src/schema.gql index dd2570aa6b..e511c7c8c4 100644 --- a/apps/server/src/schema.gql +++ b/apps/server/src/schema.gql @@ -133,6 +133,9 @@ type InvitationType { """User information""" user: UserType! + + """Invitee information""" + invitee: UserType! } type Query { @@ -172,9 +175,9 @@ type Mutation { deleteWorkspace(id: String!): Boolean! invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String! revoke(workspaceId: String!, userId: String!): Boolean! - acceptInviteById(workspaceId: String!, inviteId: String!): Boolean! + acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean! acceptInvite(workspaceId: String!): Boolean! - leaveWorkspace(workspaceId: String!): Boolean! + leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean! sharePage(workspaceId: String!, pageId: String!): Boolean! revokePage(workspaceId: String!, pageId: String!): Boolean! setBlob(workspaceId: String!, blob: Upload!): String! diff --git a/apps/server/src/tests/mailer.e2e.ts b/apps/server/src/tests/mailer.e2e.ts index 8bc8852715..c75714c448 100644 --- a/apps/server/src/tests/mailer.e2e.ts +++ b/apps/server/src/tests/mailer.e2e.ts @@ -13,6 +13,7 @@ import { AuthModule } from '../modules/auth'; import { AuthService } from '../modules/auth/service'; import { PrismaModule } from '../prisma'; import { RateLimiterModule } from '../throttler'; +import { getCurrentMailMessageCount, getLatestMailMessage } from './utils'; let auth: AuthService; let module: TestingModule; @@ -68,23 +69,11 @@ test.afterEach(async () => { await module.close(); }); -const getCurrentMailMessageCount = async () => { - const response = await fetch('http://localhost:8025/api/v2/messages'); - const data = await response.json(); - return data.total; -}; - -const getLatestMailMessage = async () => { - const response = await fetch('http://localhost:8025/api/v2/messages'); - const data = await response.json(); - return data.items[0]; -}; - test('should include callbackUrl in sending email', async t => { if (skip) { return t.pass(); } - await auth.signUp('Alex Yang', 'alexyang@example.org', '123456'); + // await auth.signUp('Alex Yang', 'alexyang@example.org', '123456'); for (const fn of [ 'sendSetPasswordEmail', 'sendChangeEmail', diff --git a/apps/server/src/tests/utils.ts b/apps/server/src/tests/utils.ts index 8b517b5806..bd43f4da2c 100644 --- a/apps/server/src/tests/utils.ts +++ b/apps/server/src/tests/utils.ts @@ -12,6 +12,18 @@ import type { InvitationType, WorkspaceType } from '../modules/workspaces'; const gql = '/graphql'; +export async function getCurrentMailMessageCount() { + const response = await fetch('http://localhost:8025/api/v2/messages'); + const data = await response.json(); + return data.total; +} + +export async function getLatestMailMessage() { + const response = await fetch('http://localhost:8025/api/v2/messages'); + const data = await response.json(); + return data.items[0]; +} + async function signUp( app: INestApplication, name: string, @@ -192,7 +204,8 @@ async function inviteUser( async function acceptInviteById( app: INestApplication, workspaceId: string, - inviteId: string + inviteId: string, + sendAcceptMail = false ): Promise { const res = await request(app.getHttpServer()) .post(gql) @@ -200,7 +213,7 @@ async function acceptInviteById( .send({ query: ` mutation { - acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}") + acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail}) } `, }) @@ -231,7 +244,8 @@ async function acceptInvite( async function leaveWorkspace( app: INestApplication, token: string, - workspaceId: string + workspaceId: string, + sendLeaveMail = false ): Promise { const res = await request(app.getHttpServer()) .post(gql) @@ -240,7 +254,7 @@ async function leaveWorkspace( .send({ query: ` mutation { - leaveWorkspace(workspaceId: "${workspaceId}") + leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail}) } `, }) diff --git a/apps/server/src/tests/workspace-invite.spec.ts b/apps/server/src/tests/workspace-invite.spec.ts index 7bf71ca17e..9299a980bb 100644 --- a/apps/server/src/tests/workspace-invite.spec.ts +++ b/apps/server/src/tests/workspace-invite.spec.ts @@ -12,6 +12,8 @@ import { acceptInvite, acceptInviteById, createWorkspace, + getCurrentMailMessageCount, + getLatestMailMessage, getWorkspace, inviteUser, leaveWorkspace, @@ -100,6 +102,8 @@ test('should leave a workspace', async t => { await acceptInvite(app, u2.token.token, workspace.id); const leave = await leaveWorkspace(app, u2.token.token, workspace.id); + + t.pass(); t.true(leave, 'failed to leave workspace'); }); @@ -162,13 +166,15 @@ test('should invite a user by link', async t => { t.is(currMember?.inviteId, invite, 'failed to check invite id'); }); -test('should send invite email', async t => { +test('should send email', async t => { if (mail.hasConfigured()) { const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); const u2 = await signUp(app, 'test', 'production@toeverything.info', '1'); const workspace = await createWorkspace(app, u1.token.token); - await inviteUser( + const primitiveMailCount = await getCurrentMailMessageCount(); + + const invite = await inviteUser( app, u1.token.token, workspace.id, @@ -176,6 +182,60 @@ test('should send invite email', async t => { 'Admin', true ); + + const afterInviteMailCount = await getCurrentMailMessageCount(); + t.is( + primitiveMailCount + 1, + afterInviteMailCount, + 'failed to send invite email' + ); + const inviteEmailContent = await getLatestMailMessage(); + + t.not( + // @ts-expect-error Third part library type mismatch + inviteEmailContent.To.find(item => { + return item.Mailbox === 'production'; + }), + undefined, + 'invite email address was incorrectly sent' + ); + + const accept = await acceptInviteById(app, workspace.id, invite, true); + t.true(accept, 'failed to accept invite'); + + const afterAcceptMailCount = await getCurrentMailMessageCount(); + t.is( + afterInviteMailCount + 1, + afterAcceptMailCount, + 'failed to send accepted email to owner' + ); + const acceptEmailContent = await getLatestMailMessage(); + t.not( + // @ts-expect-error Third part library type mismatch + acceptEmailContent.To.find(item => { + return item.Mailbox === 'u1'; + }), + undefined, + 'accept email address was incorrectly sent' + ); + + await leaveWorkspace(app, u2.token.token, workspace.id, true); + + const afterLeaveMailCount = await getCurrentMailMessageCount(); + t.is( + afterAcceptMailCount + 1, + afterLeaveMailCount, + 'failed to send leave email to owner' + ); + const leaveEmailContent = await getLatestMailMessage(); + t.not( + // @ts-expect-error Third part library type mismatch + leaveEmailContent.To.find(item => { + return item.Mailbox === 'u1'; + }), + undefined, + 'leave email address was incorrectly sent' + ); } t.pass(); }); diff --git a/packages/graphql/src/graphql/index.ts b/packages/graphql/src/graphql/index.ts index c687a92e79..8afc4bc527 100644 --- a/packages/graphql/src/graphql/index.ts +++ b/packages/graphql/src/graphql/index.ts @@ -301,8 +301,12 @@ export const leaveWorkspaceMutation = { definitionName: 'leaveWorkspace', containsFile: false, query: ` -mutation leaveWorkspace($workspaceId: String!) { - leaveWorkspace(workspaceId: $workspaceId) +mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLeaveMail: Boolean) { + leaveWorkspace( + workspaceId: $workspaceId + workspaceName: $workspaceName + sendLeaveMail: $sendLeaveMail + ) }`, }; @@ -475,8 +479,12 @@ export const acceptInviteByInviteIdMutation = { definitionName: 'acceptInviteById', containsFile: false, query: ` -mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!) { - acceptInviteById(workspaceId: $workspaceId, inviteId: $inviteId) +mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!, $sendAcceptMail: Boolean) { + acceptInviteById( + workspaceId: $workspaceId + inviteId: $inviteId + sendAcceptMail: $sendAcceptMail + ) }`, }; diff --git a/packages/graphql/src/graphql/leave-workspace.gql b/packages/graphql/src/graphql/leave-workspace.gql index 11c095a0d9..1b5064943a 100644 --- a/packages/graphql/src/graphql/leave-workspace.gql +++ b/packages/graphql/src/graphql/leave-workspace.gql @@ -1,3 +1,11 @@ -mutation leaveWorkspace($workspaceId: String!) { - leaveWorkspace(workspaceId: $workspaceId) +mutation leaveWorkspace( + $workspaceId: String! + $workspaceName: String! + $sendLeaveMail: Boolean +) { + leaveWorkspace( + workspaceId: $workspaceId + workspaceName: $workspaceName + sendLeaveMail: $sendLeaveMail + ) } diff --git a/packages/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql b/packages/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql index 1138ddf327..c00a3d8769 100644 --- a/packages/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql +++ b/packages/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql @@ -1,3 +1,11 @@ -mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!) { - acceptInviteById(workspaceId: $workspaceId, inviteId: $inviteId) +mutation acceptInviteByInviteId( + $workspaceId: String! + $inviteId: String! + $sendAcceptMail: Boolean +) { + acceptInviteById( + workspaceId: $workspaceId + inviteId: $inviteId + sendAcceptMail: $sendAcceptMail + ) } diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index a0c2cf739e..ba066d815e 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -279,6 +279,8 @@ export type GetWorkspacesQuery = { export type LeaveWorkspaceMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; + workspaceName: Scalars['String']['input']; + sendLeaveMail: InputMaybe; }>; export type LeaveWorkspaceMutation = { @@ -428,6 +430,7 @@ export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string }; export type AcceptInviteByInviteIdMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; inviteId: Scalars['String']['input']; + sendAcceptMail: InputMaybe; }>; export type AcceptInviteByInviteIdMutation = { diff --git a/packages/storage/project.json b/packages/storage/project.json index 5b627984f6..4b3376caf3 100644 --- a/packages/storage/project.json +++ b/packages/storage/project.json @@ -12,15 +12,10 @@ "script": "build" }, "inputs": [ - { - "runtime": "rustc --version" - }, - { - "runtime": "node -v" - }, - { - "runtime": "clang --version" - } + { "runtime": "rustc --version" }, + { "runtime": "node -v" }, + { "runtime": "clang --version" }, + { "runtime": "cargo tree" } ], "outputs": ["{projectRoot}/*.node", "{workspaceRoot}/*.node"] }