feat(server): new email template (#9528)

use `yarn af server dev:mail` to preview all mail template
fix CLOUD-93
This commit is contained in:
darkskygit
2025-01-22 02:55:24 +00:00
parent 2db9cc3922
commit 83ed215f4a
54 changed files with 4794 additions and 892 deletions

View File

@@ -10,6 +10,7 @@
"scripts": {
"build": "tsc",
"dev": "nodemon ./src/index.ts",
"dev:mail": "email dev -d src/mails",
"test": "ava --concurrency 1 --serial",
"test:copilot": "ava \"src/__tests__/**/copilot-*.spec.ts\"",
"test:coverage": "c8 ava --concurrency 1 --serial",
@@ -58,6 +59,8 @@
"@opentelemetry/semantic-conventions": "^1.28.0",
"@prisma/client": "^5.22.0",
"@prisma/instrumentation": "^5.22.0",
"@react-email/components": "0.0.31",
"@react-email/render": "1.0.3",
"@socket.io/redis-adapter": "^8.3.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
@@ -85,6 +88,7 @@
"openai": "^4.76.2",
"piscina": "^5.0.0-alpha.0",
"prisma": "^5.22.0",
"react": "19.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ses": "^1.10.0",
@@ -112,12 +116,16 @@
"@types/node": "^22.0.0",
"@types/nodemailer": "^6.4.17",
"@types/on-headers": "^1.0.3",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"ava": "^6.2.0",
"c8": "^10.1.3",
"cross-env": "^7.0.3",
"nodemon": "^3.1.7",
"react-dom": "19.0.0",
"react-email": "3.0.4",
"sinon": "^19.0.2",
"supertest": "^7.0.0"
},

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,10 @@ test.before(async t => {
imports: [FeatureModule, UserModule, AuthModule],
tapModule: m => {
m.overrideProvider(MailService).useValue(
Sinon.createStubInstance(MailService)
Sinon.stub(
// @ts-expect-error safe
new MailService()
)
);
},
});
@@ -71,7 +74,7 @@ test('should be able to sign in with email', async t => {
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const [signInLink] = mailer.sendSignInMail.firstCall.args;
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -99,7 +102,7 @@ test('should be able to sign up with email', async t => {
t.is(res.body.email, 'u2@affine.pro');
t.true(mailer.sendSignUpMail.calledOnce);
const [signUpLink] = mailer.sendSignUpMail.firstCall.args;
const [, { url: signUpLink }] = mailer.sendSignUpMail.firstCall.args;
const url = new URL(signUpLink);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');

View File

@@ -1,54 +0,0 @@
/// <reference types="../global.d.ts" />
// This test case is for testing the mailer service.
// Please use local SMTP server for testing.
// See: https://github.com/mailhog/MailHog
import {
getCurrentMailMessageCount,
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import { TestingModule } from '@nestjs/testing';
import type { TestFn } from 'ava';
import ava from 'ava';
import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth/service';
import { createTestingModule } from './utils';
const test = ava as TestFn<{
auth: AuthService;
module: TestingModule;
skip: boolean;
}>;
test.beforeEach(async t => {
t.context.module = await createTestingModule({
imports: [ConfigModule.forRoot({})],
});
t.context.auth = t.context.module.get(AuthService);
});
test.afterEach.always(async t => {
await t.context.module.close();
});
test('should include callbackUrl in sending email', async t => {
const { auth } = t.context;
await auth.signUp('test@affine.pro', '123456');
for (const fn of [
'sendSetPasswordEmail',
'sendChangeEmail',
'sendChangePasswordEmail',
'sendVerifyChangeEmail',
] as const) {
const prev = await getCurrentMailMessageCount();
await auth[fn]('test@affine.pro', 'https://test.com/callback');
const current = await getCurrentMailMessageCount();
const mail = await getLatestMailMessage();
t.regex(
mail?.Content?.Body,
/https:\/\/test.com\/callback/,
`should include callbackUrl when calling ${fn}`
);
t.is(current, prev + 1, `calling ${fn}`);
}
});

View File

@@ -11,6 +11,7 @@ const test = ava as TestFn<{
app: INestApplication;
mail: MailService;
}>;
import * as renderers from '../mails';
test.beforeEach(async t => {
const { module, app } = await createTestingApp({
@@ -42,7 +43,7 @@ test('should send invite email', async t => {
const workspace = await createWorkspace(app, u1.token.token);
const stub = Sinon.stub(mail, 'sendMail');
const stub = Sinon.stub(mail, 'send');
await inviteUser(app, u1.token.token, workspace.id, u2.email, true);
@@ -53,9 +54,17 @@ test('should send invite email', async t => {
t.is(args.to, u2.email);
t.true(
args.subject!.startsWith(
`${u1.name} invited you to join` /* we don't know the name of mocked workspace */
`${u1.email} invited you to join` /* we don't know the name of mocked workspace */
)
);
}
t.pass();
});
test('should render emails', async t => {
for (const render of Object.values(renderers)) {
// @ts-expect-error use [PreviewProps]
const content = await render();
t.snapshot(content.html, content.subject);
}
});

View File

@@ -703,23 +703,15 @@ test('should be able to emit events', async t => {
);
await grantMember(app, owner.token.token, tws.id, read.id, 'Owner');
const [ownerTransferred, roleChanged] = event.emit
const [ownershipTransferred] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(
roleChanged,
ownershipTransferred,
[
'workspace.members.roleChanged',
{ userId: read.id, workspaceId: tws.id, permission: Permission.Owner },
],
'should emit role changed event'
);
t.deepEqual(
ownerTransferred,
[
'workspace.members.ownerTransferred',
{ email: owner.email, workspaceId: tws.id },
'workspace.members.ownershipTransferred',
{ from: owner.id, to: read.id, workspaceId: tws.id },
],
'should emit owner transferred event'
);

View File

@@ -174,12 +174,13 @@ test('should send email', async t => {
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'
);
// TODO(@darkskygit): enable this after cluster event system is ready
// const afterLeaveMailCount = await getCurrentMailMessageCount();
// t.is(
// afterAcceptMailCount + 1,
// afterLeaveMailCount,
// 'failed to send leave email to owner'
// );
const leaveEmailContent = await getLatestMailMessage();
t.not(
leaveEmailContent.To.find((item: any) => {

View File

@@ -15,8 +15,17 @@ export interface WorkspaceEvents {
workspaceId: Workspace['id'];
permission: number;
}>;
ownerTransferred: Payload<{ email: string; workspaceId: Workspace['id'] }>;
ownershipTransferred: Payload<{
from: User['id'];
to: User['id'];
workspaceId: Workspace['id'];
}>;
ownershipReceived: Payload<{ workspaceId: Workspace['id'] }>;
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
leave: Payload<{
user: Pick<User, 'id' | 'email'>;
workspaceId: Workspace['id'];
}>;
removed: Payload<{ workspaceId: Workspace['id']; userId: User['id'] }>;
};
deleted: Payload<Workspace['id']>;

View File

@@ -1,26 +1,81 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import {
EmailRenderer,
renderChangeEmailMail,
renderChangeEmailNotificationMail,
renderChangePasswordMail,
renderLinkInvitationApproveMail,
renderLinkInvitationDeclineMail,
renderLinkInvitationReviewRequestMail,
renderMemberAcceptedMail,
renderMemberInvitationMail,
renderMemberLeaveMail,
renderMemberRemovedMail,
renderOwnershipReceivedMail,
renderOwnershipTransferredMail,
renderSetPasswordMail,
renderSignInMail,
renderSignUpMail,
renderTeamBecomeAdminMail,
renderTeamBecomeCollaboratorMail,
renderTeamDeleteIn1MonthMail,
renderTeamDeleteIn24HoursMail,
renderTeamWorkspaceDeletedMail,
renderTeamWorkspaceExpiredMail,
renderTeamWorkspaceExpireSoonMail,
renderTeamWorkspaceUpgradedMail,
renderVerifyChangeEmailMail,
renderVerifyEmailMail,
} from '../../mails';
import { WorkspaceProps } from '../../mails/components';
import { Config } from '../config';
import { MailerServiceIsNotConfigured } from '../error';
import { URLHelper } from '../helpers';
import { metrics } from '../metrics';
import type { MailerService, Options } from './mailer';
import { MAILER_SERVICE } from './mailer';
import {
emailTemplate,
getRoleChangedTemplate,
type RoleChangedMailParams,
} from './template';
type Props<T extends EmailRenderer<any>> =
T extends EmailRenderer<infer P> ? P : never;
type Sender<T extends EmailRenderer<any>> = (
to: string,
props: Props<T>
) => Promise<SMTPTransport.SentMessageInfo>;
function make<T extends EmailRenderer<any>>(
sender: {
send: (options: Options) => Promise<SMTPTransport.SentMessageInfo>;
},
renderer: T,
factory?: (props: Props<T>) => {
props: Props<T>;
options: Partial<Options>;
}
): Sender<T> {
return async (to, props) => {
const { props: overrideProps, options } = factory
? factory(props)
: { props, options: {} };
const { html, subject } = await renderer(overrideProps);
return sender.send({
to,
subject,
html,
...options,
});
};
}
@Injectable()
export class MailService {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
@Optional() @Inject(MAILER_SERVICE) private readonly mailer?: MailerService
) {}
async sendMail(options: Options) {
readonly send = async (options: Options) => {
if (!this.mailer) {
throw new MailerServiceIsNotConfigured();
}
@@ -39,410 +94,98 @@ export class MailService {
metrics.mail.counter('error').add(1);
throw e;
}
};
private make<T extends EmailRenderer<any>>(
renderer: T,
factory?: (props: Props<T>) => {
props: Props<T>;
options: Partial<Options>;
}
) {
return make(this, renderer, factory);
}
private readonly convertWorkspaceProps = <
T extends { workspace: WorkspaceProps },
>(
props: T
) => {
return {
props: {
...props,
workspace: {
...props.workspace,
avatar: 'cid:workspaceAvatar',
},
},
options: {
attachments: [
{
cid: 'workspaceAvatar',
filename: 'workspaceAvatar',
content: props.workspace.avatar,
encoding: 'base64',
},
],
},
};
};
private makeWorkspace<T extends EmailRenderer<any>>(renderer: T) {
return this.make(renderer, this.convertWorkspaceProps);
}
hasConfigured() {
return !!this.mailer;
}
async sendInviteEmail(
to: string,
inviteId: string,
invitationInfo: {
workspace: {
id: string;
name: string;
avatar: string;
};
user: {
avatar: string;
name: string;
};
}
) {
const buttonUrl = this.url.link(`/invite/${inviteId}`);
const workspaceAvatar = invitationInfo.workspace.avatar;
// User mails
sendSignUpMail = this.make(renderSignUpMail);
sendSignInMail = this.make(renderSignInMail);
sendChangePasswordMail = this.make(renderChangePasswordMail);
sendSetPasswordMail = this.make(renderSetPasswordMail);
sendChangeEmailMail = this.make(renderChangeEmailMail);
sendVerifyChangeEmail = this.make(renderVerifyChangeEmailMail);
sendVerifyEmail = this.make(renderVerifyEmailMail);
sendNotificationChangeEmail = make(this, renderChangeEmailNotificationMail);
const content = `<p style="margin:0">${
invitationInfo.user.avatar
? `<img
src="${invitationInfo.user.avatar}"
alt=""
width="24px"
height="24px"
style="width:24px; height:24px; border-radius: 12px;object-fit: cover;vertical-align: middle"
/>`
: ''
}
<span style="font-weight:500;margin-right: 4px;">${
invitationInfo.user.name
}</span>
<span>invited you to join</span>
<img
src="cid:workspaceAvatar"
alt=""
width="24px"
height="24px"
style="width:24px; height:24px; margin-left:4px;border-radius: 12px;object-fit: cover;vertical-align: middle"
/>
<span style="font-weight:500;margin-right: 4px;">${
invitationInfo.workspace.name
}</span></p><p style="margin-top:8px;margin-bottom:0;">Click button to join this workspace</p>`;
const html = emailTemplate({
title: 'You are invited!',
content,
buttonContent: 'Accept & Join',
buttonUrl,
});
return this.sendMail({
to,
subject: `${invitationInfo.user.name} invited you to join ${invitationInfo.workspace.name}`,
html,
attachments: [
{
cid: 'workspaceAvatar',
filename: 'image.png',
content: workspaceAvatar,
encoding: 'base64',
},
],
});
}
async sendSignUpMail(url: string, options: Options) {
const html = emailTemplate({
title: 'Create AFFiNE Account',
content:
'Click the button below to complete your account creation and sign in. This magic link will expire in 30 minutes.',
buttonContent: ' Create account and sign in',
buttonUrl: url,
});
return this.sendMail({
html,
subject: 'Your AFFiNE account is waiting for you!',
...options,
});
}
async sendSignInMail(url: string, options: Options) {
const html = emailTemplate({
title: 'Sign in to AFFiNE',
content:
'Click the button below to securely sign in. The magic link will expire in 30 minutes.',
buttonContent: 'Sign in to AFFiNE',
buttonUrl: url,
});
return this.sendMail({
html,
subject: 'Sign in to AFFiNE',
...options,
});
}
async sendChangePasswordEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Modify your AFFiNE password',
content:
'Click the button below to reset your password. The magic link will expire in 30 minutes.',
buttonContent: 'Set new password',
buttonUrl: url,
});
return this.sendMail({
to,
subject: `Modify your AFFiNE password`,
html,
});
}
async sendSetPasswordEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Set your AFFiNE password',
content:
'Click the button below to set your password. The magic link will expire in 30 minutes.',
buttonContent: 'Set your password',
buttonUrl: url,
});
return this.sendMail({
to,
subject: `Set your AFFiNE password`,
html,
});
}
async sendChangeEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Verify your current email for AFFiNE',
content:
'You recently requested to change the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
buttonContent: 'Verify and set up a new email address',
buttonUrl: url,
});
return this.sendMail({
to,
subject: `Verify your current email for AFFiNE`,
html,
});
}
async sendVerifyChangeEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Verify your new email address',
content:
'You recently requested to change the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
buttonContent: 'Verify your new email address',
buttonUrl: url,
});
return this.sendMail({
to,
subject: `Verify your new email for AFFiNE`,
html,
});
}
async sendVerifyEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Verify your email address',
content:
'You recently requested to verify the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
buttonContent: 'Verify your email address',
buttonUrl: url,
});
return this.sendMail({
to,
subject: `Verify your email for AFFiNE`,
html,
});
}
async sendNotificationChangeEmail(to: string) {
const html = emailTemplate({
title: 'Email change successful',
content: `As per your request, we have changed your email. Please make sure you're using ${to} when you log in the next time. `,
});
return this.sendMail({
to,
subject: `Your email has been changed`,
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({
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({
to,
subject: title,
html,
});
}
// =================== Workspace Mails ===================
sendMemberInviteMail = this.makeWorkspace(renderMemberInvitationMail);
sendMemberAcceptedEmail = this.makeWorkspace(renderMemberAcceptedMail);
sendMemberLeaveEmail = this.makeWorkspace(renderMemberLeaveMail);
sendLinkInvitationReviewRequestMail = this.makeWorkspace(
renderLinkInvitationReviewRequestMail
);
sendLinkInvitationApproveMail = this.makeWorkspace(
renderLinkInvitationApproveMail
);
sendLinkInvitationDeclineMail = this.makeWorkspace(
renderLinkInvitationDeclineMail
);
sendMemberRemovedMail = this.makeWorkspace(renderMemberRemovedMail);
sendOwnershipTransferredMail = this.makeWorkspace(
renderOwnershipTransferredMail
);
sendOwnershipReceivedMail = this.makeWorkspace(renderOwnershipReceivedMail);
// =================== Team Workspace Mails ===================
async sendTeamWorkspaceUpgradedEmail(
to: string,
ws: { id: string; name: string; isOwner: boolean }
) {
const { id: workspaceId, name: workspaceName, isOwner } = ws;
const baseContent = {
subject: `${workspaceName} has been upgraded to team workspace! 🎉`,
title: 'Welcome to the team workspace!',
content: `Great news! ${workspaceName} has been upgraded to team workspace by the workspace owner. You now have access to the following enhanced features:`,
};
if (isOwner) {
baseContent.subject =
'Your workspace has been upgraded to team workspace! 🎉';
baseContent.title = 'Welcome to the team workspace!';
baseContent.content = `${workspaceName} has been upgraded to team workspace with the following benefits:`;
}
const html = emailTemplate({
title: baseContent.title,
content: `${baseContent.content}
✓ 100 GB initial storage + 20 GB per seat
✓ 500 MB of maximum file size
✓ Unlimited team members (10+ seats)
✓ Multiple admin roles
✓ Priority customer support`,
buttonContent: 'Open Workspace',
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
});
return this.sendMail({ to, subject: baseContent.subject, html });
}
async sendReviewRequestEmail(
to: string,
invitee: string,
ws: { id: string; name: string }
) {
const { id: workspaceId, name: workspaceName } = ws;
const title = `New request to join ${workspaceName}`;
const html = emailTemplate({
title: 'Request to join your workspace',
content: `${invitee} has requested to join ${workspaceName}. As a workspace owner/admin, you can approve or decline this request.`,
buttonContent: 'Review request',
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
});
return this.sendMail({ to, subject: title, html });
}
async sendReviewApproveEmail(to: string, ws: { id: string; name: string }) {
const { id: workspaceId, name: workspaceName } = ws;
const title = `Your request to join ${workspaceName} has been approved`;
const html = emailTemplate({
title: 'Welcome to the workspace!',
content: `Your request to join ${workspaceName} has been accepted. You can now access the team workspace and collaborate with other members.`,
buttonContent: 'Open Workspace',
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
});
return this.sendMail({ to, subject: title, html });
}
async sendReviewDeclinedEmail(to: string, ws: { name: string }) {
const { name: workspaceName } = ws;
const title = `Your request to join ${workspaceName} was declined`;
const html = emailTemplate({
title: 'Request declined',
content: `Your request to join ${workspaceName} has been declined by the workspace admin.`,
});
return this.sendMail({ to, subject: title, html });
}
async sendRoleChangedEmail(to: string, ws: RoleChangedMailParams) {
const { subject, title, content } = getRoleChangedTemplate(ws);
const html = emailTemplate({ title, content });
console.log({ subject, title, content, to });
return this.sendMail({ to, subject, html });
}
async sendOwnershipTransferredEmail(to: string, ws: { name: string }) {
const { name: workspaceName } = ws;
const title = `Your ownership of ${workspaceName} has been transferred`;
const html = emailTemplate({
title: 'Ownership transferred',
content: `You have transferred ownership of ${workspaceName}. You are now a admin in this workspace.`,
});
return this.sendMail({ to, subject: title, html });
}
async sendMemberRemovedEmail(to: string, ws: { name: string }) {
const { name: workspaceName } = ws;
const title = `You have been removed from ${workspaceName}`;
const html = emailTemplate({
title: 'Workspace access removed',
content: `You have been removed from {workspace name}. You no longer have access to this workspace.`,
});
return this.sendMail({ to, subject: title, html });
}
async sendWorkspaceExpireRemindEmail(
to: string,
ws: {
id: string;
name: string;
expirationDate: Date;
deletionDate?: Date;
}
) {
const {
id: workspaceId,
name: workspaceName,
expirationDate,
deletionDate,
} = ws;
const baseContent: {
subject: string;
title: string;
content: string;
button?: { buttonContent: string; buttonUrl: string };
} = {
subject: `[Action Required] Your ${workspaceName} team workspace is expiring soon`,
title: 'Team workspace expiring soon',
content: `Your ${workspaceName} team workspace will expire on ${expirationDate}. After expiration, you won't be able to sync or collaborate with team members. Please renew your subscription to continue using all team features.`,
button: {
buttonContent: 'Go to Billing',
// TODO(@darkskygit): use real billing path
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
},
};
if (deletionDate) {
if (deletionDate.getTime() > Date.now()) {
// in 24 hours
if (deletionDate.getTime() - Date.now() < 24 * 60 * 60 * 1000) {
baseContent.subject = `[Action Required] Final warning: Your ${workspaceName} data will be deleted in 24 hours`;
baseContent.title = 'Urgent: Last chance to prevent data loss';
baseContent.content = `Your ${workspaceName} team workspace data will be permanently deleted in 24 hours on ${deletionDate}. To prevent data loss, please take immediate action:
<li>Renew your subscription to restore team features</li>
<li>Export your workspace data from Workspace Settings > Export Workspace</li>`;
} else {
baseContent.subject = `[Action Required] Important: Your ${workspaceName} data will be deleted soon`;
baseContent.title = 'Take action to prevent data loss';
baseContent.content = `Your ${workspaceName} team workspace expired on ${expirationDate}. All workspace data will be permanently deleted on ${deletionDate} (180 days after expiration). To prevent data loss, please either:
<li>Renew your subscription to restore team features</li>
<li>Export your workspace data from Workspace Settings > Export Workspace</li>`;
}
} else {
baseContent.subject = `Data deletion completed for ${workspaceName}`;
baseContent.title = 'Workspace data deleted';
baseContent.content = `All data in ${workspaceName} has been permanently deleted as the workspace remained expired for 180 days. This action cannot be undone.
Thank you for your support of AFFiNE. We hope to see you again in the future.`;
baseContent.button = undefined;
}
} else if (expirationDate.getTime() < Date.now()) {
baseContent.subject = `Your ${workspaceName} team workspace has expired`;
baseContent.title = 'Team workspace expired';
baseContent.content = `Your ${workspaceName} team workspace expired on ${expirationDate}. Your workspace can't sync or collaborate with team members. Please renew your subscription to restore all team features.`;
}
const html = emailTemplate({
title: baseContent.title,
content: baseContent.content,
...baseContent.button,
});
return this.sendMail({ to, subject: baseContent.subject, html });
}
sendTeamWorkspaceUpgradedEmail = this.makeWorkspace(
renderTeamWorkspaceUpgradedMail
);
sendTeamBecomeAdminMail = this.makeWorkspace(renderTeamBecomeAdminMail);
sendTeamBecomeCollaboratorMail = this.makeWorkspace(
renderTeamBecomeCollaboratorMail
);
sendTeamDeleteIn24HoursMail = this.makeWorkspace(
renderTeamDeleteIn24HoursMail
);
sendTeamDeleteIn1MonthMail = this.makeWorkspace(renderTeamDeleteIn1MonthMail);
sendTeamWorkspaceDeletedMail = this.makeWorkspace(
renderTeamWorkspaceDeletedMail
);
sendTeamExpireSoonMail = this.makeWorkspace(
renderTeamWorkspaceExpireSoonMail
);
sendTeamExpiredMail = this.makeWorkspace(renderTeamWorkspaceExpiredMail);
}

View File

@@ -1,256 +0,0 @@
export const emailTemplate = ({
title,
content,
buttonContent,
buttonUrl,
subContent,
}: {
title: string;
content: string;
buttonContent?: string;
buttonUrl?: string;
subContent?: string;
}) => {
return `<body style="background: #f6f7fb; overflow: hidden">
<table
width="100%"
border="0"
cellpadding="24px"
style="
background: #fff;
max-width: 450px;
margin: 32px auto 0 auto;
border-radius: 16px 16px 0 0;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
"
>
<tr>
<td>
<a href="https://affine.pro" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
alt="AFFiNE log"
height="32px"
/>
</a>
</td>
</tr>
<tr>
<td
style="
font-size: 20px;
font-weight: 600;
line-height: 28px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #444;
padding-top: 0;
"
>${title}</td>
</tr>
<tr>
<td
style="
font-size: 15px;
font-weight: 400;
line-height: 24px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #444;
padding-top: 0;
"
>${content}</td>
</tr>
${
buttonContent && buttonUrl
? `<tr>
<td style="margin-left: 24px; padding-top: 0; padding-bottom: ${
subContent ? '0' : '64px'
}">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 8px" bgcolor="#1E96EB">
<a
href="${buttonUrl}"
target="_blank"
style="
font-size: 15px;
font-family: inter, Arial, Helvetica, sans-serif;
font-weight: 600;
line-height: 24px;
color: #fff;
text-decoration: none;
border-radius: 8px;
padding: 8px 18px;
border: 1px solid rgba(0,0,0,.1);
display: inline-block;
font-weight: bold;
"
>${buttonContent}</a
>
</td>
</tr>
</table>
</td>
</tr>`
: ''
}
${
subContent
? `<tr>
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #444;
padding-top: 24px;
"
>
${subContent}
</td>
</tr>`
: ''
}
</table>
<table
width="100%"
border="0"
style="
background: #fafafa;
max-width: 450px;
margin: 0 auto 32px auto;
border-radius: 0 0 16px 16px;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
padding: 20px;
"
>
<tr align="center">
<td>
<table cellpadding="0">
<tr>
<td style="padding: 0 10px">
<a
href="https://github.com/toeverything/AFFiNE"
target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
alt="AFFiNE github link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://twitter.com/AffineOfficial" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
alt="AFFiNE twitter link"
height="16px"
/>
</a>
</td>
<td style="padding: 0 10px">
<a href="https://discord.gg/whd5mjYqVw" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
alt="AFFiNE discord link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.youtube.com/@affinepro" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
alt="AFFiNE youtube link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://t.me/affineworkos" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
alt="AFFiNE telegram link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.reddit.com/r/Affine/" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
alt="AFFiNE reddit link"
height="16px"
/></a>
</td>
</tr>
</table>
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #8e8d91;
padding-top: 8px;
"
>
One hyper-fused platform for wildly creative minds
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #8e8d91;
padding-top: 8px;
"
>
Copyright<img
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
alt="copyright"
height="14px"
style="vertical-align: middle; margin: 0 4px"
/>2023-${new Date().getUTCFullYear()} Toeverything
</td>
</tr>
</table>
</body>`;
};
type RoleChangedMail = {
subject: string;
title: string;
content: string;
};
export type RoleChangedMailParams = {
name: string;
role: 'owner' | 'admin' | 'member' | 'readonly';
};
export const getRoleChangedTemplate = (
ws: RoleChangedMailParams
): RoleChangedMail => {
const { name, role } = ws;
let subject = `You are now an ${role} of ${name}`;
let title = 'Role update in workspace';
let content = `Your role in ${name} has been changed to ${role}. You can continue to collaborate in this workspace.`;
switch (role) {
case 'owner':
title = 'Welcome, new workspace owner!';
content = `You have been assigned as the owner of ${name}. As a workspace owner, you have full control over this team workspace.`;
break;
case 'admin':
title = `You've been promoted to admin.`;
content = `You have been promoted to admin of ${name}. As an admin, you can help the workspace owner manage members in this workspace.`;
break;
default:
subject = `Your role has been changed in ${name}`;
break;
}
return { subject, title, content };
};

View File

@@ -302,31 +302,29 @@ export class AuthService implements OnApplicationBootstrap {
}
async sendChangePasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangePasswordEmail(email, callbackUrl);
return this.mailer.sendChangePasswordMail(email, { url: callbackUrl });
}
async sendSetPasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendSetPasswordEmail(email, callbackUrl);
return this.mailer.sendSetPasswordMail(email, { url: callbackUrl });
}
async sendChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangeEmail(email, callbackUrl);
return this.mailer.sendChangeEmailMail(email, { url: callbackUrl });
}
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyChangeEmail(email, callbackUrl);
return this.mailer.sendVerifyChangeEmail(email, { url: callbackUrl });
}
async sendVerifyEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyEmail(email, callbackUrl);
return this.mailer.sendVerifyEmail(email, { url: callbackUrl });
}
async sendNotificationChangeEmail(email: string) {
return this.mailer.sendNotificationChangeEmail(email);
return this.mailer.sendNotificationChangeEmail(email, {
to: email,
});
}
async sendSignInEmail(email: string, link: string, signUp: boolean) {
return signUp
? await this.mailer.sendSignUpMail(link, {
to: email,
})
: await this.mailer.sendSignInMail(link, {
to: email,
});
? await this.mailer.sendSignUpMail(email, { url: link })
: await this.mailer.sendSignInMail(email, { url: link });
}
}

