mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
refactor(server): mail service (#10934)
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
Binary file not shown.
+1235
-1235
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../../tests/kit" },
|
||||
{ "path": "../../../tools/cli" },
|
||||
{ "path": "../../../tools/utils" },
|
||||
{ "path": "../native" }
|
||||
|
||||
@@ -654,7 +654,6 @@ export const PackageList = [
|
||||
location: 'packages/backend/server',
|
||||
name: '@affine/server',
|
||||
workspaceDependencies: [
|
||||
'tests/kit',
|
||||
'tools/cli',
|
||||
'tools/utils',
|
||||
'packages/backend/native',
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user