feat: send email to owner after member accepted invitation / leave workspace (#4152)

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
Qi 2023-09-07 13:08:23 +08:00 committed by GitHub
parent 1301605db5
commit 498683ff4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 259 additions and 47 deletions

View File

@ -2,6 +2,7 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
import { WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/react'; import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
@ -24,7 +25,11 @@ export const WorkspaceSetting = ({ workspaceId }: { workspaceId: string }) => {
const { jumpToSubPath, jumpToIndex } = useNavigateHelper(); const { jumpToSubPath, jumpToIndex } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace(); const [currentWorkspace] = useCurrentWorkspace();
const workspace = useWorkspace(workspaceId); const workspace = useWorkspace(workspaceId);
const [workspaceName] = useBlockSuiteWorkspaceName(
workspace.blockSuiteWorkspace
);
const workspaces = useAtomValue(rootWorkspacesMetadataAtom); const workspaces = useAtomValue(rootWorkspacesMetadataAtom);
const pushNotification = useSetAtom(pushNotificationAtom); const pushNotification = useSetAtom(pushNotificationAtom);
@ -74,13 +79,19 @@ export const WorkspaceSetting = ({ workspaceId }: { workspaceId: string }) => {
const handleLeaveWorkspace = useCallback(async () => { const handleLeaveWorkspace = useCallback(async () => {
closeAndJumpOut(); closeAndJumpOut();
await leaveWorkspace(workspaceId); await leaveWorkspace(workspaceId, workspaceName);
pushNotification({ pushNotification({
title: 'Successfully leave', title: 'Successfully leave',
type: 'success', type: 'success',
}); });
}, [closeAndJumpOut, leaveWorkspace, pushNotification, workspaceId]); }, [
closeAndJumpOut,
leaveWorkspace,
pushNotification,
workspaceId,
workspaceName,
]);
const onTransformWorkspace = useOnTransformWorkspace(); const onTransformWorkspace = useOnTransformWorkspace();
// const handleDelete = useCallback(async () => { // const handleDelete = useCallback(async () => {

View File

@ -12,10 +12,13 @@ export function useLeaveWorkspace() {
}); });
return useCallback( return useCallback(
async (workspaceId: string) => { async (workspaceId: string, workspaceName: string) => {
deleteWorkspaceMeta(workspaceId); deleteWorkspaceMeta(workspaceId);
await leaveWorkspace({ await leaveWorkspace({
workspaceId, workspaceId,
workspaceName,
sendLeaveMail: true,
}); });
}, },
[deleteWorkspaceMeta, leaveWorkspace] [deleteWorkspaceMeta, leaveWorkspace]

View File

@ -35,6 +35,7 @@ export const loader: LoaderFunction = async args => {
variables: { variables: {
workspaceId: res.getInviteInfo.workspace.id, workspaceId: res.getInviteInfo.workspace.id,
inviteId, inviteId,
sendAcceptMail: true,
}, },
}).catch(console.error); }).catch(console.error);

View File

@ -160,4 +160,50 @@ export class MailService {
html, 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,
});
}
} }

View File

@ -7,8 +7,8 @@ export const emailTemplate = ({
}: { }: {
title: string; title: string;
content: string; content: string;
buttonContent: string; buttonContent?: string;
buttonUrl: string; buttonUrl?: string;
subContent?: string; subContent?: string;
}) => { }) => {
return `<body style="background: #f6f7fb; overflow: hidden"> return `<body style="background: #f6f7fb; overflow: hidden">
@ -59,7 +59,9 @@ export const emailTemplate = ({
" "
>${content}</td> >${content}</td>
</tr> </tr>
<tr> ${
buttonContent && buttonUrl
? `<tr>
<td style="margin-left: 24px; padding-top: 0; padding-bottom: ${ <td style="margin-left: 24px; padding-top: 0; padding-bottom: ${
subContent ? '0' : '64px' subContent ? '0' : '64px'
}"> }">
@ -88,7 +90,9 @@ export const emailTemplate = ({
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>`
: ''
}
${ ${
subContent subContent
? `<tr> ? `<tr>

View File

@ -109,6 +109,8 @@ export class InvitationType {
workspace!: InvitationWorkspaceType; workspace!: InvitationWorkspaceType;
@Field({ description: 'User information' }) @Field({ description: 'User information' })
user!: UserType; user!: UserType;
@Field({ description: 'Invitee information' })
invitee!: UserType;
} }
@InputType() @InputType()
@ -514,6 +516,17 @@ export class WorkspaceResolver {
user: true, user: true,
}, },
}); });
const invitee = await this.prisma.userWorkspacePermission.findUniqueOrThrow(
{
where: {
id: inviteId,
workspaceId: permission.workspaceId,
},
include: {
user: true,
},
}
);
let avatar = ''; let avatar = '';
@ -532,6 +545,7 @@ export class WorkspaceResolver {
id: permission.workspaceId, id: permission.workspaceId,
}, },
user: owner.user, user: owner.user,
invitee: invitee.user,
}; };
} }
@ -550,8 +564,28 @@ export class WorkspaceResolver {
@Public() @Public()
async acceptInviteById( async acceptInviteById(
@Args('workspaceId') workspaceId: string, @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); return this.permissionProvider.acceptById(workspaceId, inviteId);
} }
@ -566,10 +600,35 @@ export class WorkspaceResolver {
@Mutation(() => Boolean) @Mutation(() => Boolean)
async leaveWorkspace( async leaveWorkspace(
@CurrentUser() user: UserType, @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); 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); return this.permissionProvider.revoke(workspaceId, user.id);
} }

