refactor(server): mail service (#10934)

This commit is contained in:
forehalo
2025-03-19 17:00:19 +00:00
parent b3a245f47a
commit 21c4a29f55
47 changed files with 2076 additions and 2131 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,
},
});
}
}

View 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', {});

View 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 };

View 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;
}
}

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -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
);
}

View File

@@ -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: [

View File

@@ -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,
},
},
});
}
}

View File

@@ -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(

View File

@@ -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;