␊
- ␊
- ␊
- ␊
- ␊
- ␊
- 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.␊
- ␊
- ␊
- ␊
- ␊
- ␊
- ␊
- ␊
- Verify your email address␊
+ ␊
+ You can use this key to upgrade your selfhost workspace in␊
+ Settings > Workspace > License.␊
+ ␊
␊
␊
␊
diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap
new file mode 100644
index 0000000000..aab688d27e
Binary files /dev/null and b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap differ
diff --git a/packages/backend/server/src/__tests__/auth/auth.e2e.ts b/packages/backend/server/src/__tests__/auth/auth.e2e.ts
index f1aad7a23e..a294c2c810 100644
--- a/packages/backend/server/src/__tests__/auth/auth.e2e.ts
+++ b/packages/backend/server/src/__tests__/auth/auth.e2e.ts
@@ -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');
});
diff --git a/packages/backend/server/src/__tests__/auth/controller.spec.ts b/packages/backend/server/src/__tests__/auth/controller.spec.ts
index f61f53cf36..82aba5a860 100644
--- a/packages/backend/server/src/__tests__/auth/controller.spec.ts
+++ b/packages/backend/server/src/__tests__/auth/controller.spec.ts
@@ -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;
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');
diff --git a/packages/backend/server/src/__tests__/e2e/create-app.ts b/packages/backend/server/src/__tests__/e2e/create-app.ts
index 714d4a9216..b92ed3f43c 100644
--- a/packages/backend/server/src/__tests__/e2e/create-app.ts
+++ b/packages/backend/server/src/__tests__/e2e/create-app.ts
@@ -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();
}
diff --git a/packages/backend/server/src/__tests__/mailer.spec.ts b/packages/backend/server/src/__tests__/mailer.spec.ts
deleted file mode 100644
index b91ff4dd96..0000000000
--- a/packages/backend/server/src/__tests__/mailer.spec.ts
+++ /dev/null
@@ -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);
- }
-});
diff --git a/packages/backend/server/src/__tests__/mails.spec.ts b/packages/backend/server/src/__tests__/mails.spec.ts
new file mode 100644
index 0000000000..19a79e27c9
--- /dev/null
+++ b/packages/backend/server/src/__tests__/mails.spec.ts
@@ -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);
+ }
+});
diff --git a/packages/backend/server/src/__tests__/mocks/index.ts b/packages/backend/server/src/__tests__/mocks/index.ts
index 212b716342..4e8dba884d 100644
--- a/packages/backend/server/src/__tests__/mocks/index.ts
+++ b/packages/backend/server/src/__tests__/mocks/index.ts
@@ -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 };
diff --git a/packages/backend/server/src/__tests__/mocks/mailer.mock.ts b/packages/backend/server/src/__tests__/mocks/mailer.mock.ts
new file mode 100644
index 0000000000..b377ebecf0
--- /dev/null
+++ b/packages/backend/server/src/__tests__/mocks/mailer.mock.ts
@@ -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(
+ name: Mail
+ ): Extract {
+ 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;
+ }
+}
diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts
index b5cd982fab..a64ddd9b73 100644
--- a/packages/backend/server/src/__tests__/team.e2e.ts
+++ b/packages/backend/server/src/__tests__/team.e2e.ts
@@ -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 => {
diff --git a/packages/backend/server/src/__tests__/utils/testing-app.ts b/packages/backend/server/src/__tests__/utils/testing-app.ts
index 695d7d6735..eb40474560 100644
--- a/packages/backend/server/src/__tests__/utils/testing-app.ts
+++ b/packages/backend/server/src/__tests__/utils/testing-app.ts
@@ -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() {
private readonly userCookies: Set = new Set();
readonly create!: ReturnType;
+ readonly mails!: MockMailer;
[Symbol.asyncDispose](): Promise {
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) {
diff --git a/packages/backend/server/src/__tests__/utils/testing-module.ts b/packages/backend/server/src/__tests__/utils/testing-module.ts
index a784f53b69..4df3b15c94 100644
--- a/packages/backend/server/src/__tests__/utils/testing-module.ts
+++ b/packages/backend/server/src/__tests__/utils/testing-module.ts
@@ -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;
create: ReturnType;
+ mails: MockMailer;
[Symbol.asyncDispose](): Promise;
}
@@ -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
diff --git a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts
index 8135215800..6dcdf4d062 100644
--- a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts
+++ b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts
@@ -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 => {
diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts
index 1cfc2d5f2b..762a0159c7 100644
--- a/packages/backend/server/src/app.module.ts
+++ b/packages/backend/server/src/app.module.ts
@@ -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)
diff --git a/packages/backend/server/src/base/index.ts b/packages/backend/server/src/base/index.ts
index add6b5b2ac..8e6ba4421a 100644
--- a/packages/backend/server/src/base/index.ts
+++ b/packages/backend/server/src/base/index.ts
@@ -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';
diff --git a/packages/backend/server/src/base/job/index.ts b/packages/backend/server/src/base/job/index.ts
index b815378bf7..eb4f57bd48 100644
--- a/packages/backend/server/src/base/job/index.ts
+++ b/packages/backend/server/src/base/job/index.ts
@@ -1 +1 @@
-export { JobModule, JobQueue, OnJob } from './queue';
+export { JOB_SIGNAL, JobModule, JobQueue, OnJob } from './queue';
diff --git a/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts b/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts
index 5e9abda8b9..14f2e18fe6 100644
--- a/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts
+++ b/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts
@@ -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
diff --git a/packages/backend/server/src/base/job/queue/def.ts b/packages/backend/server/src/base/job/queue/def.ts
index 01e9b70050..6db33e2908 100644
--- a/packages/backend/server/src/base/job/queue/def.ts
+++ b/packages/backend/server/src/base/job/queue/def.ts
@@ -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(JOB_METADATA, target);
}
+
+export enum JOB_SIGNAL {
+ RETRY = 'retry',
+}
diff --git a/packages/backend/server/src/base/job/queue/executor.ts b/packages/backend/server/src/base/job/queue/executor.ts
index 10efd8aaed..a9e019b0c3 100644
--- a/packages/backend/server/src/base/job/queue/executor.ts
+++ b/packages/backend/server/src/base/job/queue/executor.ts
@@ -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 });
}
diff --git a/packages/backend/server/src/base/job/queue/index.ts b/packages/backend/server/src/base/job/queue/index.ts
index 3cde80a531..70124a6364 100644
--- a/packages/backend/server/src/base/job/queue/index.ts
+++ b/packages/backend/server/src/base/job/queue/index.ts
@@ -42,4 +42,4 @@ export class JobModule {
}
export { JobQueue };
-export { OnJob } from './def';
+export { JOB_SIGNAL, OnJob } from './def';
diff --git a/packages/backend/server/src/base/job/queue/scanner.ts b/packages/backend/server/src/base/job/queue/scanner.ts
index 22aee769bf..cf53a053da 100644
--- a/packages/backend/server/src/base/job/queue/scanner.ts
+++ b/packages/backend/server/src/base/job/queue/scanner.ts
@@ -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;
}
@Injectable()
diff --git a/packages/backend/server/src/base/mailer/index.ts b/packages/backend/server/src/base/mailer/index.ts
deleted file mode 100644
index d6de678a55..0000000000
--- a/packages/backend/server/src/base/mailer/index.ts
+++ /dev/null
@@ -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 };
diff --git a/packages/backend/server/src/base/mailer/mail.service.ts b/packages/backend/server/src/base/mailer/mail.service.ts
deleted file mode 100644
index 5dc8533216..0000000000
--- a/packages/backend/server/src/base/mailer/mail.service.ts
+++ /dev/null
@@ -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 ? P : never;
-type Sender> = (
- to: string,
- props: Props
-) => Promise;
-
-function make>(
- sender: {
- send: (options: Options) => Promise;
- },
- renderer: T,
- factory?: (props: Props) => {
- props: Props;
- options: Partial;
- }
-): Sender {
- 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>(
- renderer: T,
- factory?: (props: Props) => {
- props: Props;
- options: Partial;
- }
- ) {
- 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>(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);
-}
diff --git a/packages/backend/server/src/base/mailer/mailer.ts b/packages/backend/server/src/base/mailer/mailer.ts
deleted file mode 100644
index eed4a4f4d9..0000000000
--- a/packages/backend/server/src/base/mailer/mailer.ts
+++ /dev/null
@@ -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;
-export type Response = SMTPTransport.SentMessageInfo;
-export type Options = SMTPTransport.Options;
-
-export const MAILER: FactoryProvider<
- Transporter | 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],
-};
diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts
index a5b067fad9..02b7280333 100644
--- a/packages/backend/server/src/core/auth/controller.ts
+++ b/packages/backend/server/src/core/auth/controller.ts
@@ -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,
diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts
index 94a0e45019..506bf79eea 100644
--- a/packages/backend/server/src/core/auth/index.ts
+++ b/packages/backend/server/src/core/auth/index.ts
@@ -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,
diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts
index c7c60767a9..065d978210 100644
--- a/packages/backend/server/src/core/auth/resolver.ts
+++ b/packages/backend/server/src/core/auth/resolver.ts
@@ -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)
diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts
index 3aede5a09e..141efe30fc 100644
--- a/packages/backend/server/src/core/auth/service.ts
+++ b/packages/backend/server/src/core/auth/service.ts
@@ -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,
+ },
+ });
}
}
diff --git a/packages/backend/server/src/base/mailer/config.ts b/packages/backend/server/src/core/mail/config.ts
similarity index 74%
rename from packages/backend/server/src/base/mailer/config.ts
rename to packages/backend/server/src/core/mail/config.ts
index a65891bbed..b2a2754512 100644
--- a/packages/backend/server/src/base/mailer/config.ts
+++ b/packages/backend/server/src/core/mail/config.ts
@@ -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.
diff --git a/packages/backend/server/src/core/mail/index.ts b/packages/backend/server/src/core/mail/index.ts
new file mode 100644
index 0000000000..834c25c74d
--- /dev/null
+++ b/packages/backend/server/src/core/mail/index.ts
@@ -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 };
diff --git a/packages/backend/server/src/core/mail/job.ts b/packages/backend/server/src/core/mail/job.ts
new file mode 100644
index 0000000000..61317081a1
--- /dev/null
+++ b/packages/backend/server/src/core/mail/job.ts
@@ -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 = {
+ [Key in keyof Props]: Props[Key] extends infer Prop
+ ? Prop extends UserProps
+ ? {
+ $$userId: string;
+ } & Omit
+ : Prop extends WorkspaceProps
+ ? {
+ $$workspaceId: string;
+ } & Omit
+ : Prop
+ : never;
+};
+
+type SendMailJob> = {
+ 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;
+};
+
+declare global {
+ interface Jobs {
+ 'notification.sendMail': {
+ [K in MailName]: SendMailJob;
+ }[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 = {};
+
+ 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;
+ }
+}
diff --git a/packages/backend/server/src/core/mail/mailer.ts b/packages/backend/server/src/core/mail/mailer.ts
new file mode 100644
index 0000000000..3263ee1b24
--- /dev/null
+++ b/packages/backend/server/src/core/mail/mailer.ts
@@ -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;
+ }
+ }
+}
diff --git a/packages/backend/server/src/core/mail/sender.ts b/packages/backend/server/src/core/mail/sender.ts
new file mode 100644
index 0000000000..e07ab5e07d
--- /dev/null
+++ b/packages/backend/server/src/core/mail/sender.ts
@@ -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 & {
+ to: string;
+ subject: string;
+ html: string;
+};
+
+@Injectable()
+export class MailSender implements OnModuleInit {
+ private readonly logger = new Logger(MailSender.name);
+ private smtp: Transporter | 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;
+ }
+ }
+}
diff --git a/packages/backend/server/src/core/workspaces/event.ts b/packages/backend/server/src/core/workspaces/event.ts
index 309afd72d0..ff9930b1a6 100644
--- a/packages/backend/server/src/core/workspaces/event.ts
+++ b/packages/backend/server/src/core/workspaces/event.ts
@@ -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
);
}
diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts
index 0879672f0a..f7efd06725 100644
--- a/packages/backend/server/src/core/workspaces/index.ts
+++ b/packages/backend/server/src/core/workspaces/index.ts
@@ -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: [
diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts
index 1a38099213..51575bac6a 100644
--- a/packages/backend/server/src/core/workspaces/resolvers/service.ts
+++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts
@@ -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 {
@@ -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,
+ },
+ },
+ });
}
}
diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts
index 2fbd3a1e47..e682eb2e3e 100644
--- a/packages/backend/server/src/core/workspaces/resolvers/team.ts
+++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts
@@ -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(
diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts
index 00aea3ec81..5541036ec2 100644
--- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts
+++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts
@@ -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;
diff --git a/packages/backend/server/src/mails/index.tsx b/packages/backend/server/src/mails/index.tsx
index 3d91806573..4dc12c4828 100644
--- a/packages/backend/server/src/mails/index.tsx
+++ b/packages/backend/server/src/mails/index.tsx
@@ -63,121 +63,105 @@ function make>(
};
}
-// ================ 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 = Parameters<
+ (typeof Renderers)[T]
+>[0];
diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts
index 4504b7f233..cdcc9520cd 100644
--- a/packages/backend/server/src/plugins/payment/index.ts
+++ b/packages/backend/server/src/plugins/payment/index.ts
@@ -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,
diff --git a/packages/backend/server/src/plugins/payment/manager/selfhost.ts b/packages/backend/server/src/plugins/payment/manager/selfhost.ts
index 917ce86219..6e0321428a 100644
--- a/packages/backend/server/src/plugins/payment/manager/selfhost.ts
+++ b/packages/backend/server/src/plugins/payment/manager/selfhost.ts
@@ -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 {
diff --git a/packages/backend/server/tsconfig.json b/packages/backend/server/tsconfig.json
index da980b0e2b..45ba2e7e23 100644
--- a/packages/backend/server/tsconfig.json
+++ b/packages/backend/server/tsconfig.json
@@ -12,7 +12,6 @@
},
"include": ["./src"],
"references": [
- { "path": "../../../tests/kit" },
{ "path": "../../../tools/cli" },
{ "path": "../../../tools/utils" },
{ "path": "../native" }
diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts
index 3a14edf895..15e610d466 100644
--- a/tools/utils/src/workspace.gen.ts
+++ b/tools/utils/src/workspace.gen.ts
@@ -654,7 +654,6 @@ export const PackageList = [
location: 'packages/backend/server',
name: '@affine/server',
workspaceDependencies: [
- 'tests/kit',
'tools/cli',
'tools/utils',
'packages/backend/native',
diff --git a/yarn.lock b/yarn.lock
index 6c455411d1..52aa1e4103 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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:*"
|