View File

@ -133,6 +133,9 @@ type InvitationType {
"""User information""" """User information"""
user: UserType! user: UserType!
"""Invitee information"""
invitee: UserType!
} }
type Query { type Query {
@ -172,9 +175,9 @@ type Mutation {
deleteWorkspace(id: String!): Boolean! deleteWorkspace(id: String!): Boolean!
invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String! invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String!
revoke(workspaceId: String!, userId: String!): Boolean! revoke(workspaceId: String!, userId: String!): Boolean!
acceptInviteById(workspaceId: String!, inviteId: String!): Boolean! acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean!
acceptInvite(workspaceId: String!): Boolean! acceptInvite(workspaceId: String!): Boolean!
leaveWorkspace(workspaceId: String!): Boolean! leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean!
sharePage(workspaceId: String!, pageId: String!): Boolean! sharePage(workspaceId: String!, pageId: String!): Boolean!
revokePage(workspaceId: String!, pageId: String!): Boolean! revokePage(workspaceId: String!, pageId: String!): Boolean!
setBlob(workspaceId: String!, blob: Upload!): String! setBlob(workspaceId: String!, blob: Upload!): String!

View File

@ -13,6 +13,7 @@ import { AuthModule } from '../modules/auth';
import { AuthService } from '../modules/auth/service'; import { AuthService } from '../modules/auth/service';
import { PrismaModule } from '../prisma'; import { PrismaModule } from '../prisma';
import { RateLimiterModule } from '../throttler'; import { RateLimiterModule } from '../throttler';
import { getCurrentMailMessageCount, getLatestMailMessage } from './utils';
let auth: AuthService; let auth: AuthService;
let module: TestingModule; let module: TestingModule;
@ -68,23 +69,11 @@ test.afterEach(async () => {
await module.close(); 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 => { test('should include callbackUrl in sending email', async t => {
if (skip) { if (skip) {
return t.pass(); 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 [ for (const fn of [
'sendSetPasswordEmail', 'sendSetPasswordEmail',
'sendChangeEmail', 'sendChangeEmail',

View File

@ -12,6 +12,18 @@ import type { InvitationType, WorkspaceType } from '../modules/workspaces';
const gql = '/graphql'; 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( async function signUp(
app: INestApplication, app: INestApplication,
name: string, name: string,
@ -192,7 +204,8 @@ async function inviteUser(
async function acceptInviteById( async function acceptInviteById(
app: INestApplication, app: INestApplication,
workspaceId: string, workspaceId: string,
inviteId: string inviteId: string,
sendAcceptMail = false
): Promise<boolean> { ): Promise<boolean> {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(gql) .post(gql)
@ -200,7 +213,7 @@ async function acceptInviteById(
.send({ .send({
query: ` query: `
mutation { mutation {
acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}") acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail})
} }
`, `,
}) })
@ -231,7 +244,8 @@ async function acceptInvite(
async function leaveWorkspace( async function leaveWorkspace(
app: INestApplication, app: INestApplication,
token: string, token: string,
workspaceId: string workspaceId: string,
sendLeaveMail = false
): Promise<boolean> { ): Promise<boolean> {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(gql) .post(gql)
@ -240,7 +254,7 @@ async function leaveWorkspace(
.send({ .send({
query: ` query: `
mutation { mutation {
leaveWorkspace(workspaceId: "${workspaceId}") leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail})
} }
`, `,
}) })

View File

@ -12,6 +12,8 @@ import {
acceptInvite, acceptInvite,
acceptInviteById, acceptInviteById,
createWorkspace, createWorkspace,
getCurrentMailMessageCount,
getLatestMailMessage,
getWorkspace, getWorkspace,
inviteUser, inviteUser,
leaveWorkspace, leaveWorkspace,
@ -100,6 +102,8 @@ test('should leave a workspace', async t => {
await acceptInvite(app, u2.token.token, workspace.id); await acceptInvite(app, u2.token.token, workspace.id);
const leave = await leaveWorkspace(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'); 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'); 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()) { if (mail.hasConfigured()) {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'test', 'production@toeverything.info', '1'); const u2 = await signUp(app, 'test', 'production@toeverything.info', '1');
const workspace = await createWorkspace(app, u1.token.token); const workspace = await createWorkspace(app, u1.token.token);
await inviteUser( const primitiveMailCount = await getCurrentMailMessageCount();
const invite = await inviteUser(
app, app,
u1.token.token, u1.token.token,
workspace.id, workspace.id,
@ -176,6 +182,60 @@ test('should send invite email', async t => {
'Admin', 'Admin',
true 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(); t.pass();
}); });

View File

@ -301,8 +301,12 @@ export const leaveWorkspaceMutation = {
definitionName: 'leaveWorkspace', definitionName: 'leaveWorkspace',
containsFile: false, containsFile: false,
query: ` query: `
mutation leaveWorkspace($workspaceId: String!) { mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLeaveMail: Boolean) {
leaveWorkspace(workspaceId: $workspaceId) leaveWorkspace(
workspaceId: $workspaceId
workspaceName: $workspaceName
sendLeaveMail: $sendLeaveMail
)
}`, }`,
}; };
@ -475,8 +479,12 @@ export const acceptInviteByInviteIdMutation = {
definitionName: 'acceptInviteById', definitionName: 'acceptInviteById',
containsFile: false, containsFile: false,
query: ` query: `
mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!) { mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!, $sendAcceptMail: Boolean) {
acceptInviteById(workspaceId: $workspaceId, inviteId: $inviteId) acceptInviteById(
workspaceId: $workspaceId
inviteId: $inviteId
sendAcceptMail: $sendAcceptMail
)
}`, }`,
}; };

View File

@ -1,3 +1,11 @@
mutation leaveWorkspace($workspaceId: String!) { mutation leaveWorkspace(
leaveWorkspace(workspaceId: $workspaceId) $workspaceId: String!
$workspaceName: String!
$sendLeaveMail: Boolean
) {
leaveWorkspace(
workspaceId: $workspaceId
workspaceName: $workspaceName
sendLeaveMail: $sendLeaveMail
)
} }

View File

@ -1,3 +1,11 @@
mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!) { mutation acceptInviteByInviteId(
acceptInviteById(workspaceId: $workspaceId, inviteId: $inviteId) $workspaceId: String!
$inviteId: String!
$sendAcceptMail: Boolean
) {
acceptInviteById(
workspaceId: $workspaceId
inviteId: $inviteId
sendAcceptMail: $sendAcceptMail
)
} }

View File

@ -279,6 +279,8 @@ export type GetWorkspacesQuery = {
export type LeaveWorkspaceMutationVariables = Exact<{ export type LeaveWorkspaceMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
workspaceName: Scalars['String']['input'];
sendLeaveMail: InputMaybe<Scalars['Boolean']['input']>;
}>; }>;
export type LeaveWorkspaceMutation = { export type LeaveWorkspaceMutation = {
@ -428,6 +430,7 @@ export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string };
export type AcceptInviteByInviteIdMutationVariables = Exact<{ export type AcceptInviteByInviteIdMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
inviteId: Scalars['String']['input']; inviteId: Scalars['String']['input'];
sendAcceptMail: InputMaybe<Scalars['Boolean']['input']>;
}>; }>;
export type AcceptInviteByInviteIdMutation = { export type AcceptInviteByInviteIdMutation = {

View File

@ -12,15 +12,10 @@
"script": "build" "script": "build"
}, },
"inputs": [ "inputs": [
{ { "runtime": "rustc --version" },
"runtime": "rustc --version" { "runtime": "node -v" },
}, { "runtime": "clang --version" },
{ { "runtime": "cargo tree" }
"runtime": "node -v"
},
{
"runtime": "clang --version"
}
], ],
"outputs": ["{projectRoot}/*.node", "{workspaceRoot}/*.node"] "outputs": ["{projectRoot}/*.node", "{workspaceRoot}/*.node"]
} }