refactor(server): mail service (#10934)

This commit is contained in:
forehalo
2025-03-19 17:00:19 +00:00
parent b3a245f47a
commit 21c4a29f55
47 changed files with 2076 additions and 2131 deletions
-1
View File
@@ -127,7 +127,6 @@ runs:
- name: yarn install
if: ${{ inputs.package-install == 'true' }}
continue-on-error: true
shell: bash
working-directory: ${{ steps.workspace-path.outputs.workspace_path }}
run: yarn ${{ inputs.extra-flags }}
-1
View File
@@ -112,7 +112,6 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine-tools/cli": "workspace:*",
"@affine-tools/utils": "workspace:*",
"@affine/server-native": "workspace:*",
@@ -1,13 +1,8 @@
import { randomBytes } from 'node:crypto';
import {
getCurrentMailMessageCount,
getTokenFromLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { TestFn } from 'ava';
import ava from 'ava';
import { MailService } from '../../base/mailer';
import {
changeEmail,
changePassword,
@@ -21,14 +16,11 @@ import {
const test = ava as TestFn<{
app: TestingApp;
mail: MailService;
}>;
test.beforeEach(async t => {
const app = await createTestingApp();
const mail = app.get(MailService);
t.context.app = app;
t.context.mail = mail;
});
test.afterEach.always(async t => {
@@ -36,182 +28,106 @@ test.afterEach.always(async t => {
});
test('change email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const { app } = t.context;
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
await app.signupV1(u1Email);
const primitiveMailCount = await getCurrentMailMessageCount();
await sendChangeEmail(app, u1Email, 'affine.pro');
const user = await app.signupV1(u1Email);
await sendChangeEmail(app, u1Email, 'affine.pro');
const afterSendChangeMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterSendChangeMailCount,
'failed to send change email'
);
const changeMail = app.mails.last('ChangeEmail');
const changeEmailToken = await getTokenFromLatestMailMessage();
t.is(changeMail.to, u1Email);
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
let link = new URL(changeMail.props.url);
await sendVerifyChangeEmail(
app,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const changeEmailToken = link.searchParams.get('token');
const afterSendVerifyMailCount = await getCurrentMailMessageCount();
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
t.is(
afterSendChangeMailCount + 1,
afterSendVerifyMailCount,
'failed to send verify email'
);
await sendVerifyChangeEmail(
app,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const verifyEmailToken = await getTokenFromLatestMailMessage();
const verifyMail = app.mails.last('VerifyChangeEmail');
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
t.is(verifyMail.to, u2Email);
await changeEmail(app, verifyEmailToken as string, u2Email);
link = new URL(verifyMail.props.url);
const afterNotificationMailCount = await getCurrentMailMessageCount();
const verifyEmailToken = link.searchParams.get('token');
t.is(
afterSendVerifyMailCount + 1,
afterNotificationMailCount,
'failed to send notification email'
);
}
t.pass();
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
await changeEmail(app, verifyEmailToken as string, u2Email);
const changedMail = app.mails.last('EmailChanged');
t.is(changedMail.to, u2Email);
t.is(changedMail.props.to, u2Email);
await app.logout();
await app.login({
...user,
email: u2Email,
});
const me = await currentUser(app);
t.not(me, null, 'failed to get current user');
t.is(me?.email, u2Email, 'failed to get current user');
});
test('set and change password', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const { app } = t.context;
const u1Email = 'u1@affine.pro';
const u1 = await app.signupV1(u1Email);
const u1 = await app.signupV1(u1Email);
await sendSetPasswordEmail(app, u1Email, 'affine.pro');
const primitiveMailCount = await getCurrentMailMessageCount();
const setPasswordMail = app.mails.last('ChangePassword');
const link = new URL(setPasswordMail.props.url);
const setPasswordToken = link.searchParams.get('token');
await sendSetPasswordEmail(app, u1Email, 'affine.pro');
t.is(setPasswordMail.to, u1Email);
t.not(
setPasswordToken,
null,
'fail to get set password token from email content'
);
const afterSendSetMailCount = await getCurrentMailMessageCount();
const newPassword = randomBytes(16).toString('hex');
const success = await changePassword(
app,
u1.id,
setPasswordToken as string,
newPassword
);
t.is(
primitiveMailCount + 1,
afterSendSetMailCount,
'failed to send set email'
);
t.true(success, 'failed to change password');
const setPasswordToken = await getTokenFromLatestMailMessage();
let user = await currentUser(app);
t.not(
setPasswordToken,
null,
'fail to get set password token from email content'
);
t.is(user, null);
const newPassword = randomBytes(16).toString('hex');
const success = await changePassword(
app,
u1.id,
setPasswordToken as string,
newPassword
);
await app.login({
...u1,
password: newPassword,
});
t.true(success, 'failed to change password');
user = await currentUser(app);
await app.login({
...u1,
password: newPassword,
});
const user = await currentUser(app);
t.not(user, null, 'failed to get current user');
t.is(user?.email, u1Email, 'failed to get current user');
}
t.pass();
});
test('should revoke token after change user identify', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
// change email
{
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const u1 = await app.signupV1(u1Email);
{
const user = await currentUser(app);
t.is(user?.email, u1Email, 'failed to get current user');
}
await sendChangeEmail(app, u1Email, 'affine.pro');
const changeEmailToken = await getTokenFromLatestMailMessage();
await sendVerifyChangeEmail(
app,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const verifyEmailToken = await getTokenFromLatestMailMessage();
await changeEmail(app, verifyEmailToken as string, u2Email);
let user = await currentUser(app);
t.is(user, null, 'token should be revoked');
await app.login({
...u1,
email: u2Email,
});
user = await currentUser(app);
t.is(user?.email, u2Email, 'failed to sign in with new email');
}
// change password
{
const u3Email = 'u3333@affine.pro';
await app.logout();
const u3 = await app.signupV1(u3Email);
{
const user = await currentUser(app);
t.is(user?.email, u3Email, 'failed to get current user');
}
await sendSetPasswordEmail(app, u3Email, 'affine.pro');
const token = await getTokenFromLatestMailMessage();
const newPassword = randomBytes(16).toString('hex');
await changePassword(app, u3.id, token as string, newPassword);
let user = await currentUser(app);
t.is(user, null, 'token should be revoked');
await app.login({
...u3,
password: newPassword,
});
user = await currentUser(app);
t.is(user?.email, u3Email, 'failed to sign in with new password');
}
}
t.pass();
t.not(user, null, 'failed to get current user');
t.is(user?.email, u1Email, 'failed to get current user');
});
@@ -5,7 +5,6 @@ import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { MailService } from '../../base';
import { AuthModule } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { FeatureModule } from '../../core/features';
@@ -20,26 +19,16 @@ import {
const test = ava as TestFn<{
auth: AuthService;
db: PrismaClient;
mailer: Sinon.SinonStubbedInstance<MailService>;
app: TestingApp;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [FeatureModule, UserModule, AuthModule],
tapModule: m => {
m.overrideProvider(MailService).useValue(
Sinon.stub(
// @ts-expect-error safe
new MailService()
)
);
},
});
t.context.auth = app.get(AuthService);
t.context.db = app.get(PrismaClient);
t.context.mailer = app.get(MailService);
t.context.app = app;
});
@@ -67,11 +56,9 @@ test('should be able to sign in with credential', async t => {
});
test('should be able to sign in with email', async t => {
const { app, mailer } = t.context;
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
@@ -79,10 +66,11 @@ test('should be able to sign in with email', async t => {
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const signInMail = app.mails.last('SignIn');
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
t.is(signInMail.to, u1.email);
const url = new URL(signInMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -93,10 +81,7 @@ test('should be able to sign in with email', async t => {
});
test('should be able to sign up with email', async t => {
const { app, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignUpMail.resolves({ rejected: [] });
const { app } = t.context;
const res = await app
.POST('/api/auth/sign-in')
@@ -104,10 +89,11 @@ test('should be able to sign up with email', async t => {
.expect(200);
t.is(res.body.email, 'u2@affine.pro');
t.true(mailer.sendSignUpMail.calledOnce);
const signUpMail = app.mails.last('SignUp');
const [, { url: signUpLink }] = mailer.sendSignUpMail.firstCall.args;
const url = new URL(signUpLink);
t.is(signUpMail.to, 'u2@affine.pro');
const url = new URL(signUpMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -129,7 +115,7 @@ test('should not be able to sign in if email is invalid', async t => {
});
test('should not be able to sign in if forbidden', async t => {
const { app, auth, mailer } = t.context;
const { app, auth } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false);
@@ -139,9 +125,8 @@ test('should not be able to sign in if forbidden', async t => {
.send({ email: u1.email })
.expect(HttpStatus.FORBIDDEN);
t.true(mailer.sendSignInMail.notCalled);
canSignInStub.restore();
t.pass();
});
test('should be able to sign out', async t => {
@@ -253,12 +238,10 @@ test('should be able to sign out multiple accounts in one session', async t => {
});
test('should be able to sign in with email and client nonce', async t => {
const { app, mailer } = t.context;
const { app } = t.context;
const clientNonce = randomUUID();
const u1 = await app.createUser();
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
@@ -266,10 +249,11 @@ test('should be able to sign in with email and client nonce', async t => {
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const signInMail = app.mails.last('SignIn');
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
t.is(signInMail.to, u1.email);
const url = new URL(signInMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -283,12 +267,10 @@ test('should be able to sign in with email and client nonce', async t => {
});
test('should not be able to sign in with email and client nonce if invalid', async t => {
const { app, mailer } = t.context;
const { app } = t.context;
const clientNonce = randomUUID();
const u1 = await app.createUser();
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
@@ -296,10 +278,11 @@ test('should not be able to sign in with email and client nonce if invalid', asy
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const signInMail = app.mails.last('SignIn');
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
t.is(signInMail.to, u1.email);
const url = new URL(signInMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -13,6 +13,8 @@ import {
} from '../../base';
import { SocketIoAdapter } from '../../base/websocket';
import { AuthGuard } from '../../core/auth';
import { Mailer } from '../../core/mail';
import { MockMailer } from '../mocks';
import { TEST_LOG_LEVEL } from '../utils';
interface TestingAppMetadata {
@@ -21,6 +23,10 @@ interface TestingAppMetadata {
}
export class TestingApp extends NestApplication {
get mails() {
return this.get(Mailer, { strict: false }) as MockMailer;
}
async [Symbol.asyncDispose]() {
await this.close();
}
@@ -1,61 +0,0 @@
import type { TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
import { MailService } from '../base/mailer';
import {
createTestingApp,
createWorkspace,
inviteUser,
TestingApp,
} from './utils';
const test = ava as TestFn<{
app: TestingApp;
mail: MailService;
}>;
import * as renderers from '../mails';
test.beforeEach(async t => {
const app = await createTestingApp();
const mail = app.get(MailService);
t.context.app = app;
t.context.mail = mail;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should send invite email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u2 = await app.signupV1('u2@affine.pro');
const u1 = await app.signupV1('u1@affine.pro');
const stub = Sinon.stub(mail, 'send');
const workspace = await createWorkspace(app);
await inviteUser(app, workspace.id, u2.email, true);
t.true(stub.calledOnce);
const args = stub.args[0][0];
t.is(args.to, u2.email);
t.true(
args.subject!.startsWith(
`${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);
}
});
@@ -0,0 +1,11 @@
import test from 'ava';
import { Renderers } from '../mails';
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);
}
});
@@ -3,6 +3,7 @@ export * from './team-workspace.mock';
export * from './user.mock';
export * from './workspace.mock';
import { MockMailer } from './mailer.mock';
import { MockTeamWorkspace } from './team-workspace.mock';
import { MockUser } from './user.mock';
import { MockWorkspace } from './workspace.mock';
@@ -12,3 +13,5 @@ export const Mockers = {
Workspace: MockWorkspace,
TeamWorkspace: MockTeamWorkspace,
};
export { MockMailer };
@@ -0,0 +1,29 @@
import Sinon from 'sinon';
import { Mailer } from '../../core/mail';
import { MailName } from '../../mails';
export class MockMailer {
send = Sinon.createStubInstance(Mailer).send.resolves(true);
last<Mail extends MailName>(
name: Mail
): Extract<Jobs['notification.sendMail'], { name: Mail }> {
const last = this.send.lastCall.args[0];
if (!last) {
throw new Error('No mail ever sent');
}
if (last.name !== name) {
throw new Error(`Mail name mismatch: ${last.name} !== ${name}`);
}
return last as any;
}
count(name: MailName) {
return this.send.getCalls().filter(call => call.args[0].name === name)
.length;
}
}
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto';
import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud';
import { User, WorkspaceMemberStatus } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
@@ -619,12 +618,9 @@ test('should be able to invite by link', async t => {
test('should be able to send mails', async t => {
const { app } = t.context;
const { inviteBatch } = await init(app, 5);
const primitiveMailCount = await getCurrentMailMessageCount();
{
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
t.is(await getCurrentMailMessageCount(), primitiveMailCount + 2);
}
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
t.is(app.mails.count('MemberInvitation'), 2);
});
test('should be able to emit events', async t => {
@@ -10,8 +10,10 @@ import supertest from 'supertest';
import { AFFiNELogger, ApplyType, GlobalExceptionFilter } from '../../base';
import { AuthService } from '../../core/auth';
import { Mailer } from '../../core/mail';
import { UserModel } from '../../models';
import { createFactory, MockedUser, MockUser, MockUserInput } from '../mocks';
import { MockMailer } from '../mocks/mailer.mock';
import { createTestingModule } from './testing-module';
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
@@ -82,6 +84,7 @@ export class TestingApp extends ApplyType<INestApplication>() {
private readonly userCookies: Set<string> = new Set();
readonly create!: ReturnType<typeof createFactory>;
readonly mails!: MockMailer;
[Symbol.asyncDispose](): Promise<void> {
return this.close();
@@ -280,6 +283,8 @@ function makeTestingApp(app: INestApplication): TestingApp {
// @ts-expect-error allow
testingApp.create = createFactory(app.get(PrismaClient, { strict: false }));
// @ts-expect-error allow
testingApp.mails = app.get(Mailer, { strict: false }) as MockMailer;
return new Proxy(testingApp, {
get(target, prop) {
@@ -12,11 +12,13 @@ import { AppModule, FunctionalityModules } from '../../app.module';
import { AFFiNELogger, Runtime } from '../../base';
import { GqlModule } from '../../base/graphql';
import { AuthGuard, AuthModule } from '../../core/auth';
import { Mailer, MailModule } from '../../core/mail';
import { ModelsModule } from '../../models';
// for jsdoc inference
// oxlint-disable-next-line no-unused-vars
import type { createModule } from '../create-module';
import { createFactory } from '../mocks';
import { MockMailer } from '../mocks/mailer.mock';
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
interface TestingModuleMeatdata extends ModuleMetadata {
@@ -26,6 +28,7 @@ interface TestingModuleMeatdata extends ModuleMetadata {
export interface TestingModule extends BaseTestingModule {
initTestingDB(): Promise<void>;
create: ReturnType<typeof createFactory>;
mails: MockMailer;
[Symbol.asyncDispose](): Promise<void>;
}
@@ -68,6 +71,7 @@ export async function createTestingModule(
ModelsModule,
AuthModule,
GqlModule,
MailModule,
...imports,
]);
@@ -87,6 +91,7 @@ export async function createTestingModule(
if (moduleDef.tapModule) {
moduleDef.tapModule(builder);
}
builder.overrideProvider(Mailer).useClass(MockMailer);
const module = await builder.compile();
@@ -108,6 +113,8 @@ export async function createTestingModule(
await module.close();
};
testingModule.mails = module.get(Mailer, { strict: false }) as MockMailer;
const logger = new AFFiNELogger();
// we got a lot smoking tests try to break nestjs
// can't tolerate the noisy logs
@@ -1,12 +1,7 @@
import {
getCurrentMailMessageCount,
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import { MailService } from '../base/mailer';
import { AuthService } from '../core/auth/service';
import { Models } from '../models';
import {
@@ -24,7 +19,6 @@ const test = ava as TestFn<{
app: TestingApp;
client: PrismaClient;
auth: AuthService;
mail: MailService;
models: Models;
}>;
@@ -33,7 +27,6 @@ test.before(async t => {
t.context.app = app;
t.context.client = app.get(PrismaClient);
t.context.auth = app.get(AuthService);
t.context.mail = app.get(MailService);
t.context.models = app.get(Models);
});
@@ -125,70 +118,31 @@ test('should invite a user by link', async t => {
});
test('should send email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u2 = await app.signupV1('u2@affine.pro');
await app.signupV1('u1@affine.pro');
const { app } = t.context;
const u2 = await app.signupV1('u2@affine.pro');
const u1 = await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app);
const primitiveMailCount = await getCurrentMailMessageCount();
const workspace = await createWorkspace(app);
const invite = await inviteUser(app, workspace.id, u2.email, true);
const invite = await inviteUser(app, workspace.id, u2.email, true);
const invitationMail = app.mails.last('MemberInvitation');
const afterInviteMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterInviteMailCount,
'failed to send invite email'
);
const inviteEmailContent = await getLatestMailMessage();
t.is(invitationMail.name, 'MemberInvitation');
t.is(invitationMail.to, u2.email);
t.not(
inviteEmailContent.To.find((item: any) => {
return item.Mailbox === 'u2';
}),
undefined,
'invite email address was incorrectly sent'
);
app.switchUser(u2.id);
await acceptInviteById(app, workspace.id, invite, true);
app.switchUser(u2.id);
const accept = await acceptInviteById(app, workspace.id, invite, true);
t.true(accept, 'failed to accept invite');
const acceptedMail = app.mails.last('MemberAccepted');
t.is(acceptedMail.to, u1.email);
t.is(acceptedMail.props.user.$$userId, u2.id);
const afterAcceptMailCount = await getCurrentMailMessageCount();
t.is(
afterInviteMailCount + 1,
afterAcceptMailCount,
'failed to send accepted email to owner'
);
const acceptEmailContent = await getLatestMailMessage();
t.not(
acceptEmailContent.To.find((item: any) => {
return item.Mailbox === 'u1';
}),
undefined,
'accept email address was incorrectly sent'
);
await leaveWorkspace(app, workspace.id, true);
await leaveWorkspace(app, workspace.id, true);
const leaveMail = app.mails.last('MemberLeave');
// 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) => {
return item.Mailbox === 'u1';
}),
undefined,
'leave email address was incorrectly sent'
);
}
t.pass();
t.is(leaveMail.to, u1.email);
t.is(leaveMail.props.user.$$userId, u2.id);
});
test('should support pagination for member', async t => {
+8 -3
View File
@@ -28,7 +28,6 @@ import { GqlModule } from './base/graphql';
import { HelpersModule } from './base/helpers';
import { JobModule } from './base/job';
import { LoggerModule } from './base/logger';
import { MailModule } from './base/mailer';
import { MetricsModule } from './base/metrics';
import { MutexModule } from './base/mutex';
import { PrismaModule } from './base/prisma';
@@ -43,6 +42,7 @@ import { DocStorageModule } from './core/doc';
import { DocRendererModule } from './core/doc-renderer';
import { DocServiceModule } from './core/doc-service';
import { FeatureModule } from './core/features';
import { MailModule } from './core/mail';
import { NotificationModule } from './core/notification';
import { PermissionModule } from './core/permission';
import { QuotaModule } from './core/quota';
@@ -101,7 +101,6 @@ export const FunctionalityModules = [
PrismaModule,
MetricsModule,
RateLimiterModule,
MailModule,
StorageProviderModule,
HelpersModule,
ErrorModule,
@@ -219,7 +218,13 @@ export function buildAppModule() {
.use(UserModule, AuthModule, PermissionModule)
// business modules
.use(FeatureModule, QuotaModule, DocStorageModule, NotificationModule)
.use(
FeatureModule,
QuotaModule,
DocStorageModule,
NotificationModule,
MailModule
)
// sync server only
.useIf(config => config.flavor.sync, SyncModule)
@@ -26,7 +26,6 @@ export * from './guard';
export { CryptoHelper, URLHelper } from './helpers';
export * from './job';
export { AFFiNELogger } from './logger';
export { MailService } from './mailer';
export { CallMetric, metrics } from './metrics';
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
export * from './nestjs';
@@ -1 +1 @@
export { JobModule, JobQueue, OnJob } from './queue';
export { JOB_SIGNAL, JobModule, JobQueue, OnJob } from './queue';
@@ -93,10 +93,6 @@ test('should register job handler', async t => {
t.is(handler!.name, 'JobHandlers.handleJob');
t.is(typeof handler!.fn, 'function');
const result = await handler!.fn({ name: 'test' });
t.is(result, 'test');
});
// #endregion
@@ -63,3 +63,7 @@ If you want to introduce new job queue, please modify the Queue enum first in ${
export function getJobHandlerMetadata(target: any): JobName[] {
return sliceMetadata<JobName>(JOB_METADATA, target);
}
export enum JOB_SIGNAL {
RETRY = 'retry',
}
@@ -13,7 +13,7 @@ import { metrics, wrapCallMetric } from '../../metrics';
import { QueueRedis } from '../../redis';
import { Runtime } from '../../runtime';
import { genRequestId } from '../../utils';
import { namespace, Queue, QUEUES } from './def';
import { JOB_SIGNAL, namespace, Queue, QUEUES } from './def';
import { JobHandlerScanner } from './scanner';
@Injectable()
@@ -69,8 +69,12 @@ export class JobExecutor
try {
this.logger.debug(`Job started: ${signature}`);
const result = await handler.fn(payload);
if (result === JOB_SIGNAL.RETRY) {
throw new Error(`Manually job retry`);
}
this.logger.debug(`Job finished: ${signature}`);
return result;
} catch (e) {
this.logger.error(`Job failed: ${signature}`, e);
throw e;
@@ -88,7 +92,7 @@ export class JobExecutor
const activeJobs = metrics.queue.counter('active_jobs');
activeJobs.add(1, { queue: ns });
try {
return await fn();
await fn();
} finally {
activeJobs.add(-1, { queue: ns });
}
@@ -42,4 +42,4 @@ export class JobModule {
}
export { JobQueue };
export { OnJob } from './def';
export { JOB_SIGNAL, OnJob } from './def';
@@ -1,11 +1,11 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleScanner } from '../../nestjs';
import { getJobHandlerMetadata } from './def';
import { getJobHandlerMetadata, JOB_SIGNAL } from './def';
interface JobHandler {
name: string;
fn: (payload: any) => any;
fn: (payload: any) => Promise<JOB_SIGNAL | undefined>;
}
@Injectable()
@@ -1,24 +0,0 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { OptionalModule } from '../nestjs';
import { MailService } from './mail.service';
import { MAILER } from './mailer';
@Global()
@OptionalModule({
providers: [MAILER],
exports: [MAILER],
requires: ['mailer.host'],
})
class MailerModule {}
@Global()
@Module({
imports: [MailerModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
export { MailService };
@@ -1,193 +0,0 @@
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,
renderTeamLicenseMail,
renderTeamWorkspaceDeletedMail,
renderTeamWorkspaceExpiredMail,
renderTeamWorkspaceExpireSoonMail,
renderTeamWorkspaceUpgradedMail,
renderVerifyChangeEmailMail,
renderVerifyEmailMail,
} from '../../mails';
import { WorkspaceProps } from '../../mails/components';
import { Config } from '../config';
import { MailerServiceIsNotConfigured } from '../error';
import { metrics } from '../metrics';
import type { MailerService, Options } from './mailer';
import { MAILER_SERVICE } from './mailer';
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,
@Optional() @Inject(MAILER_SERVICE) private readonly mailer?: MailerService
) {}
readonly send = async (options: Options) => {
if (!this.mailer) {
throw new MailerServiceIsNotConfigured();
}
metrics.mail.counter('total').add(1);
try {
const result = await this.mailer.sendMail({
from: this.config.mailer?.from,
...options,
});
metrics.mail.counter('sent').add(1);
return result;
} catch (e) {
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;
}
// 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);
// =================== 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 ===================
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);
sendTeamLicenseMail = this.make(renderTeamLicenseMail);
}
@@ -1,33 +0,0 @@
import { FactoryProvider, Logger } from '@nestjs/common';
import { createTransport, Transporter } from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { Config } from '../config';
export const MAILER_SERVICE = Symbol('MAILER_SERVICE');
export type MailerService = Transporter<SMTPTransport.SentMessageInfo>;
export type Response = SMTPTransport.SentMessageInfo;
export type Options = SMTPTransport.Options;
export const MAILER: FactoryProvider<
Transporter<SMTPTransport.SentMessageInfo> | undefined
> = {
provide: MAILER_SERVICE,
useFactory: (config: Config) => {
if (config.mailer) {
const logger = new Logger('Mailer');
const auth = config.mailer.auth;
if (auth && auth.user && !('pass' in auth)) {
logger.warn(
'Mailer service has not configured password, please make sure your mailer service allow empty password.'
);
}
return createTransport(config.mailer);
} else {
return undefined;
}
},
inject: [Config],
};
@@ -20,7 +20,6 @@ import {
CryptoHelper,
EarlyAccessRequired,
EmailTokenNotFound,
InternalServerError,
InvalidAuthState,
InvalidEmail,
InvalidEmailToken,
@@ -235,16 +234,7 @@ export class AuthController {
this.logger.debug(`Magic link: ${magicLink}`);
}
const result = await this.auth.sendSignInEmail(
email,
magicLink,
otp,
!user
);
if (result.rejected.length) {
throw new InternalServerError('Failed to send sign-in email.');
}
await this.auth.sendSignInEmail(email, magicLink, otp, !user);
res.status(HttpStatus.OK).send({
email: email,
@@ -3,6 +3,7 @@ import './config';
import { Module } from '@nestjs/common';
import { FeatureModule } from '../features';
import { MailModule } from '../mail';
import { QuotaModule } from '../quota';
import { UserModule } from '../user';
import { AuthController } from './controller';
@@ -12,7 +13,7 @@ import { AuthResolver } from './resolver';
import { AuthService } from './service';
@Module({
imports: [FeatureModule, UserModule, QuotaModule],
imports: [FeatureModule, UserModule, QuotaModule, MailModule],
providers: [
AuthService,
AuthResolver,
@@ -161,9 +161,7 @@ export class AuthResolver {
const url = this.url.link(callbackUrl, { userId: user.id, token });
const res = await this.auth.sendChangePasswordEmail(user.email, url);
return !res.rejected.length;
return await this.auth.sendChangePasswordEmail(user.email, url);
}
@Mutation(() => Boolean)
@@ -204,8 +202,7 @@ export class AuthResolver {
const url = this.url.link(callbackUrl, { token });
const res = await this.auth.sendChangeEmail(user.email, url);
return !res.rejected.length;
return await this.auth.sendChangeEmail(user.email, url);
}
@Mutation(() => Boolean)
@@ -248,9 +245,7 @@ export class AuthResolver {
);
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
const res = await this.auth.sendVerifyChangeEmail(email, url);
return !res.rejected.length;
return await this.auth.sendVerifyChangeEmail(email, url);
}
@Mutation(() => Boolean)
@@ -265,8 +260,7 @@ export class AuthResolver {
const url = this.url.link(callbackUrl, { token });
const res = await this.auth.sendVerifyEmail(user.email, url);
return !res.rejected.length;
return await this.auth.sendVerifyEmail(user.email, url);
}
@Mutation(() => Boolean)
@@ -2,7 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { CookieOptions, Request, Response } from 'express';
import { assign, pick } from 'lodash-es';
import { Config, MailService, SignUpForbidden } from '../../base';
import { Config, SignUpForbidden } from '../../base';
import {
Models,
type User,
@@ -10,6 +10,7 @@ import {
type UserSession,
} from '../../models';
import { FeatureService } from '../features';
import { Mailer } from '../mail/mailer';
import type { CurrentUser } from './session';
export function sessionUser(
@@ -47,7 +48,7 @@ export class AuthService implements OnApplicationBootstrap {
constructor(
private readonly config: Config,
private readonly models: Models,
private readonly mailer: MailService,
private readonly mailer: Mailer,
private readonly feature: FeatureService
) {}
@@ -325,23 +326,57 @@ export class AuthService implements OnApplicationBootstrap {
}
async sendChangePasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangePasswordMail(email, { url: callbackUrl });
return await this.mailer.send({
name: 'ChangePassword',
to: email,
props: {
url: callbackUrl,
},
});
}
async sendSetPasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendSetPasswordMail(email, { url: callbackUrl });
return await this.mailer.send({
name: 'SetPassword',
to: email,
props: {
url: callbackUrl,
},
});
}
async sendChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangeEmailMail(email, { url: callbackUrl });
return await this.mailer.send({
name: 'ChangeEmail',
to: email,
props: {
url: callbackUrl,
},
});
}
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyChangeEmail(email, { url: callbackUrl });
return await this.mailer.send({
name: 'VerifyChangeEmail',
to: email,
props: {
url: callbackUrl,
},
});
}
async sendVerifyEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyEmail(email, { url: callbackUrl });
return await this.mailer.send({
name: 'VerifyEmail',
to: email,
props: {
url: callbackUrl,
},
});
}
async sendNotificationChangeEmail(email: string) {
return this.mailer.sendNotificationChangeEmail(email, {
return await this.mailer.send({
name: 'EmailChanged',
to: email,
props: {
to: email,
},
});
}
@@ -351,8 +386,13 @@ export class AuthService implements OnApplicationBootstrap {
otp: string,
signUp: boolean
) {
return signUp
? await this.mailer.sendSignUpMail(email, { url: link, otp })
: await this.mailer.sendSignInMail(email, { url: link, otp });
return await this.mailer.send({
name: signUp ? 'SignUp' : 'SignIn',
to: email,
props: {
url: link,
otp,
},
});
}
}
@@ -1,8 +1,8 @@
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { defineStartupConfig, ModuleConfig } from '../config';
import { defineStartupConfig, ModuleConfig } from '../../base/config';
declare module '../config' {
declare module '../../base/config' {
interface AppConfig {
/**
* Configurations for mail service used to post auth or bussiness mails.
@@ -0,0 +1,17 @@
import './config';
import { Module } from '@nestjs/common';
import { DocStorageModule } from '../doc';
import { StorageModule } from '../storage';
import { MailJob } from './job';
import { Mailer } from './mailer';
import { MailSender } from './sender';
@Module({
imports: [DocStorageModule, StorageModule],
providers: [MailSender, Mailer, MailJob],
exports: [Mailer],
})
export class MailModule {}
export { Mailer };
@@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import { getStreamAsBuffer } from 'get-stream';
import { JOB_SIGNAL, OnJob } from '../../base';
import { type MailName, MailProps, Renderers } from '../../mails';
import { UserProps, WorkspaceProps } from '../../mails/components';
import { Models } from '../../models';
import { DocReader } from '../doc/reader';
import { WorkspaceBlobStorage } from '../storage';
import { MailSender, SendOptions } from './sender';
type DynamicallyFetchedProps<Props> = {
[Key in keyof Props]: Props[Key] extends infer Prop
? Prop extends UserProps
? {
$$userId: string;
} & Omit<Prop, 'email' | 'avatar'>
: Prop extends WorkspaceProps
? {
$$workspaceId: string;
} & Omit<Prop, 'name' | 'avatar'>
: Prop
: never;
};
type SendMailJob<Mail extends MailName = MailName, Props = MailProps<Mail>> = {
name: Mail;
to: string;
// NOTE(@forehalo):
// workspace avatar currently send as base64 img instead of a avatar url,
// so the content might be too large to be put in job payload.
props: DynamicallyFetchedProps<Props>;
};
declare global {
interface Jobs {
'notification.sendMail': {
[K in MailName]: SendMailJob<K>;
}[MailName];
}
}
@Injectable()
export class MailJob {
constructor(
private readonly sender: MailSender,
private readonly doc: DocReader,
private readonly workspaceBlob: WorkspaceBlobStorage,
private readonly models: Models
) {}
@OnJob('notification.sendMail')
async sendMail({ name, to, props }: Jobs['notification.sendMail']) {
let options: Partial<SendOptions> = {};
for (const key in props) {
// @ts-expect-error allow
const val = props[key];
if (val && typeof val === 'object') {
if ('$$workspaceId' in val) {
const workspaceProps = await this.fetchWorkspaceProps(
val.$$workspaceId
);
if (!workspaceProps) {
return;
}
if (workspaceProps.avatar) {
workspaceProps.avatar = 'cid:workspaceAvatar';
options.attachments = [
{
cid: 'workspaceAvatar',
filename: 'workspaceAvatar',
content: workspaceProps.avatar,
encoding: 'base64',
},
];
}
// @ts-expect-error replacement
props[key] = workspaceProps;
} else if ('$$userId' in val) {
const userProps = await this.fetchUserProps(val.$$userId);
if (!userProps) {
return;
}
// @ts-expect-error replacement
props[key] = userProps;
}
}
}
const result = await this.sender.send(name, {
to,
...(await Renderers[name](
// @ts-expect-error the job trigger part has been typechecked
props
)),
...options,
});
return result === false ? JOB_SIGNAL.RETRY : undefined;
}
private async fetchWorkspaceProps(workspaceId: string) {
const workspace = await this.doc.getWorkspaceContent(workspaceId);
if (!workspace) {
return;
}
const props: WorkspaceProps = {
name: workspace.name,
};
if (workspace.avatarKey) {
const avatar = await this.workspaceBlob.get(
workspace.id,
workspace.avatarKey
);
if (avatar.body) {
props.avatar = (await getStreamAsBuffer(avatar.body)).toString(
'base64'
);
}
}
return props;
}
private async fetchUserProps(userId: string) {
const user = await this.models.user.getWorkspaceUser(userId);
if (!user) {
return;
}
return { email: user.email } satisfies UserProps;
}
}
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { JobQueue } from '../../base';
@Injectable()
export class Mailer {
constructor(private readonly queue: JobQueue) {}
async send(command: Jobs['notification.sendMail']) {
try {
await this.queue.add('notification.sendMail', command);
return true;
} catch {
return false;
}
}
}
@@ -0,0 +1,89 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
createTestAccount,
createTransport,
getTestMessageUrl,
SendMailOptions,
Transporter,
} from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { Config, metrics } from '../../base';
export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & {
to: string;
subject: string;
html: string;
};
@Injectable()
export class MailSender implements OnModuleInit {
private readonly logger = new Logger(MailSender.name);
private smtp: Transporter<SMTPTransport.SentMessageInfo> | null = null;
private usingTestAccount = false;
constructor(private readonly config: Config) {}
onModuleInit() {
this.createSMTP(this.config.mailer);
}
createSMTP(config: SMTPTransport.Options) {
if (config.host) {
this.smtp = createTransport(config);
} else if (this.config.node.dev) {
createTestAccount((err, account) => {
if (!err) {
this.smtp = createTransport({
from: 'noreply@toeverything.info',
...this.config.mailer,
...account.smtp,
auth: {
user: account.user,
pass: account.pass,
},
});
this.usingTestAccount = true;
}
});
} else {
this.logger.warn('Mailer SMTP transport is not configured.');
}
}
async send(name: string, options: SendOptions) {
if (!this.smtp) {
this.logger.warn(`Mailer SMTP transport is not configured to send mail.`);
return null;
}
metrics.mail.counter('send_total').add(1, { name });
try {
const result = await this.smtp.sendMail({
from: this.config.mailer.from,
...options,
});
if (result.rejected.length > 0) {
metrics.mail.counter('rejected_total').add(1, { name });
this.logger.error(
`Mail [${name}] rejected with response: ${result.response}`
);
return false;
}
metrics.mail.counter('accepted_total').add(1, { name });
this.logger.log(`Mail [${name}] sent successfully.`);
if (this.usingTestAccount) {
this.logger.debug(
` ⚙️ Mail preview url: ${getTestMessageUrl(result)}`
);
}
return true;
} catch (e) {
metrics.mail.counter('failed_total').add(1, { name });
this.logger.error(`Failed to send mail [${name}].`, e);
return false;
}
}
}
@@ -33,9 +33,12 @@ export class WorkspaceEvents {
workspaceId,
}: Events['workspace.members.requestDeclined']) {
const user = await this.models.user.getWorkspaceUser(userId);
if (!user) {
return;
}
// send decline mail
await this.workspaceService.sendReviewDeclinedEmail(
user?.email,
user.email,
workspaceId
);
}
@@ -3,6 +3,8 @@ import { Module } from '@nestjs/common';
import { DocStorageModule } from '../doc';
import { DocRendererModule } from '../doc-renderer';
import { FeatureModule } from '../features';
import { MailModule } from '../mail';
import { NotificationModule } from '../notification';
import { PermissionModule } from '../permission';
import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
@@ -28,6 +30,8 @@ import {
StorageModule,
UserModule,
PermissionModule,
NotificationModule,
MailModule,
],
controllers: [WorkspacesController],
providers: [
@@ -1,21 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { getStreamAsBuffer } from 'get-stream';
import {
Cache,
Config,
MailService,
NotFound,
OnEvent,
URLHelper,
UserNotFound,
} from '../../../base';
import { Cache, NotFound, OnEvent, URLHelper } from '../../../base';
import { Models } from '../../../models';
import { DocReader } from '../../doc';
import { Mailer } from '../../mail';
import { WorkspaceRole } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage';
export const defaultWorkspaceAvatar =
export const DEFAULT_WORKSPACE_AVATAR =
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
export type InviteInfo = {
@@ -29,13 +22,12 @@ export class WorkspaceService {
private readonly logger = new Logger(WorkspaceService.name);
constructor(
private readonly blobStorage: WorkspaceBlobStorage,
private readonly cache: Cache,
private readonly doc: DocReader,
private readonly mailer: MailService,
private readonly models: Models,
private readonly url: URLHelper,
private readonly config: Config
private readonly doc: DocReader,
private readonly blobStorage: WorkspaceBlobStorage,
private readonly mailer: Mailer
) {}
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
@@ -62,7 +54,7 @@ export class WorkspaceService {
async getWorkspaceInfo(workspaceId: string) {
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
let avatar = defaultWorkspaceAvatar;
let avatar = DEFAULT_WORKSPACE_AVATAR;
if (workspaceContent?.avatarKey) {
const avatarBlob = await this.blobStorage.get(
workspaceId,
@@ -81,70 +73,58 @@ export class WorkspaceService {
};
}
private async getInviteeEmailTarget(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
return;
}
const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = await this.models.user.getWorkspaceUser(inviteeUserId);
if (!invitee) {
this.logger.error(
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
);
return;
}
return {
email: invitee.email,
workspace,
};
}
async sendAcceptedEmail(inviteId: string) {
const { workspaceId, inviterUserId, inviteeUserId } =
await this.getInviteInfo(inviteId);
const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = inviteeUserId
? await this.models.user.getWorkspaceUser(inviteeUserId)
: null;
const inviter = inviterUserId
? await this.models.user.getWorkspaceUser(inviterUserId)
: await this.models.workspaceUser.getOwner(workspaceId);
if (!inviter || !invitee) {
this.logger.error(
if (!inviter || !inviteeUserId) {
this.logger.warn(
`Inviter or invitee user not found for inviteId: ${inviteId}`
);
return false;
}
await this.mailer.sendMemberAcceptedEmail(inviter.email, {
user: invitee,
workspace,
return await this.mailer.send({
name: 'MemberAccepted',
to: inviter.email,
props: {
user: {
$$userId: inviteeUserId,
},
workspace: {
$$workspaceId: workspaceId,
},
},
});
return true;
}
async sendInviteEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) {
return;
}
const owner = await this.models.workspaceUser.getOwner(target.workspace.id);
const inviteUrl = this.url.link(`/invite/${inviteId}`);
if (this.config.node.dev) {
// make it easier to test in dev mode
this.logger.debug(`Invite link: ${inviteUrl}`);
}
await this.mailer.sendMemberInviteMail(target.email, {
workspace: target.workspace,
user: owner,
url: inviteUrl,
async sendInviteEmail({
workspaceId,
inviteeEmail,
inviterUserId,
inviteId,
}: {
inviterUserId: string;
inviteeEmail: string;
inviteId: string;
workspaceId: string;
}) {
return await this.mailer.send({
name: 'MemberInvitation',
to: inviteeEmail,
props: {
workspace: {
$$workspaceId: workspaceId,
},
user: {
$$userId: inviterUserId,
},
url: this.url.link(`/invite/${inviteId}`),
},
});
}
@@ -154,23 +134,37 @@ export class WorkspaceService {
}
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
const admins = await this.models.workspaceUser.getAdmins(workspaceId);
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
workspace,
isOwner: true,
url: this.url.link(`/workspace/${workspaceId}`),
const link = this.url.link(`/workspace/${workspaceId}`);
await this.mailer.send({
name: 'TeamWorkspaceUpgraded',
to: owner.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
isOwner: true,
url: link,
},
});
for (const user of admins) {
await this.mailer.sendTeamWorkspaceUpgradedEmail(user.email, {
workspace,
isOwner: false,
url: this.url.link(`/workspace/${workspaceId}`),
});
}
await Promise.allSettled(
admins.map(async user => {
await this.mailer.send({
name: 'TeamWorkspaceUpgraded',
to: user.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
isOwner: false,
url: link,
},
});
})
);
}
async sendReviewRequestedEmail(inviteId: string) {
@@ -180,46 +174,63 @@ export class WorkspaceService {
return;
}
const invitee = await this.models.user.getWorkspaceUser(inviteeUserId);
if (!invitee) {
this.logger.error(
`Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}`
);
return;
}
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
const admin = await this.models.workspaceUser.getAdmins(workspaceId);
const admins = await this.models.workspaceUser.getAdmins(workspaceId);
for (const user of [owner, ...admin]) {
await this.mailer.sendLinkInvitationReviewRequestMail(user.email, {
workspace,
user: invitee,
url: this.url.link(`/workspace/${workspace.id}`),
});
}
await Promise.allSettled(
[owner, ...admins].map(async receiver => {
await this.mailer.send({
name: 'LinkInvitationReviewRequest',
to: receiver.email,
props: {
user: {
$$userId: inviteeUserId,
},
workspace: {
$$workspaceId: workspaceId,
},
url: this.url.link(`/workspace/${workspaceId}`),
},
});
})
);
}
async sendReviewApproveEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) return;
const invitation = await this.models.workspaceUser.getById(inviteId);
if (!invitation) {
this.logger.warn(`Invitation not found for inviteId: ${inviteId}`);
return;
}
await this.mailer.sendLinkInvitationApproveMail(target.email, {
workspace: target.workspace,
url: this.url.link(`/workspace/${target.workspace.id}`),
const user = await this.models.user.getWorkspaceUser(invitation.userId);
if (!user) {
this.logger.warn(`Invitee user not found for inviteId: ${inviteId}`);
return;
}
await this.mailer.send({
name: 'LinkInvitationApprove',
to: user.email,
props: {
workspace: {
$$workspaceId: invitation.workspaceId,
},
url: this.url.link(`/workspace/${invitation.workspaceId}`),
},
});
}
async sendReviewDeclinedEmail(
email: string | undefined,
workspaceId: string
) {
if (!email) return;
const workspace = await this.getWorkspaceInfo(workspaceId);
await this.mailer.sendLinkInvitationDeclineMail(email, {
workspace,
async sendReviewDeclinedEmail(email: string, workspaceId: string) {
await this.mailer.send({
name: 'LinkInvitationDecline',
to: email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
},
});
}
@@ -228,43 +239,75 @@ export class WorkspaceService {
ws: { id: string; role: WorkspaceRole }
) {
const user = await this.models.user.getWorkspaceUser(userId);
if (!user) throw new UserNotFound();
const workspace = await this.getWorkspaceInfo(ws.id);
if (!user) {
this.logger.warn(
`User not found for seeding role changed email: ${userId}`
);
return;
}
if (ws.role === WorkspaceRole.Admin) {
await this.mailer.sendTeamBecomeAdminMail(user.email, {
workspace,
url: this.url.link(`/workspace/${workspace.id}`),
await this.mailer.send({
name: 'TeamBecomeAdmin',
to: user.email,
props: {
workspace: {
$$workspaceId: ws.id,
},
url: this.url.link(`/workspace/${ws.id}`),
},
});
} else {
await this.mailer.sendTeamBecomeCollaboratorMail(user.email, {
workspace,
url: this.url.link(`/workspace/${workspace.id}`),
await this.mailer.send({
name: 'TeamBecomeCollaborator',
to: user.email,
props: {
workspace: {
$$workspaceId: ws.id,
},
url: this.url.link(`/workspace/${ws.id}`),
},
});
}
}
async sendOwnershipTransferredEmail(email: string, ws: { id: string }) {
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendOwnershipTransferredMail(email, { workspace });
await this.mailer.send({
name: 'OwnershipTransferred',
to: email,
props: {
workspace: {
$$workspaceId: ws.id,
},
},
});
}
async sendOwnershipReceivedEmail(email: string, ws: { id: string }) {
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendOwnershipReceivedMail(email, { workspace });
await this.mailer.send({
name: 'OwnershipReceived',
to: email,
props: {
workspace: {
$$workspaceId: ws.id,
},
},
});
}
@OnEvent('workspace.members.leave')
async onMemberLeave({
user,
workspaceId,
}: Events['workspace.members.leave']) {
const workspace = await this.getWorkspaceInfo(workspaceId);
async sendLeaveEmail(workspaceId: string, userId: string) {
const owner = await this.models.workspaceUser.getOwner(workspaceId);
await this.mailer.sendMemberLeaveEmail(owner.email, {
workspace,
user,
await this.mailer.send({
name: 'MemberLeave',
to: owner.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
user: {
$$userId: userId,
},
},
});
}
@@ -274,9 +317,21 @@ export class WorkspaceService {
workspaceId,
}: Events['workspace.members.removed']) {
const user = await this.models.user.get(userId);
if (!user) return;
if (!user) {
this.logger.warn(
`User not found for seeding member removed email: ${userId}`
);
return;
}
const workspace = await this.getWorkspaceInfo(workspaceId);
await this.mailer.sendMemberRemovedMail(user.email, { workspace });
await this.mailer.send({
name: 'MemberRemoved',
to: user.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
},
});
}
}
@@ -118,7 +118,12 @@ export class TeamWorkspaceResolver {
// after user click the invite link, we can check again and reject if charge failed
if (sendInviteMail) {
try {
await this.workspaceService.sendInviteEmail(ret.inviteId);
await this.workspaceService.sendInviteEmail({
workspaceId,
inviteeEmail: target.email,
inviterUserId: user.id,
inviteId: role.id,
});
ret.sentSuccess = true;
} catch (e) {
this.logger.warn(
@@ -493,7 +493,12 @@ export class WorkspaceResolver {
if (sendInviteMail) {
try {
await this.workspaceService.sendInviteEmail(role.id);
await this.workspaceService.sendInviteEmail({
workspaceId,
inviteeEmail: email,
inviterUserId: user.id,
inviteId: role.id,
});
} catch (e) {
await this.models.workspaceUser.delete(workspaceId, user.id);
@@ -577,7 +582,7 @@ export class WorkspaceResolver {
userId,
workspaceId,
});
} else {
} else if (role.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.removed', {
userId,
workspaceId,
@@ -688,13 +693,7 @@ export class WorkspaceResolver {
await this.models.workspaceUser.delete(workspaceId, user.id);
if (sendLeaveMail) {
this.event.emit('workspace.members.leave', {
workspaceId,
user: {
id: user.id,
email: user.email,
},
});
await this.workspaceService.sendLeaveEmail(workspaceId, user.id);
}
return true;
+95 -111
View File
@@ -63,121 +63,105 @@ function make<T extends React.ComponentType<any>>(
};
}
// ================ 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'
);
export const Renderers = {
//#region User
SignIn: make(SignIn, 'Sign in to AFFiNE'),
SignUp: make(SignUp, 'Your AFFiNE account is waiting for you!'),
SetPassword: make(SetPassword, 'Set your AFFiNE password'),
ChangePassword: make(ChangePassword, 'Modify your AFFiNE password'),
VerifyEmail: make(VerifyEmail, 'Verify your email address'),
ChangeEmail: make(ChangeEmail, 'Change your email address'),
VerifyChangeEmail: make(VerifyChangeEmail, 'Verify your new email address'),
EmailChanged: make(ChangeEmailNotification, 'Account email address changed'),
//#endregion
// ================ 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}`
);
//#region Workspace
MemberInvitation: make(
Invitation,
props => `${props.user.email} invited you to join ${props.workspace.name}`
),
MemberAccepted: make(
InvitationAccepted,
props => `${props.user.email} accepted your invitation`
),
MemberLeave: make(
MemberLeave,
props => `${props.user.email} left ${props.workspace.name}`
),
LinkInvitationReviewRequest: make(
LinkInvitationReviewRequest,
props => `New request to join ${props.workspace.name}`
),
LinkInvitationApprove: make(
LinkInvitationApproved,
props => `Your request to join ${props.workspace.name} has been approved`
),
LinkInvitationDecline: make(
LinkInvitationReviewDeclined,
props => `Your request to join ${props.workspace.name} was declined`
),
MemberRemoved: make(
MemberRemoved,
props => `You have been removed from ${props.workspace.name}`
),
OwnershipTransferred: make(
OwnershipTransferred,
props => `Your ownership of ${props.workspace.name} has been transferred`
),
OwnershipReceived: make(
OwnershipReceived,
props => `You are now the owner of ${props.workspace.name}`
),
//#endregion
// ================ Team ================
export const renderTeamWorkspaceUpgradedMail = make(
TeamWorkspaceUpgraded,
props =>
//#region Team
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
props.isOwner
? 'Your workspace has been upgraded to team workspace! 🎉'
: `${props.workspace.name} has been upgraded to team workspace! 🎉`
);
),
TeamBecomeAdmin: make(
TeamBecomeAdmin,
props => `You are now an admin of ${props.workspace.name}`
),
TeamBecomeCollaborator: make(
TeamBecomeCollaborator,
props => `Your role has been changed in ${props.workspace.name}`
),
TeamDeleteIn24Hours: make(
TeamDeleteIn24Hours,
props =>
`[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours`
),
TeamDeleteInOneMonth: make(
TeamDeleteInOneMonth,
props =>
`[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon`
),
TeamWorkspaceDeleted: make(
TeamWorkspaceDeleted,
props => `Your workspace ${props.workspace.name} has been deleted`
),
TeamWorkspaceExpireSoon: make(
TeamExpireSoon,
props =>
`[Action Required] Your ${props.workspace.name} team workspace will expire soon`
),
TeamWorkspaceExpired: make(
TeamExpired,
props => `Your ${props.workspace.name} team workspace has expired`
),
//#endregion
export const renderTeamBecomeAdminMail = make(
TeamBecomeAdmin,
props => `You are now an admin of ${props.workspace.name}`
);
//#region License
TeamLicense: make(
TeamLicense,
'Your AFFiNE Self-Hosted Team Workspace license is ready'
),
//#endregion
} as const;
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`
);
export const renderTeamLicenseMail = make(
TeamLicense,
'Your AFFiNE Self-Hosted Team Workspace license is ready'
);
export type MailName = keyof typeof Renderers;
export type MailProps<T extends MailName> = Parameters<
(typeof Renderers)[T]
>[0];
@@ -2,6 +2,7 @@ import './config';
import { ServerFeature } from '../../core/config';
import { FeatureModule } from '../../core/features';
import { MailModule } from '../../core/mail';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user';
@@ -33,6 +34,7 @@ import { StripeWebhook } from './webhook';
UserModule,
PermissionModule,
WorkspaceModule,
MailModule,
],
providers: [
StripeProvider,
@@ -6,11 +6,8 @@ import { pick } from 'lodash-es';
import Stripe from 'stripe';
import { z } from 'zod';
import {
MailService,
SubscriptionPlanNotFound,
URLHelper,
} from '../../../base';
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
import { Mailer } from '../../../core/mail';
import {
KnownStripeInvoice,
KnownStripePrice,
@@ -49,7 +46,7 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
stripe: Stripe,
db: PrismaClient,
private readonly url: URLHelper,
private readonly mailer: MailService
private readonly mailer: Mailer
) {
super(stripe, db);
}
@@ -140,7 +137,11 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
}),
]);
await this.mailer.sendTeamLicenseMail(userEmail, { license: key });
await this.mailer.send({
name: 'TeamLicense',
to: userEmail,
props: { license: key },
});
return subscription;
} else {
-1
View File
@@ -12,7 +12,6 @@
},
"include": ["./src"],
"references": [
{ "path": "../../../tests/kit" },
{ "path": "../../../tools/cli" },
{ "path": "../../../tools/utils" },
{ "path": "../native" }
-1
View File
@@ -654,7 +654,6 @@ export const PackageList = [
location: 'packages/backend/server',
name: '@affine/server',
workspaceDependencies: [
'tests/kit',
'tools/cli',
'tools/utils',
'packages/backend/native',
-1
View File
@@ -842,7 +842,6 @@ __metadata:
version: 0.0.0-use.local
resolution: "@affine/server@workspace:packages/backend/server"
dependencies:
"@affine-test/kit": "workspace:*"
"@affine-tools/cli": "workspace:*"
"@affine-tools/utils": "workspace:*"
"@affine/server-native": "workspace:*"