refactor(server): mail service (#10934)
This commit is contained in:
1
.github/actions/setup-node/action.yml
vendored
1
.github/actions/setup-node/action.yml
vendored
@@ -127,7 +127,6 @@ runs:
|
|||||||
|
|
||||||
- name: yarn install
|
- name: yarn install
|
||||||
if: ${{ inputs.package-install == 'true' }}
|
if: ${{ inputs.package-install == 'true' }}
|
||||||
continue-on-error: true
|
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ${{ steps.workspace-path.outputs.workspace_path }}
|
working-directory: ${{ steps.workspace-path.outputs.workspace_path }}
|
||||||
run: yarn ${{ inputs.extra-flags }}
|
run: yarn ${{ inputs.extra-flags }}
|
||||||
|
|||||||
@@ -112,7 +112,6 @@
|
|||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-test/kit": "workspace:*",
|
|
||||||
"@affine-tools/cli": "workspace:*",
|
"@affine-tools/cli": "workspace:*",
|
||||||
"@affine-tools/utils": "workspace:*",
|
"@affine-tools/utils": "workspace:*",
|
||||||
"@affine/server-native": "workspace:*",
|
"@affine/server-native": "workspace:*",
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,13 +1,8 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
|
||||||
getCurrentMailMessageCount,
|
|
||||||
getTokenFromLatestMailMessage,
|
|
||||||
} from '@affine-test/kit/utils/cloud';
|
|
||||||
import type { TestFn } from 'ava';
|
import type { TestFn } from 'ava';
|
||||||
import ava from 'ava';
|
import ava from 'ava';
|
||||||
|
|
||||||
import { MailService } from '../../base/mailer';
|
|
||||||
import {
|
import {
|
||||||
changeEmail,
|
changeEmail,
|
||||||
changePassword,
|
changePassword,
|
||||||
@@ -21,14 +16,11 @@ import {
|
|||||||
|
|
||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
app: TestingApp;
|
app: TestingApp;
|
||||||
mail: MailService;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.beforeEach(async t => {
|
test.beforeEach(async t => {
|
||||||
const app = await createTestingApp();
|
const app = await createTestingApp();
|
||||||
const mail = app.get(MailService);
|
|
||||||
t.context.app = app;
|
t.context.app = app;
|
||||||
t.context.mail = mail;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach.always(async t => {
|
test.afterEach.always(async t => {
|
||||||
@@ -36,182 +28,106 @@ test.afterEach.always(async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('change email', async t => {
|
test('change email', async t => {
|
||||||
const { mail, app } = t.context;
|
const { app } = t.context;
|
||||||
if (mail.hasConfigured()) {
|
const u1Email = 'u1@affine.pro';
|
||||||
const u1Email = 'u1@affine.pro';
|
const u2Email = 'u2@affine.pro';
|
||||||
const u2Email = 'u2@affine.pro';
|
|
||||||
|
|
||||||
await app.signupV1(u1Email);
|
const user = await app.signupV1(u1Email);
|
||||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
await sendChangeEmail(app, u1Email, 'affine.pro');
|
||||||
await sendChangeEmail(app, u1Email, 'affine.pro');
|
|
||||||
|
|
||||||
const afterSendChangeMailCount = await getCurrentMailMessageCount();
|
const changeMail = app.mails.last('ChangeEmail');
|
||||||
t.is(
|
|
||||||
primitiveMailCount + 1,
|
|
||||||
afterSendChangeMailCount,
|
|
||||||
'failed to send change email'
|
|
||||||
);
|
|
||||||
|
|
||||||
const changeEmailToken = await getTokenFromLatestMailMessage();
|
t.is(changeMail.to, u1Email);
|
||||||
|
|
||||||
t.not(
|
let link = new URL(changeMail.props.url);
|
||||||
changeEmailToken,
|
|
||||||
null,
|
|
||||||
'fail to get change email token from email content'
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendVerifyChangeEmail(
|
const changeEmailToken = link.searchParams.get('token');
|
||||||
app,
|
|
||||||
changeEmailToken as string,
|
|
||||||
u2Email,
|
|
||||||
'affine.pro'
|
|
||||||
);
|
|
||||||
|
|
||||||
const afterSendVerifyMailCount = await getCurrentMailMessageCount();
|
t.not(
|
||||||
|
changeEmailToken,
|
||||||
|
null,
|
||||||
|
'fail to get change email token from email content'
|
||||||
|
);
|
||||||
|
|
||||||
t.is(
|
await sendVerifyChangeEmail(
|
||||||
afterSendChangeMailCount + 1,
|
app,
|
||||||
afterSendVerifyMailCount,
|
changeEmailToken as string,
|
||||||
'failed to send verify email'
|
u2Email,
|
||||||
);
|
'affine.pro'
|
||||||
|
);
|
||||||
|
|
||||||
const verifyEmailToken = await getTokenFromLatestMailMessage();
|
const verifyMail = app.mails.last('VerifyChangeEmail');
|
||||||
|
|
||||||
t.not(
|
t.is(verifyMail.to, u2Email);
|
||||||
verifyEmailToken,
|
|
||||||
null,
|
|
||||||
'fail to get verify change email token from email content'
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
t.not(
|
||||||
afterSendVerifyMailCount + 1,
|
verifyEmailToken,
|
||||||
afterNotificationMailCount,
|
null,
|
||||||
'failed to send notification email'
|
'fail to get verify change email token from email content'
|
||||||
);
|
);
|
||||||
}
|
|
||||||
t.pass();
|
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 => {
|
test('set and change password', async t => {
|
||||||
const { mail, app } = t.context;
|
const { app } = t.context;
|
||||||
if (mail.hasConfigured()) {
|
const u1Email = 'u1@affine.pro';
|
||||||
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(
|
t.true(success, 'failed to change password');
|
||||||
primitiveMailCount + 1,
|
|
||||||
afterSendSetMailCount,
|
|
||||||
'failed to send set email'
|
|
||||||
);
|
|
||||||
|
|
||||||
const setPasswordToken = await getTokenFromLatestMailMessage();
|
let user = await currentUser(app);
|
||||||
|
|
||||||
t.not(
|
t.is(user, null);
|
||||||
setPasswordToken,
|
|
||||||
null,
|
|
||||||
'fail to get set password token from email content'
|
|
||||||
);
|
|
||||||
|
|
||||||
const newPassword = randomBytes(16).toString('hex');
|
await app.login({
|
||||||
const success = await changePassword(
|
...u1,
|
||||||
app,
|
password: newPassword,
|
||||||
u1.id,
|
});
|
||||||
setPasswordToken as string,
|
|
||||||
newPassword
|
|
||||||
);
|
|
||||||
|
|
||||||
t.true(success, 'failed to change password');
|
user = await currentUser(app);
|
||||||
|
|
||||||
await app.login({
|
t.not(user, null, 'failed to get current user');
|
||||||
...u1,
|
t.is(user?.email, u1Email, 'failed to get current user');
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import ava, { TestFn } from 'ava';
|
import ava, { TestFn } from 'ava';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { MailService } from '../../base';
|
|
||||||
import { AuthModule } from '../../core/auth';
|
import { AuthModule } from '../../core/auth';
|
||||||
import { AuthService } from '../../core/auth/service';
|
import { AuthService } from '../../core/auth/service';
|
||||||
import { FeatureModule } from '../../core/features';
|
import { FeatureModule } from '../../core/features';
|
||||||
@@ -20,26 +19,16 @@ import {
|
|||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
db: PrismaClient;
|
db: PrismaClient;
|
||||||
mailer: Sinon.SinonStubbedInstance<MailService>;
|
|
||||||
app: TestingApp;
|
app: TestingApp;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
const app = await createTestingApp({
|
const app = await createTestingApp({
|
||||||
imports: [FeatureModule, UserModule, AuthModule],
|
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.auth = app.get(AuthService);
|
||||||
t.context.db = app.get(PrismaClient);
|
t.context.db = app.get(PrismaClient);
|
||||||
t.context.mailer = app.get(MailService);
|
|
||||||
t.context.app = app;
|
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 => {
|
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');
|
const u1 = await app.createUser('u1@affine.pro');
|
||||||
// @ts-expect-error mock
|
|
||||||
mailer.sendSignInMail.resolves({ rejected: [] });
|
|
||||||
|
|
||||||
const res = await app
|
const res = await app
|
||||||
.POST('/api/auth/sign-in')
|
.POST('/api/auth/sign-in')
|
||||||
@@ -79,10 +66,11 @@ test('should be able to sign in with email', async t => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
t.is(res.body.email, u1.email);
|
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;
|
t.is(signInMail.to, u1.email);
|
||||||
const url = new URL(signInLink);
|
|
||||||
|
const url = new URL(signInMail.props.url);
|
||||||
const email = url.searchParams.get('email');
|
const email = url.searchParams.get('email');
|
||||||
const token = url.searchParams.get('token');
|
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 => {
|
test('should be able to sign up with email', async t => {
|
||||||
const { app, mailer } = t.context;
|
const { app } = t.context;
|
||||||
|
|
||||||
// @ts-expect-error mock
|
|
||||||
mailer.sendSignUpMail.resolves({ rejected: [] });
|
|
||||||
|
|
||||||
const res = await app
|
const res = await app
|
||||||
.POST('/api/auth/sign-in')
|
.POST('/api/auth/sign-in')
|
||||||
@@ -104,10 +89,11 @@ test('should be able to sign up with email', async t => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
t.is(res.body.email, 'u2@affine.pro');
|
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;
|
t.is(signUpMail.to, 'u2@affine.pro');
|
||||||
const url = new URL(signUpLink);
|
|
||||||
|
const url = new URL(signUpMail.props.url);
|
||||||
const email = url.searchParams.get('email');
|
const email = url.searchParams.get('email');
|
||||||
const token = url.searchParams.get('token');
|
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 => {
|
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 u1 = await app.createUser('u1@affine.pro');
|
||||||
const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false);
|
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 })
|
.send({ email: u1.email })
|
||||||
.expect(HttpStatus.FORBIDDEN);
|
.expect(HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
t.true(mailer.sendSignInMail.notCalled);
|
|
||||||
|
|
||||||
canSignInStub.restore();
|
canSignInStub.restore();
|
||||||
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to sign out', async t => {
|
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 => {
|
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 clientNonce = randomUUID();
|
||||||
const u1 = await app.createUser();
|
const u1 = await app.createUser();
|
||||||
// @ts-expect-error mock
|
|
||||||
mailer.sendSignInMail.resolves({ rejected: [] });
|
|
||||||
|
|
||||||
const res = await app
|
const res = await app
|
||||||
.POST('/api/auth/sign-in')
|
.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);
|
.expect(200);
|
||||||
|
|
||||||
t.is(res.body.email, u1.email);
|
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;
|
t.is(signInMail.to, u1.email);
|
||||||
const url = new URL(signInLink);
|
|
||||||
|
const url = new URL(signInMail.props.url);
|
||||||
const email = url.searchParams.get('email');
|
const email = url.searchParams.get('email');
|
||||||
const token = url.searchParams.get('token');
|
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 => {
|
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 clientNonce = randomUUID();
|
||||||
const u1 = await app.createUser();
|
const u1 = await app.createUser();
|
||||||
// @ts-expect-error mock
|
|
||||||
mailer.sendSignInMail.resolves({ rejected: [] });
|
|
||||||
|
|
||||||
const res = await app
|
const res = await app
|
||||||
.POST('/api/auth/sign-in')
|
.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);
|
.expect(200);
|
||||||
|
|
||||||
t.is(res.body.email, u1.email);
|
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;
|
t.is(signInMail.to, u1.email);
|
||||||
const url = new URL(signInLink);
|
|
||||||
|
const url = new URL(signInMail.props.url);
|
||||||
const email = url.searchParams.get('email');
|
const email = url.searchParams.get('email');
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { SocketIoAdapter } from '../../base/websocket';
|
import { SocketIoAdapter } from '../../base/websocket';
|
||||||
import { AuthGuard } from '../../core/auth';
|
import { AuthGuard } from '../../core/auth';
|
||||||
|
import { Mailer } from '../../core/mail';
|
||||||
|
import { MockMailer } from '../mocks';
|
||||||
import { TEST_LOG_LEVEL } from '../utils';
|
import { TEST_LOG_LEVEL } from '../utils';
|
||||||
|
|
||||||
interface TestingAppMetadata {
|
interface TestingAppMetadata {
|
||||||
@@ -21,6 +23,10 @@ interface TestingAppMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TestingApp extends NestApplication {
|
export class TestingApp extends NestApplication {
|
||||||
|
get mails() {
|
||||||
|
return this.get(Mailer, { strict: false }) as MockMailer;
|
||||||
|
}
|
||||||
|
|
||||||
async [Symbol.asyncDispose]() {
|
async [Symbol.asyncDispose]() {
|
||||||
await this.close();
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
11
packages/backend/server/src/__tests__/mails.spec.ts
Normal file
11
packages/backend/server/src/__tests__/mails.spec.ts
Normal file
@@ -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 './user.mock';
|
||||||
export * from './workspace.mock';
|
export * from './workspace.mock';
|
||||||
|
|
||||||
|
import { MockMailer } from './mailer.mock';
|
||||||
import { MockTeamWorkspace } from './team-workspace.mock';
|
import { MockTeamWorkspace } from './team-workspace.mock';
|
||||||
import { MockUser } from './user.mock';
|
import { MockUser } from './user.mock';
|
||||||
import { MockWorkspace } from './workspace.mock';
|
import { MockWorkspace } from './workspace.mock';
|
||||||
@@ -12,3 +13,5 @@ export const Mockers = {
|
|||||||
Workspace: MockWorkspace,
|
Workspace: MockWorkspace,
|
||||||
TeamWorkspace: MockTeamWorkspace,
|
TeamWorkspace: MockTeamWorkspace,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { MockMailer };
|
||||||
|
|||||||
29
packages/backend/server/src/__tests__/mocks/mailer.mock.ts
Normal file
29
packages/backend/server/src/__tests__/mocks/mailer.mock.ts
Normal file
@@ -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 { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud';
|
|
||||||
import { User, WorkspaceMemberStatus } from '@prisma/client';
|
import { User, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import type { TestFn } from 'ava';
|
import type { TestFn } from 'ava';
|
||||||
import ava 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 => {
|
test('should be able to send mails', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
const { inviteBatch } = await init(app, 5);
|
const { inviteBatch } = await init(app, 5);
|
||||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
|
||||||
|
|
||||||
{
|
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
|
||||||
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
|
t.is(app.mails.count('MemberInvitation'), 2);
|
||||||
t.is(await getCurrentMailMessageCount(), primitiveMailCount + 2);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to emit events', async t => {
|
test('should be able to emit events', async t => {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import supertest from 'supertest';
|
|||||||
|
|
||||||
import { AFFiNELogger, ApplyType, GlobalExceptionFilter } from '../../base';
|
import { AFFiNELogger, ApplyType, GlobalExceptionFilter } from '../../base';
|
||||||
import { AuthService } from '../../core/auth';
|
import { AuthService } from '../../core/auth';
|
||||||
|
import { Mailer } from '../../core/mail';
|
||||||
import { UserModel } from '../../models';
|
import { UserModel } from '../../models';
|
||||||
import { createFactory, MockedUser, MockUser, MockUserInput } from '../mocks';
|
import { createFactory, MockedUser, MockUser, MockUserInput } from '../mocks';
|
||||||
|
import { MockMailer } from '../mocks/mailer.mock';
|
||||||
import { createTestingModule } from './testing-module';
|
import { createTestingModule } from './testing-module';
|
||||||
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
|
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
|||||||
private readonly userCookies: Set<string> = new Set();
|
private readonly userCookies: Set<string> = new Set();
|
||||||
|
|
||||||
readonly create!: ReturnType<typeof createFactory>;
|
readonly create!: ReturnType<typeof createFactory>;
|
||||||
|
readonly mails!: MockMailer;
|
||||||
|
|
||||||
[Symbol.asyncDispose](): Promise<void> {
|
[Symbol.asyncDispose](): Promise<void> {
|
||||||
return this.close();
|
return this.close();
|
||||||
@@ -280,6 +283,8 @@ function makeTestingApp(app: INestApplication): TestingApp {
|
|||||||
|
|
||||||
// @ts-expect-error allow
|
// @ts-expect-error allow
|
||||||
testingApp.create = createFactory(app.get(PrismaClient, { strict: false }));
|
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, {
|
return new Proxy(testingApp, {
|
||||||
get(target, prop) {
|
get(target, prop) {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import { AppModule, FunctionalityModules } from '../../app.module';
|
|||||||
import { AFFiNELogger, Runtime } from '../../base';
|
import { AFFiNELogger, Runtime } from '../../base';
|
||||||
import { GqlModule } from '../../base/graphql';
|
import { GqlModule } from '../../base/graphql';
|
||||||
import { AuthGuard, AuthModule } from '../../core/auth';
|
import { AuthGuard, AuthModule } from '../../core/auth';
|
||||||
|
import { Mailer, MailModule } from '../../core/mail';
|
||||||
import { ModelsModule } from '../../models';
|
import { ModelsModule } from '../../models';
|
||||||
// for jsdoc inference
|
// for jsdoc inference
|
||||||
// oxlint-disable-next-line no-unused-vars
|
// oxlint-disable-next-line no-unused-vars
|
||||||
import type { createModule } from '../create-module';
|
import type { createModule } from '../create-module';
|
||||||
import { createFactory } from '../mocks';
|
import { createFactory } from '../mocks';
|
||||||
|
import { MockMailer } from '../mocks/mailer.mock';
|
||||||
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
|
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
|
||||||
|
|
||||||
interface TestingModuleMeatdata extends ModuleMetadata {
|
interface TestingModuleMeatdata extends ModuleMetadata {
|
||||||
@@ -26,6 +28,7 @@ interface TestingModuleMeatdata extends ModuleMetadata {
|
|||||||
export interface TestingModule extends BaseTestingModule {
|
export interface TestingModule extends BaseTestingModule {
|
||||||
initTestingDB(): Promise<void>;
|
initTestingDB(): Promise<void>;
|
||||||
create: ReturnType<typeof createFactory>;
|
create: ReturnType<typeof createFactory>;
|
||||||
|
mails: MockMailer;
|
||||||
[Symbol.asyncDispose](): Promise<void>;
|
[Symbol.asyncDispose](): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ export async function createTestingModule(
|
|||||||
ModelsModule,
|
ModelsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
GqlModule,
|
GqlModule,
|
||||||
|
MailModule,
|
||||||
...imports,
|
...imports,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -87,6 +91,7 @@ export async function createTestingModule(
|
|||||||
if (moduleDef.tapModule) {
|
if (moduleDef.tapModule) {
|
||||||
moduleDef.tapModule(builder);
|
moduleDef.tapModule(builder);
|
||||||
}
|
}
|
||||||
|
builder.overrideProvider(Mailer).useClass(MockMailer);
|
||||||
|
|
||||||
const module = await builder.compile();
|
const module = await builder.compile();
|
||||||
|
|
||||||
@@ -108,6 +113,8 @@ export async function createTestingModule(
|
|||||||
await module.close();
|
await module.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
testingModule.mails = module.get(Mailer, { strict: false }) as MockMailer;
|
||||||
|
|
||||||
const logger = new AFFiNELogger();
|
const logger = new AFFiNELogger();
|
||||||
// we got a lot smoking tests try to break nestjs
|
// we got a lot smoking tests try to break nestjs
|
||||||
// can't tolerate the noisy logs
|
// 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 { PrismaClient } from '@prisma/client';
|
||||||
import type { TestFn } from 'ava';
|
import type { TestFn } from 'ava';
|
||||||
import ava from 'ava';
|
import ava from 'ava';
|
||||||
|
|
||||||
import { MailService } from '../base/mailer';
|
|
||||||
import { AuthService } from '../core/auth/service';
|
import { AuthService } from '../core/auth/service';
|
||||||
import { Models } from '../models';
|
import { Models } from '../models';
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +19,6 @@ const test = ava as TestFn<{
|
|||||||
app: TestingApp;
|
app: TestingApp;
|
||||||
client: PrismaClient;
|
client: PrismaClient;
|
||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
mail: MailService;
|
|
||||||
models: Models;
|
models: Models;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -33,7 +27,6 @@ test.before(async t => {
|
|||||||
t.context.app = app;
|
t.context.app = app;
|
||||||
t.context.client = app.get(PrismaClient);
|
t.context.client = app.get(PrismaClient);
|
||||||
t.context.auth = app.get(AuthService);
|
t.context.auth = app.get(AuthService);
|
||||||
t.context.mail = app.get(MailService);
|
|
||||||
t.context.models = app.get(Models);
|
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 => {
|
test('should send email', async t => {
|
||||||
const { mail, app } = t.context;
|
const { app } = t.context;
|
||||||
if (mail.hasConfigured()) {
|
const u2 = await app.signupV1('u2@affine.pro');
|
||||||
const u2 = await app.signupV1('u2@affine.pro');
|
const u1 = await app.signupV1('u1@affine.pro');
|
||||||
await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
const workspace = await createWorkspace(app);
|
||||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
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(invitationMail.name, 'MemberInvitation');
|
||||||
t.is(
|
t.is(invitationMail.to, u2.email);
|
||||||
primitiveMailCount + 1,
|
|
||||||
afterInviteMailCount,
|
|
||||||
'failed to send invite email'
|
|
||||||
);
|
|
||||||
const inviteEmailContent = await getLatestMailMessage();
|
|
||||||
|
|
||||||
t.not(
|
app.switchUser(u2.id);
|
||||||
inviteEmailContent.To.find((item: any) => {
|
await acceptInviteById(app, workspace.id, invite, true);
|
||||||
return item.Mailbox === 'u2';
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
'invite email address was incorrectly sent'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.switchUser(u2.id);
|
const acceptedMail = app.mails.last('MemberAccepted');
|
||||||
const accept = await acceptInviteById(app, workspace.id, invite, true);
|
t.is(acceptedMail.to, u1.email);
|
||||||
t.true(accept, 'failed to accept invite');
|
t.is(acceptedMail.props.user.$$userId, u2.id);
|
||||||
|
|
||||||
const afterAcceptMailCount = await getCurrentMailMessageCount();
|
await leaveWorkspace(app, workspace.id, true);
|
||||||
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);
|
const leaveMail = app.mails.last('MemberLeave');
|
||||||
|
|
||||||
// TODO(@darkskygit): enable this after cluster event system is ready
|
t.is(leaveMail.to, u1.email);
|
||||||
// const afterLeaveMailCount = await getCurrentMailMessageCount();
|
t.is(leaveMail.props.user.$$userId, u2.id);
|
||||||
// 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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should support pagination for member', async t => {
|
test('should support pagination for member', async t => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { GqlModule } from './base/graphql';
|
|||||||
import { HelpersModule } from './base/helpers';
|
import { HelpersModule } from './base/helpers';
|
||||||
import { JobModule } from './base/job';
|
import { JobModule } from './base/job';
|
||||||
import { LoggerModule } from './base/logger';
|
import { LoggerModule } from './base/logger';
|
||||||
import { MailModule } from './base/mailer';
|
|
||||||
import { MetricsModule } from './base/metrics';
|
import { MetricsModule } from './base/metrics';
|
||||||
import { MutexModule } from './base/mutex';
|
import { MutexModule } from './base/mutex';
|
||||||
import { PrismaModule } from './base/prisma';
|
import { PrismaModule } from './base/prisma';
|
||||||
@@ -43,6 +42,7 @@ import { DocStorageModule } from './core/doc';
|
|||||||
import { DocRendererModule } from './core/doc-renderer';
|
import { DocRendererModule } from './core/doc-renderer';
|
||||||
import { DocServiceModule } from './core/doc-service';
|
import { DocServiceModule } from './core/doc-service';
|
||||||
import { FeatureModule } from './core/features';
|
import { FeatureModule } from './core/features';
|
||||||
|
import { MailModule } from './core/mail';
|
||||||
import { NotificationModule } from './core/notification';
|
import { NotificationModule } from './core/notification';
|
||||||
import { PermissionModule } from './core/permission';
|
import { PermissionModule } from './core/permission';
|
||||||
import { QuotaModule } from './core/quota';
|
import { QuotaModule } from './core/quota';
|
||||||
@@ -101,7 +101,6 @@ export const FunctionalityModules = [
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
MetricsModule,
|
MetricsModule,
|
||||||
RateLimiterModule,
|
RateLimiterModule,
|
||||||
MailModule,
|
|
||||||
StorageProviderModule,
|
StorageProviderModule,
|
||||||
HelpersModule,
|
HelpersModule,
|
||||||
ErrorModule,
|
ErrorModule,
|
||||||
@@ -219,7 +218,13 @@ export function buildAppModule() {
|
|||||||
.use(UserModule, AuthModule, PermissionModule)
|
.use(UserModule, AuthModule, PermissionModule)
|
||||||
|
|
||||||
// business modules
|
// business modules
|
||||||
.use(FeatureModule, QuotaModule, DocStorageModule, NotificationModule)
|
.use(
|
||||||
|
FeatureModule,
|
||||||
|
QuotaModule,
|
||||||
|
DocStorageModule,
|
||||||
|
NotificationModule,
|
||||||
|
MailModule
|
||||||
|
)
|
||||||
|
|
||||||
// sync server only
|
// sync server only
|
||||||
.useIf(config => config.flavor.sync, SyncModule)
|
.useIf(config => config.flavor.sync, SyncModule)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export * from './guard';
|
|||||||
export { CryptoHelper, URLHelper } from './helpers';
|
export { CryptoHelper, URLHelper } from './helpers';
|
||||||
export * from './job';
|
export * from './job';
|
||||||
export { AFFiNELogger } from './logger';
|
export { AFFiNELogger } from './logger';
|
||||||
export { MailService } from './mailer';
|
|
||||||
export { CallMetric, metrics } from './metrics';
|
export { CallMetric, metrics } from './metrics';
|
||||||
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
|
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
|
||||||
export * from './nestjs';
|
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(handler!.name, 'JobHandlers.handleJob');
|
||||||
t.is(typeof handler!.fn, 'function');
|
t.is(typeof handler!.fn, 'function');
|
||||||
|
|
||||||
const result = await handler!.fn({ name: 'test' });
|
|
||||||
|
|
||||||
t.is(result, 'test');
|
|
||||||
});
|
});
|
||||||
// #endregion
|
// #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[] {
|
export function getJobHandlerMetadata(target: any): JobName[] {
|
||||||
return sliceMetadata<JobName>(JOB_METADATA, target);
|
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 { QueueRedis } from '../../redis';
|
||||||
import { Runtime } from '../../runtime';
|
import { Runtime } from '../../runtime';
|
||||||
import { genRequestId } from '../../utils';
|
import { genRequestId } from '../../utils';
|
||||||
import { namespace, Queue, QUEUES } from './def';
|
import { JOB_SIGNAL, namespace, Queue, QUEUES } from './def';
|
||||||
import { JobHandlerScanner } from './scanner';
|
import { JobHandlerScanner } from './scanner';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -69,8 +69,12 @@ export class JobExecutor
|
|||||||
try {
|
try {
|
||||||
this.logger.debug(`Job started: ${signature}`);
|
this.logger.debug(`Job started: ${signature}`);
|
||||||
const result = await handler.fn(payload);
|
const result = await handler.fn(payload);
|
||||||
|
|
||||||
|
if (result === JOB_SIGNAL.RETRY) {
|
||||||
|
throw new Error(`Manually job retry`);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(`Job finished: ${signature}`);
|
this.logger.debug(`Job finished: ${signature}`);
|
||||||
return result;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`Job failed: ${signature}`, e);
|
this.logger.error(`Job failed: ${signature}`, e);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -88,7 +92,7 @@ export class JobExecutor
|
|||||||
const activeJobs = metrics.queue.counter('active_jobs');
|
const activeJobs = metrics.queue.counter('active_jobs');
|
||||||
activeJobs.add(1, { queue: ns });
|
activeJobs.add(1, { queue: ns });
|
||||||
try {
|
try {
|
||||||
return await fn();
|
await fn();
|
||||||
} finally {
|
} finally {
|
||||||
activeJobs.add(-1, { queue: ns });
|
activeJobs.add(-1, { queue: ns });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ export class JobModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { JobQueue };
|
export { JobQueue };
|
||||||
export { OnJob } from './def';
|
export { JOB_SIGNAL, OnJob } from './def';
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
import { ModuleScanner } from '../../nestjs';
|
import { ModuleScanner } from '../../nestjs';
|
||||||
import { getJobHandlerMetadata } from './def';
|
import { getJobHandlerMetadata, JOB_SIGNAL } from './def';
|
||||||
|
|
||||||
interface JobHandler {
|
interface JobHandler {
|
||||||
name: string;
|
name: string;
|
||||||
fn: (payload: any) => any;
|
fn: (payload: any) => Promise<JOB_SIGNAL | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@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,
|
CryptoHelper,
|
||||||
EarlyAccessRequired,
|
EarlyAccessRequired,
|
||||||
EmailTokenNotFound,
|
EmailTokenNotFound,
|
||||||
InternalServerError,
|
|
||||||
InvalidAuthState,
|
InvalidAuthState,
|
||||||
InvalidEmail,
|
InvalidEmail,
|
||||||
InvalidEmailToken,
|
InvalidEmailToken,
|
||||||
@@ -235,16 +234,7 @@ export class AuthController {
|
|||||||
this.logger.debug(`Magic link: ${magicLink}`);
|
this.logger.debug(`Magic link: ${magicLink}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.auth.sendSignInEmail(
|
await this.auth.sendSignInEmail(email, magicLink, otp, !user);
|
||||||
email,
|
|
||||||
magicLink,
|
|
||||||
otp,
|
|
||||||
!user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rejected.length) {
|
|
||||||
throw new InternalServerError('Failed to send sign-in email.');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(HttpStatus.OK).send({
|
res.status(HttpStatus.OK).send({
|
||||||
email: email,
|
email: email,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import './config';
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { FeatureModule } from '../features';
|
import { FeatureModule } from '../features';
|
||||||
|
import { MailModule } from '../mail';
|
||||||
import { QuotaModule } from '../quota';
|
import { QuotaModule } from '../quota';
|
||||||
import { UserModule } from '../user';
|
import { UserModule } from '../user';
|
||||||
import { AuthController } from './controller';
|
import { AuthController } from './controller';
|
||||||
@@ -12,7 +13,7 @@ import { AuthResolver } from './resolver';
|
|||||||
import { AuthService } from './service';
|
import { AuthService } from './service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [FeatureModule, UserModule, QuotaModule],
|
imports: [FeatureModule, UserModule, QuotaModule, MailModule],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
AuthResolver,
|
AuthResolver,
|
||||||
|
|||||||
@@ -161,9 +161,7 @@ export class AuthResolver {
|
|||||||
|
|
||||||
const url = this.url.link(callbackUrl, { userId: user.id, token });
|
const url = this.url.link(callbackUrl, { userId: user.id, token });
|
||||||
|
|
||||||
const res = await this.auth.sendChangePasswordEmail(user.email, url);
|
return await this.auth.sendChangePasswordEmail(user.email, url);
|
||||||
|
|
||||||
return !res.rejected.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -204,8 +202,7 @@ export class AuthResolver {
|
|||||||
|
|
||||||
const url = this.url.link(callbackUrl, { token });
|
const url = this.url.link(callbackUrl, { token });
|
||||||
|
|
||||||
const res = await this.auth.sendChangeEmail(user.email, url);
|
return await this.auth.sendChangeEmail(user.email, url);
|
||||||
return !res.rejected.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -248,9 +245,7 @@ export class AuthResolver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
|
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
|
||||||
const res = await this.auth.sendVerifyChangeEmail(email, url);
|
return await this.auth.sendVerifyChangeEmail(email, url);
|
||||||
|
|
||||||
return !res.rejected.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -265,8 +260,7 @@ export class AuthResolver {
|
|||||||
|
|
||||||
const url = this.url.link(callbackUrl, { token });
|
const url = this.url.link(callbackUrl, { token });
|
||||||
|
|
||||||
const res = await this.auth.sendVerifyEmail(user.email, url);
|
return await this.auth.sendVerifyEmail(user.email, url);
|
||||||
return !res.rejected.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
|||||||
import type { CookieOptions, Request, Response } from 'express';
|
import type { CookieOptions, Request, Response } from 'express';
|
||||||
import { assign, pick } from 'lodash-es';
|
import { assign, pick } from 'lodash-es';
|
||||||
|
|
||||||
import { Config, MailService, SignUpForbidden } from '../../base';
|
import { Config, SignUpForbidden } from '../../base';
|
||||||
import {
|
import {
|
||||||
Models,
|
Models,
|
||||||
type User,
|
type User,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type UserSession,
|
type UserSession,
|
||||||
} from '../../models';
|
} from '../../models';
|
||||||
import { FeatureService } from '../features';
|
import { FeatureService } from '../features';
|
||||||
|
import { Mailer } from '../mail/mailer';
|
||||||
import type { CurrentUser } from './session';
|
import type { CurrentUser } from './session';
|
||||||
|
|
||||||
export function sessionUser(
|
export function sessionUser(
|
||||||
@@ -47,7 +48,7 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly mailer: MailService,
|
private readonly mailer: Mailer,
|
||||||
private readonly feature: FeatureService
|
private readonly feature: FeatureService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -325,23 +326,57 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendChangePasswordEmail(email: string, callbackUrl: string) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
async sendNotificationChangeEmail(email: string) {
|
||||||
return this.mailer.sendNotificationChangeEmail(email, {
|
return await this.mailer.send({
|
||||||
|
name: 'EmailChanged',
|
||||||
to: email,
|
to: email,
|
||||||
|
props: {
|
||||||
|
to: email,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,8 +386,13 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
otp: string,
|
otp: string,
|
||||||
signUp: boolean
|
signUp: boolean
|
||||||
) {
|
) {
|
||||||
return signUp
|
return await this.mailer.send({
|
||||||
? await this.mailer.sendSignUpMail(email, { url: link, otp })
|
name: signUp ? 'SignUp' : 'SignIn',
|
||||||
: await this.mailer.sendSignInMail(email, { url: link, otp });
|
to: email,
|
||||||
|
props: {
|
||||||
|
url: link,
|
||||||
|
otp,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
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 {
|
interface AppConfig {
|
||||||
/**
|
/**
|
||||||
* Configurations for mail service used to post auth or bussiness mails.
|
* Configurations for mail service used to post auth or bussiness mails.
|
||||||
17
packages/backend/server/src/core/mail/index.ts
Normal file
17
packages/backend/server/src/core/mail/index.ts
Normal file
@@ -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 };
|
||||||
142
packages/backend/server/src/core/mail/job.ts
Normal file
142
packages/backend/server/src/core/mail/job.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/backend/server/src/core/mail/mailer.ts
Normal file
17
packages/backend/server/src/core/mail/mailer.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
packages/backend/server/src/core/mail/sender.ts
Normal file
89
packages/backend/server/src/core/mail/sender.ts
Normal file
@@ -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,
|
workspaceId,
|
||||||
}: Events['workspace.members.requestDeclined']) {
|
}: Events['workspace.members.requestDeclined']) {
|
||||||
const user = await this.models.user.getWorkspaceUser(userId);
|
const user = await this.models.user.getWorkspaceUser(userId);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// send decline mail
|
// send decline mail
|
||||||
await this.workspaceService.sendReviewDeclinedEmail(
|
await this.workspaceService.sendReviewDeclinedEmail(
|
||||||
user?.email,
|
user.email,
|
||||||
workspaceId
|
workspaceId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { DocStorageModule } from '../doc';
|
import { DocStorageModule } from '../doc';
|
||||||
import { DocRendererModule } from '../doc-renderer';
|
import { DocRendererModule } from '../doc-renderer';
|
||||||
import { FeatureModule } from '../features';
|
import { FeatureModule } from '../features';
|
||||||
|
import { MailModule } from '../mail';
|
||||||
|
import { NotificationModule } from '../notification';
|
||||||
import { PermissionModule } from '../permission';
|
import { PermissionModule } from '../permission';
|
||||||
import { QuotaModule } from '../quota';
|
import { QuotaModule } from '../quota';
|
||||||
import { StorageModule } from '../storage';
|
import { StorageModule } from '../storage';
|
||||||
@@ -28,6 +30,8 @@ import {
|
|||||||
StorageModule,
|
StorageModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
PermissionModule,
|
PermissionModule,
|
||||||
|
NotificationModule,
|
||||||
|
MailModule,
|
||||||
],
|
],
|
||||||
controllers: [WorkspacesController],
|
controllers: [WorkspacesController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { getStreamAsBuffer } from 'get-stream';
|
import { getStreamAsBuffer } from 'get-stream';
|
||||||
|
|
||||||
import {
|
import { Cache, NotFound, OnEvent, URLHelper } from '../../../base';
|
||||||
Cache,
|
|
||||||
Config,
|
|
||||||
MailService,
|
|
||||||
NotFound,
|
|
||||||
OnEvent,
|
|
||||||
URLHelper,
|
|
||||||
UserNotFound,
|
|
||||||
} from '../../../base';
|
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import { DocReader } from '../../doc';
|
import { DocReader } from '../../doc';
|
||||||
|
import { Mailer } from '../../mail';
|
||||||
import { WorkspaceRole } from '../../permission';
|
import { WorkspaceRole } from '../../permission';
|
||||||
import { WorkspaceBlobStorage } from '../../storage';
|
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';
|
'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 = {
|
export type InviteInfo = {
|
||||||
@@ -29,13 +22,12 @@ export class WorkspaceService {
|
|||||||
private readonly logger = new Logger(WorkspaceService.name);
|
private readonly logger = new Logger(WorkspaceService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly blobStorage: WorkspaceBlobStorage,
|
|
||||||
private readonly cache: Cache,
|
private readonly cache: Cache,
|
||||||
private readonly doc: DocReader,
|
|
||||||
private readonly mailer: MailService,
|
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly url: URLHelper,
|
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> {
|
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
|
||||||
@@ -62,7 +54,7 @@ export class WorkspaceService {
|
|||||||
async getWorkspaceInfo(workspaceId: string) {
|
async getWorkspaceInfo(workspaceId: string) {
|
||||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||||
|
|
||||||
let avatar = defaultWorkspaceAvatar;
|
let avatar = DEFAULT_WORKSPACE_AVATAR;
|
||||||
if (workspaceContent?.avatarKey) {
|
if (workspaceContent?.avatarKey) {
|
||||||
const avatarBlob = await this.blobStorage.get(
|
const avatarBlob = await this.blobStorage.get(
|
||||||
workspaceId,
|
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) {
|
async sendAcceptedEmail(inviteId: string) {
|
||||||
const { workspaceId, inviterUserId, inviteeUserId } =
|
const { workspaceId, inviterUserId, inviteeUserId } =
|
||||||
await this.getInviteInfo(inviteId);
|
await this.getInviteInfo(inviteId);
|
||||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
|
||||||
const invitee = inviteeUserId
|
|
||||||
? await this.models.user.getWorkspaceUser(inviteeUserId)
|
|
||||||
: null;
|
|
||||||
const inviter = inviterUserId
|
const inviter = inviterUserId
|
||||||
? await this.models.user.getWorkspaceUser(inviterUserId)
|
? await this.models.user.getWorkspaceUser(inviterUserId)
|
||||||
: await this.models.workspaceUser.getOwner(workspaceId);
|
: await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
|
|
||||||
if (!inviter || !invitee) {
|
if (!inviter || !inviteeUserId) {
|
||||||
this.logger.error(
|
this.logger.warn(
|
||||||
`Inviter or invitee user not found for inviteId: ${inviteId}`
|
`Inviter or invitee user not found for inviteId: ${inviteId}`
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.mailer.sendMemberAcceptedEmail(inviter.email, {
|
return await this.mailer.send({
|
||||||
user: invitee,
|
name: 'MemberAccepted',
|
||||||
workspace,
|
to: inviter.email,
|
||||||
|
props: {
|
||||||
|
user: {
|
||||||
|
$$userId: inviteeUserId,
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: workspaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInviteEmail(inviteId: string) {
|
async sendInviteEmail({
|
||||||
const target = await this.getInviteeEmailTarget(inviteId);
|
workspaceId,
|
||||||
|
inviteeEmail,
|
||||||
if (!target) {
|
inviterUserId,
|
||||||
return;
|
inviteId,
|
||||||
}
|
}: {
|
||||||
|
inviterUserId: string;
|
||||||
const owner = await this.models.workspaceUser.getOwner(target.workspace.id);
|
inviteeEmail: string;
|
||||||
|
inviteId: string;
|
||||||
const inviteUrl = this.url.link(`/invite/${inviteId}`);
|
workspaceId: string;
|
||||||
if (this.config.node.dev) {
|
}) {
|
||||||
// make it easier to test in dev mode
|
return await this.mailer.send({
|
||||||
this.logger.debug(`Invite link: ${inviteUrl}`);
|
name: 'MemberInvitation',
|
||||||
}
|
to: inviteeEmail,
|
||||||
await this.mailer.sendMemberInviteMail(target.email, {
|
props: {
|
||||||
workspace: target.workspace,
|
workspace: {
|
||||||
user: owner,
|
$$workspaceId: workspaceId,
|
||||||
url: inviteUrl,
|
},
|
||||||
|
user: {
|
||||||
|
$$userId: inviterUserId,
|
||||||
|
},
|
||||||
|
url: this.url.link(`/invite/${inviteId}`),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,23 +134,37 @@ export class WorkspaceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
|
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
|
||||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
|
||||||
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
const admins = await this.models.workspaceUser.getAdmins(workspaceId);
|
const admins = await this.models.workspaceUser.getAdmins(workspaceId);
|
||||||
|
|
||||||
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
|
const link = this.url.link(`/workspace/${workspaceId}`);
|
||||||
workspace,
|
await this.mailer.send({
|
||||||
isOwner: true,
|
name: 'TeamWorkspaceUpgraded',
|
||||||
url: this.url.link(`/workspace/${workspaceId}`),
|
to: owner.email,
|
||||||
|
props: {
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: workspaceId,
|
||||||
|
},
|
||||||
|
isOwner: true,
|
||||||
|
url: link,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const user of admins) {
|
await Promise.allSettled(
|
||||||
await this.mailer.sendTeamWorkspaceUpgradedEmail(user.email, {
|
admins.map(async user => {
|
||||||
workspace,
|
await this.mailer.send({
|
||||||
isOwner: false,
|
name: 'TeamWorkspaceUpgraded',
|
||||||
url: this.url.link(`/workspace/${workspaceId}`),
|
to: user.email,
|
||||||
});
|
props: {
|
||||||
}
|
workspace: {
|
||||||
|
$$workspaceId: workspaceId,
|
||||||
|
},
|
||||||
|
isOwner: false,
|
||||||
|
url: link,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendReviewRequestedEmail(inviteId: string) {
|
async sendReviewRequestedEmail(inviteId: string) {
|
||||||
@@ -180,46 +174,63 @@ export class WorkspaceService {
|
|||||||
return;
|
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 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 Promise.allSettled(
|
||||||
await this.mailer.sendLinkInvitationReviewRequestMail(user.email, {
|
[owner, ...admins].map(async receiver => {
|
||||||
workspace,
|
await this.mailer.send({
|
||||||
user: invitee,
|
name: 'LinkInvitationReviewRequest',
|
||||||
url: this.url.link(`/workspace/${workspace.id}`),
|
to: receiver.email,
|
||||||
});
|
props: {
|
||||||
}
|
user: {
|
||||||
|
$$userId: inviteeUserId,
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: workspaceId,
|
||||||
|
},
|
||||||
|
url: this.url.link(`/workspace/${workspaceId}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendReviewApproveEmail(inviteId: string) {
|
async sendReviewApproveEmail(inviteId: string) {
|
||||||
const target = await this.getInviteeEmailTarget(inviteId);
|
const invitation = await this.models.workspaceUser.getById(inviteId);
|
||||||
if (!target) return;
|
if (!invitation) {
|
||||||
|
this.logger.warn(`Invitation not found for inviteId: ${inviteId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.mailer.sendLinkInvitationApproveMail(target.email, {
|
const user = await this.models.user.getWorkspaceUser(invitation.userId);
|
||||||
workspace: target.workspace,
|
|
||||||
url: this.url.link(`/workspace/${target.workspace.id}`),
|
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(
|
async sendReviewDeclinedEmail(email: string, workspaceId: string) {
|
||||||
email: string | undefined,
|
await this.mailer.send({
|
||||||
workspaceId: string
|
name: 'LinkInvitationDecline',
|
||||||
) {
|
to: email,
|
||||||
if (!email) return;
|
props: {
|
||||||
|
workspace: {
|
||||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
$$workspaceId: workspaceId,
|
||||||
await this.mailer.sendLinkInvitationDeclineMail(email, {
|
},
|
||||||
workspace,
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,43 +239,75 @@ export class WorkspaceService {
|
|||||||
ws: { id: string; role: WorkspaceRole }
|
ws: { id: string; role: WorkspaceRole }
|
||||||
) {
|
) {
|
||||||
const user = await this.models.user.getWorkspaceUser(userId);
|
const user = await this.models.user.getWorkspaceUser(userId);
|
||||||
if (!user) throw new UserNotFound();
|
if (!user) {
|
||||||
|
this.logger.warn(
|
||||||
const workspace = await this.getWorkspaceInfo(ws.id);
|
`User not found for seeding role changed email: ${userId}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ws.role === WorkspaceRole.Admin) {
|
if (ws.role === WorkspaceRole.Admin) {
|
||||||
await this.mailer.sendTeamBecomeAdminMail(user.email, {
|
await this.mailer.send({
|
||||||
workspace,
|
name: 'TeamBecomeAdmin',
|
||||||
url: this.url.link(`/workspace/${workspace.id}`),
|
to: user.email,
|
||||||
|
props: {
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: ws.id,
|
||||||
|
},
|
||||||
|
url: this.url.link(`/workspace/${ws.id}`),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.mailer.sendTeamBecomeCollaboratorMail(user.email, {
|
await this.mailer.send({
|
||||||
workspace,
|
name: 'TeamBecomeCollaborator',
|
||||||
url: this.url.link(`/workspace/${workspace.id}`),
|
to: user.email,
|
||||||
|
props: {
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: ws.id,
|
||||||
|
},
|
||||||
|
url: this.url.link(`/workspace/${ws.id}`),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOwnershipTransferredEmail(email: string, ws: { id: string }) {
|
async sendOwnershipTransferredEmail(email: string, ws: { id: string }) {
|
||||||
const workspace = await this.getWorkspaceInfo(ws.id);
|
await this.mailer.send({
|
||||||
await this.mailer.sendOwnershipTransferredMail(email, { workspace });
|
name: 'OwnershipTransferred',
|
||||||
|
to: email,
|
||||||
|
props: {
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: ws.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOwnershipReceivedEmail(email: string, ws: { id: string }) {
|
async sendOwnershipReceivedEmail(email: string, ws: { id: string }) {
|
||||||
const workspace = await this.getWorkspaceInfo(ws.id);
|
await this.mailer.send({
|
||||||
await this.mailer.sendOwnershipReceivedMail(email, { workspace });
|
name: 'OwnershipReceived',
|
||||||
|
to: email,
|
||||||
|
props: {
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: ws.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('workspace.members.leave')
|
async sendLeaveEmail(workspaceId: string, userId: string) {
|
||||||
async onMemberLeave({
|
|
||||||
user,
|
|
||||||
workspaceId,
|
|
||||||
}: Events['workspace.members.leave']) {
|
|
||||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
|
||||||
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||||
await this.mailer.sendMemberLeaveEmail(owner.email, {
|
await this.mailer.send({
|
||||||
workspace,
|
name: 'MemberLeave',
|
||||||
user,
|
to: owner.email,
|
||||||
|
props: {
|
||||||
|
workspace: {
|
||||||
|
$$workspaceId: workspaceId,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
$$userId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,9 +317,21 @@ export class WorkspaceService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
}: Events['workspace.members.removed']) {
|
}: Events['workspace.members.removed']) {
|
||||||
const user = await this.models.user.get(userId);
|
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.send({
|
||||||
await this.mailer.sendMemberRemovedMail(user.email, { workspace });
|
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
|
// after user click the invite link, we can check again and reject if charge failed
|
||||||
if (sendInviteMail) {
|
if (sendInviteMail) {
|
||||||
try {
|
try {
|
||||||
await this.workspaceService.sendInviteEmail(ret.inviteId);
|
await this.workspaceService.sendInviteEmail({
|
||||||
|
workspaceId,
|
||||||
|
inviteeEmail: target.email,
|
||||||
|
inviterUserId: user.id,
|
||||||
|
inviteId: role.id,
|
||||||
|
});
|
||||||
ret.sentSuccess = true;
|
ret.sentSuccess = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
|||||||
@@ -493,7 +493,12 @@ export class WorkspaceResolver {
|
|||||||
|
|
||||||
if (sendInviteMail) {
|
if (sendInviteMail) {
|
||||||
try {
|
try {
|
||||||
await this.workspaceService.sendInviteEmail(role.id);
|
await this.workspaceService.sendInviteEmail({
|
||||||
|
workspaceId,
|
||||||
|
inviteeEmail: email,
|
||||||
|
inviterUserId: user.id,
|
||||||
|
inviteId: role.id,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await this.models.workspaceUser.delete(workspaceId, user.id);
|
await this.models.workspaceUser.delete(workspaceId, user.id);
|
||||||
|
|
||||||
@@ -577,7 +582,7 @@ export class WorkspaceResolver {
|
|||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (role.status === WorkspaceMemberStatus.Accepted) {
|
||||||
this.event.emit('workspace.members.removed', {
|
this.event.emit('workspace.members.removed', {
|
||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -688,13 +693,7 @@ export class WorkspaceResolver {
|
|||||||
await this.models.workspaceUser.delete(workspaceId, user.id);
|
await this.models.workspaceUser.delete(workspaceId, user.id);
|
||||||
|
|
||||||
if (sendLeaveMail) {
|
if (sendLeaveMail) {
|
||||||
this.event.emit('workspace.members.leave', {
|
await this.workspaceService.sendLeaveEmail(workspaceId, user.id);
|
||||||
workspaceId,
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -63,121 +63,105 @@ function make<T extends React.ComponentType<any>>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================ User ================
|
export const Renderers = {
|
||||||
export const renderSignInMail = make(SignIn, 'Sign in to AFFiNE');
|
//#region User
|
||||||
export const renderSignUpMail = make(
|
SignIn: make(SignIn, 'Sign in to AFFiNE'),
|
||||||
SignUp,
|
SignUp: make(SignUp, 'Your AFFiNE account is waiting for you!'),
|
||||||
'Your AFFiNE account is waiting for you!'
|
SetPassword: make(SetPassword, 'Set your AFFiNE password'),
|
||||||
);
|
ChangePassword: make(ChangePassword, 'Modify your AFFiNE password'),
|
||||||
export const renderSetPasswordMail = make(
|
VerifyEmail: make(VerifyEmail, 'Verify your email address'),
|
||||||
SetPassword,
|
ChangeEmail: make(ChangeEmail, 'Change your email address'),
|
||||||
'Set your AFFiNE password'
|
VerifyChangeEmail: make(VerifyChangeEmail, 'Verify your new email address'),
|
||||||
);
|
EmailChanged: make(ChangeEmailNotification, 'Account email address changed'),
|
||||||
export const renderChangePasswordMail = make(
|
//#endregion
|
||||||
ChangePassword,
|
|
||||||
'Modify your AFFiNE password'
|
|
||||||
);
|
|
||||||
export const renderVerifyEmailMail = make(
|
|
||||||
VerifyEmail,
|
|
||||||
'Verify your email address'
|
|
||||||
);
|
|
||||||
export const renderChangeEmailMail = make(
|
|
||||||
ChangeEmail,
|
|
||||||
'Change your email address'
|
|
||||||
);
|
|
||||||
export const renderVerifyChangeEmailMail = make(
|
|
||||||
VerifyChangeEmail,
|
|
||||||
'Verify your new email address'
|
|
||||||
);
|
|
||||||
export const renderChangeEmailNotificationMail = make(
|
|
||||||
ChangeEmailNotification,
|
|
||||||
'Account email address changed'
|
|
||||||
);
|
|
||||||
|
|
||||||
// ================ Workspace ================
|
//#region Workspace
|
||||||
export const renderMemberInvitationMail = make(
|
MemberInvitation: make(
|
||||||
Invitation,
|
Invitation,
|
||||||
props => `${props.user.email} invited you to join ${props.workspace.name}`
|
props => `${props.user.email} invited you to join ${props.workspace.name}`
|
||||||
);
|
),
|
||||||
export const renderMemberAcceptedMail = make(
|
MemberAccepted: make(
|
||||||
InvitationAccepted,
|
InvitationAccepted,
|
||||||
props => `${props.user.email} accepted your invitation`
|
props => `${props.user.email} accepted your invitation`
|
||||||
);
|
),
|
||||||
export const renderMemberLeaveMail = make(
|
MemberLeave: make(
|
||||||
MemberLeave,
|
MemberLeave,
|
||||||
props => `${props.user.email} left ${props.workspace.name}`
|
props => `${props.user.email} left ${props.workspace.name}`
|
||||||
);
|
),
|
||||||
export const renderLinkInvitationReviewRequestMail = make(
|
LinkInvitationReviewRequest: make(
|
||||||
LinkInvitationReviewRequest,
|
LinkInvitationReviewRequest,
|
||||||
props => `New request to join ${props.workspace.name}`
|
props => `New request to join ${props.workspace.name}`
|
||||||
);
|
),
|
||||||
export const renderLinkInvitationApproveMail = make(
|
LinkInvitationApprove: make(
|
||||||
LinkInvitationApproved,
|
LinkInvitationApproved,
|
||||||
props => `Your request to join ${props.workspace.name} has been approved`
|
props => `Your request to join ${props.workspace.name} has been approved`
|
||||||
);
|
),
|
||||||
export const renderLinkInvitationDeclineMail = make(
|
LinkInvitationDecline: make(
|
||||||
LinkInvitationReviewDeclined,
|
LinkInvitationReviewDeclined,
|
||||||
props => `Your request to join ${props.workspace.name} was declined`
|
props => `Your request to join ${props.workspace.name} was declined`
|
||||||
);
|
),
|
||||||
export const renderMemberRemovedMail = make(
|
MemberRemoved: make(
|
||||||
MemberRemoved,
|
MemberRemoved,
|
||||||
props => `You have been removed from ${props.workspace.name}`
|
props => `You have been removed from ${props.workspace.name}`
|
||||||
);
|
),
|
||||||
export const renderOwnershipTransferredMail = make(
|
OwnershipTransferred: make(
|
||||||
OwnershipTransferred,
|
OwnershipTransferred,
|
||||||
props => `Your ownership of ${props.workspace.name} has been transferred`
|
props => `Your ownership of ${props.workspace.name} has been transferred`
|
||||||
);
|
),
|
||||||
export const renderOwnershipReceivedMail = make(
|
OwnershipReceived: make(
|
||||||
OwnershipReceived,
|
OwnershipReceived,
|
||||||
props => `You are now the owner of ${props.workspace.name}`
|
props => `You are now the owner of ${props.workspace.name}`
|
||||||
);
|
),
|
||||||
|
//#endregion
|
||||||
|
|
||||||
// ================ Team ================
|
//#region Team
|
||||||
export const renderTeamWorkspaceUpgradedMail = make(
|
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
|
||||||
TeamWorkspaceUpgraded,
|
|
||||||
props =>
|
|
||||||
props.isOwner
|
props.isOwner
|
||||||
? 'Your workspace has been upgraded to team workspace! 🎉'
|
? 'Your workspace has been upgraded to team workspace! 🎉'
|
||||||
: `${props.workspace.name} 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(
|
//#region License
|
||||||
TeamBecomeAdmin,
|
TeamLicense: make(
|
||||||
props => `You are now an admin of ${props.workspace.name}`
|
TeamLicense,
|
||||||
);
|
'Your AFFiNE Self-Hosted Team Workspace license is ready'
|
||||||
|
),
|
||||||
|
//#endregion
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const renderTeamBecomeCollaboratorMail = make(
|
export type MailName = keyof typeof Renderers;
|
||||||
TeamBecomeCollaborator,
|
export type MailProps<T extends MailName> = Parameters<
|
||||||
props => `Your role has been changed in ${props.workspace.name}`
|
(typeof Renderers)[T]
|
||||||
);
|
>[0];
|
||||||
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'
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import './config';
|
|||||||
|
|
||||||
import { ServerFeature } from '../../core/config';
|
import { ServerFeature } from '../../core/config';
|
||||||
import { FeatureModule } from '../../core/features';
|
import { FeatureModule } from '../../core/features';
|
||||||
|
import { MailModule } from '../../core/mail';
|
||||||
import { PermissionModule } from '../../core/permission';
|
import { PermissionModule } from '../../core/permission';
|
||||||
import { QuotaModule } from '../../core/quota';
|
import { QuotaModule } from '../../core/quota';
|
||||||
import { UserModule } from '../../core/user';
|
import { UserModule } from '../../core/user';
|
||||||
@@ -33,6 +34,7 @@ import { StripeWebhook } from './webhook';
|
|||||||
UserModule,
|
UserModule,
|
||||||
PermissionModule,
|
PermissionModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
MailModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
StripeProvider,
|
StripeProvider,
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import { pick } from 'lodash-es';
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
|
||||||
MailService,
|
import { Mailer } from '../../../core/mail';
|
||||||
SubscriptionPlanNotFound,
|
|
||||||
URLHelper,
|
|
||||||
} from '../../../base';
|
|
||||||
import {
|
import {
|
||||||
KnownStripeInvoice,
|
KnownStripeInvoice,
|
||||||
KnownStripePrice,
|
KnownStripePrice,
|
||||||
@@ -49,7 +46,7 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
stripe: Stripe,
|
stripe: Stripe,
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
private readonly url: URLHelper,
|
private readonly url: URLHelper,
|
||||||
private readonly mailer: MailService
|
private readonly mailer: Mailer
|
||||||
) {
|
) {
|
||||||
super(stripe, db);
|
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;
|
return subscription;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
},
|
},
|
||||||
"include": ["./src"],
|
"include": ["./src"],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../../tests/kit" },
|
|
||||||
{ "path": "../../../tools/cli" },
|
{ "path": "../../../tools/cli" },
|
||||||
{ "path": "../../../tools/utils" },
|
{ "path": "../../../tools/utils" },
|
||||||
{ "path": "../native" }
|
{ "path": "../native" }
|
||||||
|
|||||||
@@ -654,7 +654,6 @@ export const PackageList = [
|
|||||||
location: 'packages/backend/server',
|
location: 'packages/backend/server',
|
||||||
name: '@affine/server',
|
name: '@affine/server',
|
||||||
workspaceDependencies: [
|
workspaceDependencies: [
|
||||||
'tests/kit',
|
|
||||||
'tools/cli',
|
'tools/cli',
|
||||||
'tools/utils',
|
'tools/utils',
|
||||||
'packages/backend/native',
|
'packages/backend/native',
|
||||||
|
|||||||
@@ -842,7 +842,6 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@affine/server@workspace:packages/backend/server"
|
resolution: "@affine/server@workspace:packages/backend/server"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@affine-test/kit": "workspace:*"
|
|
||||||
"@affine-tools/cli": "workspace:*"
|
"@affine-tools/cli": "workspace:*"
|
||||||
"@affine-tools/utils": "workspace:*"
|
"@affine-tools/utils": "workspace:*"
|
||||||
"@affine/server-native": "workspace:*"
|
"@affine/server-native": "workspace:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user