mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat: send email to owner after member accepted invitation / leave workspace (#4152)
Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ export const emailTemplate = ({
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
buttonContent: string;
|
||||
buttonUrl: string;
|
||||
buttonContent?: string;
|
||||
buttonUrl?: string;
|
||||
subContent?: string;
|
||||
}) => {
|
||||
return `<body style="background: #f6f7fb; overflow: hidden">
|
||||
@@ -59,7 +59,9 @@ export const emailTemplate = ({
|
||||
"
|
||||
>${content}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
${
|
||||
buttonContent && buttonUrl
|
||||
? `<tr>
|
||||
<td style="margin-left: 24px; padding-top: 0; padding-bottom: ${
|
||||
subContent ? '0' : '64px'
|
||||
}">
|
||||
@@ -88,7 +90,9 @@ export const emailTemplate = ({
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tr>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
subContent
|
||||
? `<tr>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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})
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user