View File

@@ -7,6 +7,7 @@ import {
type EventPayload,
MailService,
OnEvent,
URLHelper,
UserNotFound,
} from '../../../base';
import { Models } from '../../../models';
@@ -23,13 +24,6 @@ export type InviteInfo = {
inviteeUserId?: string;
};
const PermissionToRole = {
[Permission.Read]: 'readonly' as const,
[Permission.Write]: 'member' as const,
[Permission.Admin]: 'admin' as const,
[Permission.Owner]: 'owner' as const,
};
@Injectable()
export class WorkspaceService {
private readonly logger = new Logger(WorkspaceService.name);
@@ -41,7 +35,8 @@ export class WorkspaceService {
private readonly mailer: MailService,
private readonly permission: PermissionService,
private readonly prisma: PrismaClient,
private readonly models: Models
private readonly models: Models,
private readonly url: URLHelper
) {}
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
@@ -87,7 +82,7 @@ export class WorkspaceService {
return {
avatar,
id: workspaceId,
name: workspaceContent?.name ?? '',
name: workspaceContent?.name ?? 'Untitled Workspace',
};
}
@@ -130,26 +125,47 @@ export class WorkspaceService {
return false;
}
await this.mailer.sendAcceptedEmail(inviter.email, {
inviteeName: invitee.name,
workspaceName: workspace.name,
await this.mailer.sendMemberAcceptedEmail(inviter.email, {
user: invitee,
workspace,
});
return true;
}
async sendInviteEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) {
return;
}
const owner = await this.permission.getWorkspaceOwner(target.workspace.id);
await this.mailer.sendMemberInviteMail(target.email, {
workspace: target.workspace,
user: owner,
url: this.url.link(`/invite/${inviteId}`),
});
}
// ================ Team ================
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId);
const admin = await this.permission.getWorkspaceAdmin(workspaceId);
const admins = await this.permission.getWorkspaceAdmin(workspaceId);
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
...workspace,
workspace,
isOwner: true,
url: this.url.link(`/workspace/${workspaceId}`),
});
for (const user of admin) {
for (const user of admins) {
await this.mailer.sendTeamWorkspaceUpgradedEmail(user.email, {
...workspace,
workspace,
isOwner: false,
url: this.url.link(`/workspace/${workspaceId}`),
});
}
}
@@ -174,48 +190,34 @@ export class WorkspaceService {
const admin = await this.permission.getWorkspaceAdmin(workspaceId);
for (const user of [owner, ...admin]) {
await this.mailer.sendReviewRequestEmail(
user.email,
invitee.email,
workspace
);
await this.mailer.sendLinkInvitationReviewRequestMail(user.email, {
workspace,
user: invitee,
url: this.url.link(`/workspace/${workspace.id}`),
});
}
}
async sendInviteEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) {
return;
}
const owner = await this.permission.getWorkspaceOwner(target.workspace.id);
await this.mailer.sendInviteEmail(target.email, inviteId, {
workspace: target.workspace,
user: {
avatar: owner.avatarUrl || '',
name: owner.name || '',
},
});
}
async sendReviewApproveEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) return;
if (!target) {
return;
}
await this.mailer.sendReviewApproveEmail(target.email, target.workspace);
await this.mailer.sendLinkInvitationApproveMail(target.email, {
workspace: target.workspace,
url: this.url.link(`/workspace/${target.workspace.id}`),
});
}
async sendReviewDeclinedEmail(
email: string | undefined,
workspaceName: string
workspaceId: string
) {
if (!email) return;
await this.mailer.sendReviewDeclinedEmail(email, { name: workspaceName });
const workspace = await this.getWorkspaceInfo(workspaceId);
await this.mailer.sendLinkInvitationDeclineMail(email, {
workspace,
});
}
async sendRoleChangedEmail(
@@ -224,24 +226,42 @@ export class WorkspaceService {
) {
const user = await this.models.user.getPublicUser(userId);
if (!user) throw new UserNotFound();
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendRoleChangedEmail(user?.email, {
name: workspace.name,
role: PermissionToRole[ws.role],
});
if (ws.role === Permission.Admin) {
await this.mailer.sendTeamBecomeAdminMail(user.email, {
workspace,
url: this.url.link(`/workspace/${workspace.id}`),
});
} else {
await this.mailer.sendTeamBecomeCollaboratorMail(user.email, {
workspace,
url: this.url.link(`/workspace/${workspace.id}`),
});
}
}
async sendOwnerTransferredEmail(email: string, ws: { id: string }) {
async sendOwnershipTransferredEmail(email: string, ws: { id: string }) {
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendOwnershipTransferredEmail(email, {
name: workspace.name,
});
await this.mailer.sendOwnershipTransferredMail(email, { workspace });
}
async sendMemberRemoved(email: string, ws: { id: string }) {
async sendOwnershipReceivedEmail(email: string, ws: { id: string }) {
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendMemberRemovedEmail(email, {
name: workspace.name,
await this.mailer.sendOwnershipReceivedMail(email, { workspace });
}
@OnEvent('workspace.members.leave')
async onMemberLeave({
user,
workspaceId,
}: EventPayload<'workspace.members.leave'>) {
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId);
await this.mailer.sendMemberLeaveEmail(owner.email, {
workspace,
user,
});
}
@@ -252,22 +272,8 @@ export class WorkspaceService {
}: EventPayload<'workspace.members.requestDeclined'>) {
const user = await this.models.user.get(userId);
if (!user) return;
await this.sendMemberRemoved(user.email, { id: workspaceId });
}
@OnEvent('workspace.subscription.notify')
async onSubscriptionNotify({
workspaceId,
expirationDate,
deletionDate,
}: EventPayload<'workspace.subscription.notify'>) {
const owner = await this.permission.getWorkspaceOwner(workspaceId);
if (!owner) return;
const workspace = await this.getWorkspaceInfo(workspaceId);
await this.mailer.sendWorkspaceExpireRemindEmail(owner.email, {
...workspace,
expirationDate,
deletionDate,
});
await this.mailer.sendMemberRemovedMail(user.email, { workspace });
}
}

View File

@@ -312,15 +312,17 @@ export class TeamWorkspaceResolver {
);
if (result) {
this.event.emit('workspace.members.roleChanged', {
userId,
workspaceId,
permission,
});
if (permission === Permission.Owner) {
this.event.emit('workspace.members.ownerTransferred', {
email: user.email,
this.event.emit('workspace.members.ownershipTransferred', {
workspaceId,
from: user.id,
to: userId,
});
} else {
this.event.emit('workspace.members.roleChanged', {
userId,
workspaceId,
permission,
});
}
}
@@ -347,20 +349,6 @@ export class TeamWorkspaceResolver {
await this.workspaceService.sendReviewRequestedEmail(inviteId);
}
@OnEvent('workspace.members.requestDeclined')
async onDeclineRequest({
userId,
workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) {
const user = await this.models.user.getPublicUser(userId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
// send decline mail
await this.workspaceService.sendReviewDeclinedEmail(
user?.email,
workspace.name
);
}
@OnEvent('workspace.members.requestApproved')
async onApproveRequest({
inviteId,
@@ -369,6 +357,19 @@ export class TeamWorkspaceResolver {
await this.workspaceService.sendReviewApproveEmail(inviteId);
}
@OnEvent('workspace.members.requestDeclined')
async onDeclineRequest({
userId,
workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) {
const user = await this.models.user.getPublicUser(userId);
// send decline mail
await this.workspaceService.sendReviewDeclinedEmail(
user?.email,
workspaceId
);
}
@OnEvent('workspace.members.roleChanged')
async onRoleChanged({
userId,
@@ -382,14 +383,29 @@ export class TeamWorkspaceResolver {
});
}
@OnEvent('workspace.members.ownerTransferred')
@OnEvent('workspace.members.ownershipTransferred')
async onOwnerTransferred({
email,
workspaceId,
}: EventPayload<'workspace.members.ownerTransferred'>) {
// send role changed mail
await this.workspaceService.sendOwnerTransferredEmail(email, {
id: workspaceId,
});
from,
to,
}: EventPayload<'workspace.members.ownershipTransferred'>) {
// send ownership transferred mail
const fromUser = await this.models.user.getPublicUser(from);
const toUser = await this.models.user.getPublicUser(to);
if (fromUser) {
await this.workspaceService.sendOwnershipTransferredEmail(
fromUser.email,
{
id: workspaceId,
}
);
}
if (toUser) {
await this.workspaceService.sendOwnershipReceivedEmail(toUser.email, {
id: workspaceId,
});
}
}
}

View File

@@ -20,7 +20,6 @@ import {
DocNotFound,
EventEmitter,
InternalServerError,
MailService,
MemberQuotaExceeded,
RequestMutex,
SpaceAccessDenied,
@@ -79,7 +78,6 @@ export class WorkspaceResolver {
constructor(
private readonly cache: Cache,
private readonly mailer: MailService,
private readonly prisma: PrismaClient,
private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService,
@@ -611,17 +609,18 @@ export class WorkspaceResolver {
_workspaceName?: string
) {
await this.permissions.checkWorkspace(workspaceId, user.id);
const { name: workspaceName } =
await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const success = this.permissions.revokeWorkspace(workspaceId, user.id);
if (sendLeaveMail) {
await this.mailer.sendLeaveWorkspaceEmail(owner.email, {
workspaceName,
inviteeName: user.name,
this.event.emit('workspace.members.leave', {
workspaceId,
user: {
id: user.id,
email: user.email,
},
});
}
return this.permissions.revokeWorkspace(workspaceId, user.id);
return success;
}
}

View File

@@ -0,0 +1,10 @@
import { UserProps } from './components';
import { WorkspaceProps } from './components/workspace';
export const TEST_USER: UserProps = {
email: 'test@test.com',
};
export const TEST_WORKSPACE: WorkspaceProps = {
name: 'Test Workspace',
};

View File

@@ -0,0 +1,10 @@
import type { CSSProperties } from 'react';
export const BasicTextStyle: CSSProperties = {
fontSize: '15px',
fontWeight: '400',
lineHeight: '24px',
fontFamily: 'Inter, Arial, Helvetica, sans-serif',
margin: '24px 0 0',
color: '#141414',
};

View File

@@ -0,0 +1,68 @@
import { Container, Img, Link, Row, Section } from '@react-email/components';
import type { CSSProperties } from 'react';
import { BasicTextStyle } from './common';
const TextStyles: CSSProperties = {
...BasicTextStyle,
color: '#8e8d91',
margin: 1,
marginTop: '8px',
};
export const Footer = () => {
return (
<Container
style={{
backgroundColor: '#fafafa',
maxWidth: '450px',
margin: '0 auto 32px auto',
borderRadius: '0 0 16px 16px',
boxShadow: '0px 0px 20px 0px rgba(66, 65, 73, 0.04)',
padding: '24px',
}}
>
<Section align="center" width="auto" style={{ margin: '1px auto' }}>
<Row>
{[
'Github',
'Twitter',
'Discord',
'Youtube',
'Telegram',
'Reddit',
].map(platform => (
<td key={platform} style={{ padding: '0 10px' }}>
<Link href={`https://affine.pro/${platform.toLowerCase()}`}>
<Img
src={`https://cdn.affine.pro/mail/2023-8-9/${platform}.png`}
alt={`affine ${platform.toLowerCase()} link`}
height="16px"
/>
</Link>
</td>
))}
</Row>
</Section>
<Section align="center" width="auto">
<Row style={TextStyles}>
<td>One hyper-fused platform for wildly creative minds</td>
</Row>
</Section>
<Section align="center" width="auto">
<Row style={TextStyles}>
<td>Copyright</td>
<td>
<Img
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
alt="copyright"
height="14px"
style={{ verticalAlign: 'middle', margin: '0 4px' }}
/>
</td>
<td>2023-{new Date().getUTCFullYear()} ToEverything</td>
</Row>
</Section>
</Container>
);
};

View File

@@ -0,0 +1,3 @@
export * from './template';
export * from './user';
export * from './workspace';

View File

@@ -0,0 +1,207 @@
import {
Body,
Button as EmailButton,
Container,
Head,
Html,
Img,
Link,
Row,
Section,
Text as EmailText,
} from '@react-email/components';
import type { CSSProperties, PropsWithChildren } from 'react';
import { Footer } from './footer';
const BasicTextStyle: CSSProperties = {
fontSize: '15px',
fontWeight: '400',
lineHeight: '24px',
fontFamily: 'Inter, Arial, Helvetica, sans-serif',
margin: '24px 0 0',
color: '#141414',
};
export function Title(props: PropsWithChildren) {
return (
<EmailText
style={{
...BasicTextStyle,
fontSize: '20px',
fontWeight: '600',
lineHeight: '28px',
}}
>
{props.children}
</EmailText>
);
}
export function P(props: PropsWithChildren) {
return <EmailText style={BasicTextStyle}>{props.children}</EmailText>;
}
export function Text(props: PropsWithChildren) {
return <span style={BasicTextStyle}>{props.children}</span>;
}
export function Bold(props: PropsWithChildren) {
return <span style={{ fontWeight: 600 }}>{props.children}</span>;
}
export const Avatar = (props: {
img: string;
width?: string;
height?: string;
}) => {
return (
<img
src={props.img}
alt="avatar"
style={{
width: props.width || '20px',
height: props.height || '20px',
borderRadius: '12px',
objectFit: 'cover',
verticalAlign: 'middle',
}}
/>
);
};
export const Name = (props: PropsWithChildren) => {
return <Bold>{props.children}</Bold>;
};
export const AvatarWithName = (props: {
img?: string;
name: string;
width?: string;
height?: string;
}) => {
return (
<>
{props.img && (
<Avatar img={props.img} width={props.width} height={props.height} />
)}
<Name>{props.name}</Name>
</>
);
};
export function Content(props: PropsWithChildren) {
return typeof props.children === 'string' ? (
<EmailText>{props.children}</EmailText>
) : (
props.children
);
}
export function Button(
props: PropsWithChildren<{ type?: 'primary' | 'secondary'; href: string }>
) {
const style = {
...BasicTextStyle,
backgroundColor: props.type === 'secondary' ? '#FFFFFF' : '#1E96EB',
color: props.type === 'secondary' ? '#141414' : '#FFFFFF',
textDecoration: 'none',
fontWeight: '600',
padding: '8px 18px',
borderRadius: '8px',
border: '1px solid rgba(0,0,0,.1)',
marginRight: '4px',
};
return (
<EmailButton style={style} href={props.href}>
{props.children}
</EmailButton>
);
}
function fetchTitle(
children: React.ReactElement<PropsWithChildren>[]
): React.ReactElement {
const title = children.find(child => child.type === Title);
if (!title || !title.props.children) {
throw new Error('<Title /> is required for an email.');
}
return title;
}
function fetchContent(
children: React.ReactElement<PropsWithChildren>[]
): React.ReactElement | React.ReactElement[] {
const content = children.find(child => child.type === Content);
if (!content || !content.props.children) {
throw new Error('<Content /> is required for an email.');
}
if (Array.isArray(content.props.children)) {
return content.props.children.map((child, i) => {
/* oxlint-disable-next-line eslint-plugin-react/no-array-index-key */
return <Row key={i}>{child}</Row>;
});
}
return content;
}
function assertChildrenIsArray(
children: React.ReactNode
): asserts children is React.ReactElement<PropsWithChildren>[] {
if (!Array.isArray(children) || !children.every(child => 'type' in child)) {
throw new Error(
'Children of `Template` element must be an array of [<Title />, <Content />, ...]'
);
}
}
export function Template(props: PropsWithChildren) {
assertChildrenIsArray(props.children);
const content = (
<>
<Section>{fetchTitle(props.children)}</Section>
<Section>{fetchContent(props.children)}</Section>
</>
);
if (typeof AFFiNE !== 'undefined' && AFFiNE.node.test) {
return content;
}
return (
<Html>
<Head />
<Body style={{ backgroundColor: '#f6f7fb', overflow: 'hidden' }}>
<Container
style={{
backgroundColor: '#fff',
maxWidth: '450px',
margin: '32px auto 0',
borderRadius: '16px 16px 0 0',
boxShadow: '0px 0px 20px 0px rgba(66, 65, 73, 0.04)',
padding: '24px',
}}
>
<Section>
<Link href="https://affine.pro">
<Img
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
alt="AFFiNE logo"
height="32px"
/>
</Link>
</Section>
{content}
</Container>
<Footer />
</Body>
</Html>
);
}

View File

@@ -0,0 +1,9 @@
import { Name } from './template';
export interface UserProps {
email: string;
}
export const User = (props: UserProps) => {
return <Name>{props.email}</Name>;
};

View File

@@ -0,0 +1,18 @@
import { AvatarWithName } from './template';
export interface WorkspaceProps {
name: string;
avatar?: string;
size?: number;
}
export const Workspace = (props: WorkspaceProps) => {
return (
<AvatarWithName
name={props.name}
img={props.avatar}
width={`${props.size}px`}
height={`${props.size}px`}
/>
);
};

View File

@@ -0,0 +1,177 @@
import { render as rawRender } from '@react-email/render';
import {
TeamBecomeAdmin,
TeamBecomeCollaborator,
TeamDeleteIn24Hours,
TeamDeleteInOneMonth,
TeamExpired,
TeamExpireSoon,
TeamWorkspaceDeleted,
TeamWorkspaceUpgraded,
} from './teams';
import {
ChangeEmail,
ChangeEmailNotification,
ChangePassword,
SetPassword,
SignIn,
SignUp,
VerifyChangeEmail,
VerifyEmail,
} from './users';
import {
Invitation,
InvitationAccepted,
LinkInvitationApproved,
LinkInvitationReviewDeclined,
LinkInvitationReviewRequest,
MemberLeave,
MemberRemoved,
OwnershipReceived,
OwnershipTransferred,
} from './workspaces';
type EmailContent = {
subject: string;
html: string;
};
function render(component: React.ReactElement) {
return rawRender(component, {
pretty: AFFiNE.node.test,
});
}
type Props<T> = T extends React.FC<infer P> ? P : never;
export type EmailRenderer<Props> = (props: Props) => Promise<EmailContent>;
function make<T extends React.ComponentType<any>>(
Component: T,
subject: string | ((props: Props<T>) => string)
): EmailRenderer<Props<T>> {
return async props => {
if (!props && AFFiNE.node.test) {
// @ts-expect-error test only
props = Component.PreviewProps;
}
return {
subject: typeof subject === 'function' ? subject(props) : subject,
html: await render(<Component {...props} />),
};
};
}
// ================ User ================
export const renderSignInMail = make(SignIn, 'Sign in to AFFiNE');
export const renderSignUpMail = make(
SignUp,
'Your AFFiNE account is waiting for you!'
);
export const renderSetPasswordMail = make(
SetPassword,
'Set your AFFiNE password'
);
export const renderChangePasswordMail = make(
ChangePassword,
'Modify your AFFiNE password'
);
export const renderVerifyEmailMail = make(
VerifyEmail,
'Verify your email address'
);
export const renderChangeEmailMail = make(
ChangeEmail,
'Change your email address'
);
export const renderVerifyChangeEmailMail = make(
VerifyChangeEmail,
'Verify your new email address'
);
export const renderChangeEmailNotificationMail = make(
ChangeEmailNotification,
'Account email address changed'
);
// ================ Workspace ================
export const renderMemberInvitationMail = make(
Invitation,
props => `${props.user.email} invited you to join ${props.workspace.name}`
);
export const renderMemberAcceptedMail = make(
InvitationAccepted,
props => `${props.user.email} accepted your invitation`
);
export const renderMemberLeaveMail = make(
MemberLeave,
props => `${props.user.email} left ${props.workspace.name}`
);
export const renderLinkInvitationReviewRequestMail = make(
LinkInvitationReviewRequest,
props => `New request to join ${props.workspace.name}`
);
export const renderLinkInvitationApproveMail = make(
LinkInvitationApproved,
props => `Your request to join ${props.workspace.name} has been approved`
);
export const renderLinkInvitationDeclineMail = make(
LinkInvitationReviewDeclined,
props => `Your request to join ${props.workspace.name} was declined`
);
export const renderMemberRemovedMail = make(
MemberRemoved,
props => `You have been removed from ${props.workspace.name}`
);
export const renderOwnershipTransferredMail = make(
OwnershipTransferred,
props => `Your ownership of ${props.workspace.name} has been transferred`
);
export const renderOwnershipReceivedMail = make(
OwnershipReceived,
props => `You are now the owner of ${props.workspace.name}`
);
// ================ Team ================
export const renderTeamWorkspaceUpgradedMail = make(
TeamWorkspaceUpgraded,
props =>
props.isOwner
? 'Your workspace has been upgraded to team workspace! 🎉'
: `${props.workspace.name} has been upgraded to team workspace! 🎉`
);
export const renderTeamBecomeAdminMail = make(
TeamBecomeAdmin,
props => `You are now an admin of ${props.workspace.name}`
);
export const renderTeamBecomeCollaboratorMail = make(
TeamBecomeCollaborator,
props => `Your role has been changed in ${props.workspace.name}`
);
export const renderTeamDeleteIn24HoursMail = make(
TeamDeleteIn24Hours,
props =>
`[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours`
);
export const renderTeamDeleteIn1MonthMail = make(
TeamDeleteInOneMonth,
props =>
`[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon`
);
export const renderTeamWorkspaceDeletedMail = make(
TeamWorkspaceDeleted,
props => `Your workspace ${props.workspace.name} has been deleted`
);
export const renderTeamWorkspaceExpireSoonMail = make(
TeamExpireSoon,
props =>
`[Action Required] Your ${props.workspace.name} team workspace will expire soon`
);
export const renderTeamWorkspaceExpiredMail = make(
TeamExpired,
props => `Your ${props.workspace.name} team workspace has expired`
);

View File

@@ -0,0 +1,38 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type TeamBecomeAdminProps = {
workspace: WorkspaceProps;
url: string;
};
export default function TeamBecomeAdmin(props: TeamBecomeAdminProps) {
const { workspace, url } = props;
return (
<Template>
<Title>You&apos;ve been promoted to admin.</Title>
<Content>
<P>
You have been promoted to admin of <Workspace {...workspace} />. As an
admin, you can help the workspace owner manage members in this
workspace.
</P>
<Button href={url}>Go to Workspace</Button>
</Content>
</Template>
);
}
TeamBecomeAdmin.PreviewProps = {
workspace: TEST_WORKSPACE,
role: 'admin',
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,40 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type TeamBecomeCollaboratorProps = {
workspace: WorkspaceProps;
url: string;
};
export default function TeamBecomeCollaborator(
props: TeamBecomeCollaboratorProps
) {
const { workspace, url } = props;
return (
<Template>
<Title>Role update in workspace</Title>
<Content>
<P>
Your role in <Workspace {...workspace} /> has been changed to{' '}
collaborator. You can continue to collaborate in this workspace.
</P>
<Button href={url}>Go to Workspace</Button>
</Content>
</Template>
);
}
TeamBecomeCollaborator.PreviewProps = {
workspace: TEST_WORKSPACE,
role: 'admin',
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,55 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Text,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamDeleteInOneMonthProps {
workspace: WorkspaceProps;
expirationDate: Date;
deletionDate: Date;
url: string;
}
export default function TeamDeleteInOneMonth(props: TeamDeleteInOneMonthProps) {
const { workspace, expirationDate, deletionDate, url } = props;
return (
<Template>
<Title>Take action to prevent data loss</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace expired on{' '}
<Bold>{expirationDate.toLocaleDateString()}</Bold>. All workspace data
will be permanently deleted on{' '}
<Bold>{deletionDate.toLocaleDateString()}</Bold> (180 days after
expiration). To prevent data loss, please either:
<li>
<Text>Renew your subscription to restore team features</Text>
</li>
<li>
<Text>
Export your workspace data from Workspace Settings &gt; Export
Workspace
</Text>
</li>
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamDeleteInOneMonth.PreviewProps = {
workspace: TEST_WORKSPACE,
expirationDate: new Date('2025-01-01T00:00:00Z'),
deletionDate: new Date('2025-01-31T00:00:00Z'),
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,52 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Text,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamDeleteIn24HoursProps {
workspace: WorkspaceProps;
deletionDate: Date;
url: string;
}
export default function TeamDeleteIn24Hours(props: TeamDeleteIn24HoursProps) {
const { workspace, deletionDate, url } = props;
return (
<Template>
<Title>Urgent: Last chance to prevent data loss</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace data will be
permanently deleted in 24 hours on{' '}
<Bold>{deletionDate.toLocaleDateString()}</Bold>. To prevent data
loss, please take immediate action:
<li>
<Text>Renew your subscription to restore team features</Text>
</li>
<li>
<Text>
Export your workspace data from Workspace Settings &gt; Export
Workspace
</Text>
</li>
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamDeleteIn24Hours.PreviewProps = {
workspace: TEST_WORKSPACE,
deletionDate: new Date('2025-01-31T00:00:00Z'),
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,38 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamWorkspaceDeletedProps {
workspace: WorkspaceProps;
}
export default function TeamWorkspaceDeleted(props: TeamWorkspaceDeletedProps) {
const { workspace } = props;
return (
<Template>
<Title>Workspace data deleted</Title>
<Content>
<P>
All data in <Workspace {...workspace} /> has been permanently deleted
as the workspace remained expired for 180 days. This action cannot be
undone.
</P>
<P>
Thank you for your support of AFFiNE. We hope to see you again in the
future.
</P>
</Content>
</Template>
);
}
TeamWorkspaceDeleted.PreviewProps = {
workspace: TEST_WORKSPACE,
};

View File

@@ -0,0 +1,42 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamExpireSoonProps {
workspace: WorkspaceProps;
expirationDate: Date;
url: string;
}
export default function TeamExpireSoon(props: TeamExpireSoonProps) {
const { workspace, expirationDate, url } = props;
return (
<Template>
<Title>Team workspace will expire soon</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace will expire on{' '}
<Bold>{expirationDate.toLocaleDateString()}</Bold>. After expiration,
you won&apos;t be able to sync or collaborate with team members.
Please renew your subscription to continue using all team features.
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamExpireSoon.PreviewProps = {
workspace: TEST_WORKSPACE,
expirationDate: new Date('2025-01-01T00:00:00Z'),
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,42 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamExpiredProps {
workspace: WorkspaceProps;
expirationDate: Date;
url: string;
}
export default function TeamExpired(props: TeamExpiredProps) {
const { workspace, expirationDate, url } = props;
return (
<Template>
<Title>Team workspace expired</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace expired on{' '}
<Bold>{expirationDate.toLocaleDateString()}</Bold>. Your workspace
can&apos;t sync or collaborate with team members. Please renew your
subscription to restore all team features.
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamExpired.PreviewProps = {
workspace: TEST_WORKSPACE,
expirationDate: new Date('2025-01-01T00:00:00Z'),
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,29 @@
export {
default as TeamBecomeAdmin,
type TeamBecomeAdminProps,
} from './become-admin';
export {
default as TeamBecomeCollaborator,
type TeamBecomeCollaboratorProps,
} from './become-collaborator';
export {
default as TeamDeleteInOneMonth,
type TeamDeleteInOneMonthProps,
} from './delete-in-1m';
export {
default as TeamDeleteIn24Hours,
type TeamDeleteIn24HoursProps,
} from './delete-in-24h';
export {
default as TeamWorkspaceDeleted,
type TeamWorkspaceDeletedProps,
} from './deleted';
export {
default as TeamExpireSoon,
type TeamExpireSoonProps,
} from './expire-soon';
export { default as TeamExpired, type TeamExpiredProps } from './expired';
export {
default as TeamWorkspaceUpgraded,
type TeamWorkspaceUpgradedProps,
} from './workspace-upgraded';

View File

@@ -0,0 +1,57 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type TeamWorkspaceUpgradedProps = {
workspace: WorkspaceProps;
isOwner: boolean;
url: string;
};
export default function TeamWorkspaceUpgraded(
props: TeamWorkspaceUpgradedProps
) {
const { workspace, isOwner, url } = props;
return (
<Template>
<Title>Welcome to the team workspace!</Title>
<Content>
<P>
{isOwner ? (
<>
<Workspace {...workspace} /> has been upgraded to team workspace
with the following benefits:
</>
) : (
<>
Great news! <Workspace {...workspace} /> has been upgraded to team
workspace by the workspace owner.
<br />
You now have access to the following enhanced features:
</>
)}
<br /> 100 GB initial storage + 20 GB per seat
<br /> 500 MB of maximum file size
<br /> Unlimited team members (10+ seats)
<br /> Multiple admin roles
<br /> Priority customer support
</P>
<Button href={url}>Open Workspace</Button>
</Content>
</Template>
);
}
TeamWorkspaceUpgraded.PreviewProps = {
workspace: TEST_WORKSPACE,
isOwner: true,
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,25 @@
import { Content, Name, P, Template, Title } from '../components';
export type ChangeEmailNotificationProps = {
to: string;
};
export default function ChangeEmailNotification(
props: ChangeEmailNotificationProps
) {
return (
<Template>
<Title>Verify your current email for AFFiNE</Title>
<Content>
<P>
As per your request, we have changed your email. Please make sure
you&apos;re using <Name>{props.to}</Name> to log in the next time.
</P>
</Content>
</Template>
);
}
ChangeEmailNotification.PreviewProps = {
to: 'test@affine.pro',
};

View File

@@ -0,0 +1,25 @@
import { Button, Content, P, Template, Title } from '../components';
export type VerifyChangeEmailProps = {
url: string;
};
export default function VerifyChangeEmail(props: VerifyChangeEmailProps) {
return (
<Template>
<Title>Verify your new email address</Title>
<Content>
<P>
You recently requested to change the email address associated with
your AFFiNE account. To complete this process, please click on the
verification link below. This magic link will expire in 30 minutes.
</P>
<Button href={props.url}>Verify your new email address</Button>
</Content>
</Template>
);
}
VerifyChangeEmail.PreviewProps = {
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,29 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type ChangeEmailProps = {
url: string;
};
export default function ChangeEmail(props: ChangeEmailProps) {
return (
<Template>
<Title>Verify your current email for AFFiNE</Title>
<Content>
<P>
You recently requested to change the email address associated with
your AFFiNE account.
<br />
To complete this process, please click on the verification link below.
</P>
<P>
This magic link will expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Verify and set up a new email address</Button>
</Content>
</Template>
);
}
ChangeEmail.PreviewProps = {
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,29 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type VerifyEmailProps = {
url: string;
};
export default function VerifyEmail(props: VerifyEmailProps) {
return (
<Template>
<Title>Verify your email address</Title>
<Content>
<P>
You recently requested to verify the email address associated with
your AFFiNE account.
<br />
To complete this process, please click on the verification link below.
</P>
<P>
This magic link will expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Verify your email address</Button>
</Content>
</Template>
);
}
VerifyEmail.PreviewProps = {
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,17 @@
export { default as ChangeEmail, type ChangeEmailProps } from './email-change';
export {
default as ChangeEmailNotification,
type ChangeEmailNotificationProps,
} from './email-change-notification';
export {
default as VerifyChangeEmail,
type VerifyChangeEmailProps,
} from './email-change-verify';
export { default as VerifyEmail, type VerifyEmailProps } from './email-verify';
export {
default as ChangePassword,
type ChangePasswordProps,
} from './password-change';
export { default as SetPassword, type SetPasswordProps } from './password-set';
export { default as SignIn, type SignInProps } from './sign-in';
export { default as SignUp, type SignUpProps } from './sign-up';

View File

@@ -0,0 +1,24 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type ChangePasswordProps = {
url: string;
};
export default function ChangePassword(props: ChangePasswordProps) {
return (
<Template>
<Title>Modify your AFFiNE password</Title>
<Content>
<P>
Click the button below to reset your password. The magic link will
expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Set new password</Button>
</Content>
</Template>
);
}
ChangePassword.PreviewProps = {
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,24 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type SetPasswordProps = {
url: string;
};
export default function SetPassword(props: SetPasswordProps) {
return (
<Template>
<Title>Set your AFFiNE password</Title>
<Content>
<P>
Click the button below to set your password. The magic link will
expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Sign in to AFFiNE</Button>
</Content>
</Template>
);
}
SetPassword.PreviewProps = {
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,20 @@
import { Button, Content, P, Template, Title } from '../components';
export type SignInProps = {
url: string;
};
export default function SignUp(props: SignInProps) {
return (
<Template>
<Title>Sign in to AFFiNE</Title>
<Content>
<P>
Click the button below to securely sign in. The magic link will expire
in 30 minutes.
</P>
<Button href={props.url}>Sign in to AFFiNE</Button>
</Content>
</Template>
);
}

View File

@@ -0,0 +1,24 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type SignUpProps = {
url: string;
};
export default function SignUp(props: SignUpProps) {
return (
<Template>
<Title>Create AFFiNE Account</Title>
<Content>
<P>
Click the button below to complete your account creation and sign in.
This magic link will expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Create account and sign in</Button>
</Content>
</Template>
);
}
SignUp.PreviewProps = {
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,30 @@
export { default as Invitation, type InvitationProps } from './invitation';
export {
default as InvitationAccepted,
type InvitationAcceptedProps,
} from './invitation-accepted';
export { default as MemberLeave, type MemberLeaveProps } from './member-leave';
export {
default as MemberRemoved,
type MemberRemovedProps,
} from './member-removed';
export {
default as OwnershipReceived,
type OwnershipReceivedProps,
} from './ownership-received';
export {
default as OwnershipTransferred,
type OwnershipTransferredProps,
} from './ownership-transferred';
export {
default as LinkInvitationApproved,
type LinkInvitationApprovedProps,
} from './review-approved';
export {
default as LinkInvitationReviewDeclined,
type LinkInvitationReviewDeclinedProps,
} from './review-declined';
export {
default as LinkInvitationReviewRequest,
type LinkInvitationReviewRequestProps,
} from './review-request';

View File

@@ -0,0 +1,35 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
User,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type InvitationAcceptedProps = {
user: UserProps;
workspace: WorkspaceProps;
};
export default function InvitationAccepted(props: InvitationAcceptedProps) {
const { user, workspace } = props;
return (
<Template>
<Title>{user.email} accepted your invitation</Title>
<Content>
<P>
<User {...user} /> has joined <Workspace {...workspace} />
</P>
</Content>
</Template>
);
}
InvitationAccepted.PreviewProps = {
user: TEST_USER,
workspace: TEST_WORKSPACE,
};

View File

@@ -0,0 +1,41 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
User,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type InvitationProps = {
user: UserProps;
workspace: WorkspaceProps;
url: string;
};
export default function Invitation(props: InvitationProps) {
const { user, workspace, url } = props;
return (
<Template>
<Title>You are invited!</Title>
<Content>
<P>
<User {...user} /> invited you to join <Workspace {...workspace} />
</P>
<P>Click button to join this workspace</P>
<Button href={url}>Accept & Join</Button>
</Content>
</Template>
);
}
Invitation.PreviewProps = {
user: TEST_USER,
workspace: TEST_WORKSPACE,
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,38 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Content,
Name,
P,
Template,
Title,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type MemberLeaveProps = {
user: UserProps;
workspace: WorkspaceProps;
};
export default function MemberLeave(props: MemberLeaveProps) {
const { user, workspace } = props;
return (
<Template>
<Title>
Member left <Workspace {...workspace} size={24} />
</Title>
<Content>
<P>
<Name>{user.email}</Name> has left workspace{' '}
<Workspace {...workspace} />
</P>
</Content>
</Template>
);
}
MemberLeave.PreviewProps = {
user: TEST_USER,
workspace: TEST_WORKSPACE,
};

View File

@@ -0,0 +1,32 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type MemberRemovedProps = {
workspace: WorkspaceProps;
};
export default function MemberRemoved(props: MemberRemovedProps) {
const { workspace } = props;
return (
<Template>
<Title>Workspace access removed</Title>
<Content>
<P>
You have been removed from <Workspace {...workspace} />. You no longer
have access to this workspace.
</P>
</Content>
</Template>
);
}
MemberRemoved.PreviewProps = {
workspace: TEST_WORKSPACE,
};

View File

@@ -0,0 +1,34 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type OwnershipReceivedProps = {
workspace: WorkspaceProps;
};
export default function OwnershipReceived(props: OwnershipReceivedProps) {
const { workspace } = props;
return (
<Template>
<Title>Welcome, new workspace owner!</Title>
<Content>
<P>
You have been assigned as the owner of
<Workspace {...workspace} />. As a workspace owner, you have full
control over this workspace.
</P>
</Content>
</Template>
);
}
OwnershipReceived.PreviewProps = {
workspace: TEST_WORKSPACE,
};

View File

@@ -0,0 +1,32 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type OwnershipTransferredProps = {
workspace: WorkspaceProps;
};
export default function OwnershipTransferred(props: OwnershipTransferredProps) {
const { workspace } = props;
return (
<Template>
<Title>Ownership transferred</Title>
<Content>
<P>
You have transferred ownership of <Workspace {...workspace} />. You
are now a collaborator in this workspace.
</P>
</Content>
</Template>
);
}
OwnershipTransferred.PreviewProps = {
workspace: TEST_WORKSPACE,
};

View File

@@ -0,0 +1,39 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type LinkInvitationApprovedProps = {
workspace: WorkspaceProps;
url: string;
};
export default function LinkInvitationApproved(
props: LinkInvitationApprovedProps
) {
const { workspace, url } = props;
return (
<Template>
<Title>Welcome to the workspace!</Title>
<Content>
<P>
Your request to join <Workspace {...workspace} /> has been accepted.
You can now access the team workspace and collaborate with other
members.
</P>
</Content>
<Button href={url}>Open Workspace</Button>
</Template>
);
}
LinkInvitationApproved.PreviewProps = {
workspace: TEST_WORKSPACE,
url: 'https://app.affine.pro',
};

View File

@@ -0,0 +1,34 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type LinkInvitationReviewDeclinedProps = {
workspace: WorkspaceProps;
};
export default function LinkInvitationReviewDeclined(
props: LinkInvitationReviewDeclinedProps
) {
const { workspace } = props;
return (
<Template>
<Title>Request declined</Title>
<Content>
<P>
Your request to join <Workspace {...workspace} /> has been declined by
the workspace admin.
</P>
</Content>
</Template>
);
}
LinkInvitationReviewDeclined.PreviewProps = {
workspace: TEST_WORKSPACE,
};

View File

@@ -0,0 +1,45 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
User,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type LinkInvitationReviewRequestProps = {
workspace: WorkspaceProps;
user: UserProps;
url: string;
};
export default function LinkInvitationReviewRequest(
props: LinkInvitationReviewRequestProps
) {
const { workspace, user, url } = props;
return (
<Template>
<Title>
Request to join <Workspace {...workspace} size={24} />
</Title>
<Content>
<P>
<User {...user} /> has requested to join <Workspace {...workspace} />.
<br />
As a workspace owner/admin, you can approve or decline this request.
</P>
<Button href={url}>Review request</Button>
</Content>
</Template>
);
}
LinkInvitationReviewRequest.PreviewProps = {
workspace: TEST_WORKSPACE,
user: TEST_USER,
url: 'https://app.affine.pro',
};

View File

@@ -1,6 +1,7 @@
{
"extends": "../../../tsconfig.node.json",
"compilerOptions": {
"jsx": "react-jsx",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,