mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 14:27:02 +08:00
refactor(server): mail service (#10934)
This commit is contained in:
@@ -20,7 +20,6 @@ import {
|
||||
CryptoHelper,
|
||||
EarlyAccessRequired,
|
||||
EmailTokenNotFound,
|
||||
InternalServerError,
|
||||
InvalidAuthState,
|
||||
InvalidEmail,
|
||||
InvalidEmailToken,
|
||||
@@ -235,16 +234,7 @@ export class AuthController {
|
||||
this.logger.debug(`Magic link: ${magicLink}`);
|
||||
}
|
||||
|
||||
const result = await this.auth.sendSignInEmail(
|
||||
email,
|
||||
magicLink,
|
||||
otp,
|
||||
!user
|
||||
);
|
||||
|
||||
if (result.rejected.length) {
|
||||
throw new InternalServerError('Failed to send sign-in email.');
|
||||
}
|
||||
await this.auth.sendSignInEmail(email, magicLink, otp, !user);
|
||||
|
||||
res.status(HttpStatus.OK).send({
|
||||
email: email,
|
||||
|
||||
@@ -3,6 +3,7 @@ import './config';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../features';
|
||||
import { MailModule } from '../mail';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { UserModule } from '../user';
|
||||
import { AuthController } from './controller';
|
||||
@@ -12,7 +13,7 @@ import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [FeatureModule, UserModule, QuotaModule],
|
||||
imports: [FeatureModule, UserModule, QuotaModule, MailModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthResolver,
|
||||
|
||||
@@ -161,9 +161,7 @@ export class AuthResolver {
|
||||
|
||||
const url = this.url.link(callbackUrl, { userId: user.id, token });
|
||||
|
||||
const res = await this.auth.sendChangePasswordEmail(user.email, url);
|
||||
|
||||
return !res.rejected.length;
|
||||
return await this.auth.sendChangePasswordEmail(user.email, url);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -204,8 +202,7 @@ export class AuthResolver {
|
||||
|
||||
const url = this.url.link(callbackUrl, { token });
|
||||
|
||||
const res = await this.auth.sendChangeEmail(user.email, url);
|
||||
return !res.rejected.length;
|
||||
return await this.auth.sendChangeEmail(user.email, url);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -248,9 +245,7 @@ export class AuthResolver {
|
||||
);
|
||||
|
||||
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
|
||||
const res = await this.auth.sendVerifyChangeEmail(email, url);
|
||||
|
||||
return !res.rejected.length;
|
||||
return await this.auth.sendVerifyChangeEmail(email, url);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -265,8 +260,7 @@ export class AuthResolver {
|
||||
|
||||
const url = this.url.link(callbackUrl, { token });
|
||||
|
||||
const res = await this.auth.sendVerifyEmail(user.email, url);
|
||||
return !res.rejected.length;
|
||||
return await this.auth.sendVerifyEmail(user.email, url);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, MailService, SignUpForbidden } from '../../base';
|
||||
import { Config, SignUpForbidden } from '../../base';
|
||||
import {
|
||||
Models,
|
||||
type User,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type UserSession,
|
||||
} from '../../models';
|
||||
import { FeatureService } from '../features';
|
||||
import { Mailer } from '../mail/mailer';
|
||||
import type { CurrentUser } from './session';
|
||||
|
||||
export function sessionUser(
|
||||
@@ -47,7 +48,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly models: Models,
|
||||
private readonly mailer: MailService,
|
||||
private readonly mailer: Mailer,
|
||||
private readonly feature: FeatureService
|
||||
) {}
|
||||
|
||||
@@ -325,23 +326,57 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async sendChangePasswordEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendChangePasswordMail(email, { url: callbackUrl });
|
||||
return await this.mailer.send({
|
||||
name: 'ChangePassword',
|
||||
to: email,
|
||||
props: {
|
||||
url: callbackUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
async sendSetPasswordEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendSetPasswordMail(email, { url: callbackUrl });
|
||||
return await this.mailer.send({
|
||||
name: 'SetPassword',
|
||||
to: email,
|
||||
props: {
|
||||
url: callbackUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
async sendChangeEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendChangeEmailMail(email, { url: callbackUrl });
|
||||
return await this.mailer.send({
|
||||
name: 'ChangeEmail',
|
||||
to: email,
|
||||
props: {
|
||||
url: callbackUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendVerifyChangeEmail(email, { url: callbackUrl });
|
||||
return await this.mailer.send({
|
||||
name: 'VerifyChangeEmail',
|
||||
to: email,
|
||||
props: {
|
||||
url: callbackUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
async sendVerifyEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendVerifyEmail(email, { url: callbackUrl });
|
||||
return await this.mailer.send({
|
||||
name: 'VerifyEmail',
|
||||
to: email,
|
||||
props: {
|
||||
url: callbackUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
async sendNotificationChangeEmail(email: string) {
|
||||
return this.mailer.sendNotificationChangeEmail(email, {
|
||||
return await this.mailer.send({
|
||||
name: 'EmailChanged',
|
||||
to: email,
|
||||
props: {
|
||||
to: email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,8 +386,13 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
otp: string,
|
||||
signUp: boolean
|
||||
) {
|
||||
return signUp
|
||||
? await this.mailer.sendSignUpMail(email, { url: link, otp })
|
||||
: await this.mailer.sendSignInMail(email, { url: link, otp });
|
||||
return await this.mailer.send({
|
||||
name: signUp ? 'SignUp' : 'SignIn',
|
||||
to: email,
|
||||
props: {
|
||||
url: link,
|
||||
otp,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/backend/server/src/core/mail/config.ts
Normal file
16
packages/backend/server/src/core/mail/config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../../base/config';
|
||||
|
||||
declare module '../../base/config' {
|
||||
interface AppConfig {
|
||||
/**
|
||||
* Configurations for mail service used to post auth or bussiness mails.
|
||||
*
|
||||
* @see https://nodemailer.com/smtp/
|
||||
*/
|
||||
mailer: ModuleConfig<SMTPTransport.Options>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('mailer', {});
|
||||
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,
|
||||
}: Events['workspace.members.requestDeclined']) {
|
||||
const user = await this.models.user.getWorkspaceUser(userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
// send decline mail
|
||||
await this.workspaceService.sendReviewDeclinedEmail(
|
||||
user?.email,
|
||||
user.email,
|
||||
workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Module } from '@nestjs/common';
|
||||
import { DocStorageModule } from '../doc';
|
||||
import { DocRendererModule } from '../doc-renderer';
|
||||
import { FeatureModule } from '../features';
|
||||
import { MailModule } from '../mail';
|
||||
import { NotificationModule } from '../notification';
|
||||
import { PermissionModule } from '../permission';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
StorageModule,
|
||||
UserModule,
|
||||
PermissionModule,
|
||||
NotificationModule,
|
||||
MailModule,
|
||||
],
|
||||
controllers: [WorkspacesController],
|
||||
providers: [
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import {
|
||||
Cache,
|
||||
Config,
|
||||
MailService,
|
||||
NotFound,
|
||||
OnEvent,
|
||||
URLHelper,
|
||||
UserNotFound,
|
||||
} from '../../../base';
|
||||
import { Cache, NotFound, OnEvent, URLHelper } from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { DocReader } from '../../doc';
|
||||
import { Mailer } from '../../mail';
|
||||
import { WorkspaceRole } from '../../permission';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
|
||||
export const defaultWorkspaceAvatar =
|
||||
export const DEFAULT_WORKSPACE_AVATAR =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
|
||||
|
||||
export type InviteInfo = {
|
||||
@@ -29,13 +22,12 @@ export class WorkspaceService {
|
||||
private readonly logger = new Logger(WorkspaceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly blobStorage: WorkspaceBlobStorage,
|
||||
private readonly cache: Cache,
|
||||
private readonly doc: DocReader,
|
||||
private readonly mailer: MailService,
|
||||
private readonly models: Models,
|
||||
private readonly url: URLHelper,
|
||||
private readonly config: Config
|
||||
private readonly doc: DocReader,
|
||||
private readonly blobStorage: WorkspaceBlobStorage,
|
||||
private readonly mailer: Mailer
|
||||
) {}
|
||||
|
||||
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
|
||||
@@ -62,7 +54,7 @@ export class WorkspaceService {
|
||||
async getWorkspaceInfo(workspaceId: string) {
|
||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||
|
||||
let avatar = defaultWorkspaceAvatar;
|
||||
let avatar = DEFAULT_WORKSPACE_AVATAR;
|
||||
if (workspaceContent?.avatarKey) {
|
||||
const avatarBlob = await this.blobStorage.get(
|
||||
workspaceId,
|
||||
@@ -81,70 +73,58 @@ export class WorkspaceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getInviteeEmailTarget(inviteId: string) {
|
||||
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
|
||||
if (!inviteeUserId) {
|
||||
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
|
||||
return;
|
||||
}
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
const invitee = await this.models.user.getWorkspaceUser(inviteeUserId);
|
||||
if (!invitee) {
|
||||
this.logger.error(
|
||||
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
email: invitee.email,
|
||||
workspace,
|
||||
};
|
||||
}
|
||||
|
||||
async sendAcceptedEmail(inviteId: string) {
|
||||
const { workspaceId, inviterUserId, inviteeUserId } =
|
||||
await this.getInviteInfo(inviteId);
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
const invitee = inviteeUserId
|
||||
? await this.models.user.getWorkspaceUser(inviteeUserId)
|
||||
: null;
|
||||
|
||||
const inviter = inviterUserId
|
||||
? await this.models.user.getWorkspaceUser(inviterUserId)
|
||||
: await this.models.workspaceUser.getOwner(workspaceId);
|
||||
|
||||
if (!inviter || !invitee) {
|
||||
this.logger.error(
|
||||
if (!inviter || !inviteeUserId) {
|
||||
this.logger.warn(
|
||||
`Inviter or invitee user not found for inviteId: ${inviteId}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.mailer.sendMemberAcceptedEmail(inviter.email, {
|
||||
user: invitee,
|
||||
workspace,
|
||||
return await this.mailer.send({
|
||||
name: 'MemberAccepted',
|
||||
to: inviter.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: inviteeUserId,
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async sendInviteEmail(inviteId: string) {
|
||||
const target = await this.getInviteeEmailTarget(inviteId);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = await this.models.workspaceUser.getOwner(target.workspace.id);
|
||||
|
||||
const inviteUrl = this.url.link(`/invite/${inviteId}`);
|
||||
if (this.config.node.dev) {
|
||||
// make it easier to test in dev mode
|
||||
this.logger.debug(`Invite link: ${inviteUrl}`);
|
||||
}
|
||||
await this.mailer.sendMemberInviteMail(target.email, {
|
||||
workspace: target.workspace,
|
||||
user: owner,
|
||||
url: inviteUrl,
|
||||
async sendInviteEmail({
|
||||
workspaceId,
|
||||
inviteeEmail,
|
||||
inviterUserId,
|
||||
inviteId,
|
||||
}: {
|
||||
inviterUserId: string;
|
||||
inviteeEmail: string;
|
||||
inviteId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return await this.mailer.send({
|
||||
name: 'MemberInvitation',
|
||||
to: inviteeEmail,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
user: {
|
||||
$$userId: inviterUserId,
|
||||
},
|
||||
url: this.url.link(`/invite/${inviteId}`),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,23 +134,37 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||
const admins = await this.models.workspaceUser.getAdmins(workspaceId);
|
||||
|
||||
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
|
||||
workspace,
|
||||
isOwner: true,
|
||||
url: this.url.link(`/workspace/${workspaceId}`),
|
||||
const link = this.url.link(`/workspace/${workspaceId}`);
|
||||
await this.mailer.send({
|
||||
name: 'TeamWorkspaceUpgraded',
|
||||
to: owner.email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
isOwner: true,
|
||||
url: link,
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of admins) {
|
||||
await this.mailer.sendTeamWorkspaceUpgradedEmail(user.email, {
|
||||
workspace,
|
||||
isOwner: false,
|
||||
url: this.url.link(`/workspace/${workspaceId}`),
|
||||
});
|
||||
}
|
||||
await Promise.allSettled(
|
||||
admins.map(async user => {
|
||||
await this.mailer.send({
|
||||
name: 'TeamWorkspaceUpgraded',
|
||||
to: user.email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
isOwner: false,
|
||||
url: link,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async sendReviewRequestedEmail(inviteId: string) {
|
||||
@@ -180,46 +174,63 @@ export class WorkspaceService {
|
||||
return;
|
||||
}
|
||||
|
||||
const invitee = await this.models.user.getWorkspaceUser(inviteeUserId);
|
||||
if (!invitee) {
|
||||
this.logger.error(
|
||||
`Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||
const admin = await this.models.workspaceUser.getAdmins(workspaceId);
|
||||
const admins = await this.models.workspaceUser.getAdmins(workspaceId);
|
||||
|
||||
for (const user of [owner, ...admin]) {
|
||||
await this.mailer.sendLinkInvitationReviewRequestMail(user.email, {
|
||||
workspace,
|
||||
user: invitee,
|
||||
url: this.url.link(`/workspace/${workspace.id}`),
|
||||
});
|
||||
}
|
||||
await Promise.allSettled(
|
||||
[owner, ...admins].map(async receiver => {
|
||||
await this.mailer.send({
|
||||
name: 'LinkInvitationReviewRequest',
|
||||
to: receiver.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: inviteeUserId,
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
url: this.url.link(`/workspace/${workspaceId}`),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async sendReviewApproveEmail(inviteId: string) {
|
||||
const target = await this.getInviteeEmailTarget(inviteId);
|
||||
if (!target) return;
|
||||
const invitation = await this.models.workspaceUser.getById(inviteId);
|
||||
if (!invitation) {
|
||||
this.logger.warn(`Invitation not found for inviteId: ${inviteId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mailer.sendLinkInvitationApproveMail(target.email, {
|
||||
workspace: target.workspace,
|
||||
url: this.url.link(`/workspace/${target.workspace.id}`),
|
||||
const user = await this.models.user.getWorkspaceUser(invitation.userId);
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Invitee user not found for inviteId: ${inviteId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mailer.send({
|
||||
name: 'LinkInvitationApprove',
|
||||
to: user.email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: invitation.workspaceId,
|
||||
},
|
||||
url: this.url.link(`/workspace/${invitation.workspaceId}`),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendReviewDeclinedEmail(
|
||||
email: string | undefined,
|
||||
workspaceId: string
|
||||
) {
|
||||
if (!email) return;
|
||||
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
await this.mailer.sendLinkInvitationDeclineMail(email, {
|
||||
workspace,
|
||||
async sendReviewDeclinedEmail(email: string, workspaceId: string) {
|
||||
await this.mailer.send({
|
||||
name: 'LinkInvitationDecline',
|
||||
to: email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,43 +239,75 @@ export class WorkspaceService {
|
||||
ws: { id: string; role: WorkspaceRole }
|
||||
) {
|
||||
const user = await this.models.user.getWorkspaceUser(userId);
|
||||
if (!user) throw new UserNotFound();
|
||||
|
||||
const workspace = await this.getWorkspaceInfo(ws.id);
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`User not found for seeding role changed email: ${userId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.role === WorkspaceRole.Admin) {
|
||||
await this.mailer.sendTeamBecomeAdminMail(user.email, {
|
||||
workspace,
|
||||
url: this.url.link(`/workspace/${workspace.id}`),
|
||||
await this.mailer.send({
|
||||
name: 'TeamBecomeAdmin',
|
||||
to: user.email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: ws.id,
|
||||
},
|
||||
url: this.url.link(`/workspace/${ws.id}`),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.mailer.sendTeamBecomeCollaboratorMail(user.email, {
|
||||
workspace,
|
||||
url: this.url.link(`/workspace/${workspace.id}`),
|
||||
await this.mailer.send({
|
||||
name: 'TeamBecomeCollaborator',
|
||||
to: user.email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: ws.id,
|
||||
},
|
||||
url: this.url.link(`/workspace/${ws.id}`),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendOwnershipTransferredEmail(email: string, ws: { id: string }) {
|
||||
const workspace = await this.getWorkspaceInfo(ws.id);
|
||||
await this.mailer.sendOwnershipTransferredMail(email, { workspace });
|
||||
await this.mailer.send({
|
||||
name: 'OwnershipTransferred',
|
||||
to: email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: ws.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendOwnershipReceivedEmail(email: string, ws: { id: string }) {
|
||||
const workspace = await this.getWorkspaceInfo(ws.id);
|
||||
await this.mailer.sendOwnershipReceivedMail(email, { workspace });
|
||||
await this.mailer.send({
|
||||
name: 'OwnershipReceived',
|
||||
to: email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: ws.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.leave')
|
||||
async onMemberLeave({
|
||||
user,
|
||||
workspaceId,
|
||||
}: Events['workspace.members.leave']) {
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
async sendLeaveEmail(workspaceId: string, userId: string) {
|
||||
const owner = await this.models.workspaceUser.getOwner(workspaceId);
|
||||
await this.mailer.sendMemberLeaveEmail(owner.email, {
|
||||
workspace,
|
||||
user,
|
||||
await this.mailer.send({
|
||||
name: 'MemberLeave',
|
||||
to: owner.email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
user: {
|
||||
$$userId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,9 +317,21 @@ export class WorkspaceService {
|
||||
workspaceId,
|
||||
}: Events['workspace.members.removed']) {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user) return;
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`User not found for seeding member removed email: ${userId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
await this.mailer.sendMemberRemovedMail(user.email, { workspace });
|
||||
await this.mailer.send({
|
||||
name: 'MemberRemoved',
|
||||
to: user.email,
|
||||
props: {
|
||||
workspace: {
|
||||
$$workspaceId: workspaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,12 @@ export class TeamWorkspaceResolver {
|
||||
// after user click the invite link, we can check again and reject if charge failed
|
||||
if (sendInviteMail) {
|
||||
try {
|
||||
await this.workspaceService.sendInviteEmail(ret.inviteId);
|
||||
await this.workspaceService.sendInviteEmail({
|
||||
workspaceId,
|
||||
inviteeEmail: target.email,
|
||||
inviterUserId: user.id,
|
||||
inviteId: role.id,
|
||||
});
|
||||
ret.sentSuccess = true;
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -493,7 +493,12 @@ export class WorkspaceResolver {
|
||||
|
||||
if (sendInviteMail) {
|
||||
try {
|
||||
await this.workspaceService.sendInviteEmail(role.id);
|
||||
await this.workspaceService.sendInviteEmail({
|
||||
workspaceId,
|
||||
inviteeEmail: email,
|
||||
inviterUserId: user.id,
|
||||
inviteId: role.id,
|
||||
});
|
||||
} catch (e) {
|
||||
await this.models.workspaceUser.delete(workspaceId, user.id);
|
||||
|
||||
@@ -577,7 +582,7 @@ export class WorkspaceResolver {
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
} else {
|
||||
} else if (role.status === WorkspaceMemberStatus.Accepted) {
|
||||
this.event.emit('workspace.members.removed', {
|
||||
userId,
|
||||
workspaceId,
|
||||
@@ -688,13 +693,7 @@ export class WorkspaceResolver {
|
||||
await this.models.workspaceUser.delete(workspaceId, user.id);
|
||||
|
||||
if (sendLeaveMail) {
|
||||
this.event.emit('workspace.members.leave', {
|
||||
workspaceId,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
await this.workspaceService.sendLeaveEmail(workspaceId, user.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user