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"]
}