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

@@ -26,7 +26,6 @@ export * from './guard';
export { CryptoHelper, URLHelper } from './helpers';
export * from './job';
export { AFFiNELogger } from './logger';
export { MailService } from './mailer';
export { CallMetric, metrics } from './metrics';
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
export * from './nestjs';

View File

@@ -1 +1 @@
export { JobModule, JobQueue, OnJob } from './queue';
export { JOB_SIGNAL, JobModule, JobQueue, OnJob } from './queue';

View File

@@ -93,10 +93,6 @@ test('should register job handler', async t => {
t.is(handler!.name, 'JobHandlers.handleJob');
t.is(typeof handler!.fn, 'function');
const result = await handler!.fn({ name: 'test' });
t.is(result, 'test');
});
// #endregion

View File

@@ -63,3 +63,7 @@ If you want to introduce new job queue, please modify the Queue enum first in ${
export function getJobHandlerMetadata(target: any): JobName[] {
return sliceMetadata<JobName>(JOB_METADATA, target);
}
export enum JOB_SIGNAL {
RETRY = 'retry',
}

View File

@@ -13,7 +13,7 @@ import { metrics, wrapCallMetric } from '../../metrics';
import { QueueRedis } from '../../redis';
import { Runtime } from '../../runtime';
import { genRequestId } from '../../utils';
import { namespace, Queue, QUEUES } from './def';
import { JOB_SIGNAL, namespace, Queue, QUEUES } from './def';
import { JobHandlerScanner } from './scanner';
@Injectable()
@@ -69,8 +69,12 @@ export class JobExecutor
try {
this.logger.debug(`Job started: ${signature}`);
const result = await handler.fn(payload);
if (result === JOB_SIGNAL.RETRY) {
throw new Error(`Manually job retry`);
}
this.logger.debug(`Job finished: ${signature}`);
return result;
} catch (e) {
this.logger.error(`Job failed: ${signature}`, e);
throw e;
@@ -88,7 +92,7 @@ export class JobExecutor
const activeJobs = metrics.queue.counter('active_jobs');
activeJobs.add(1, { queue: ns });
try {
return await fn();
await fn();
} finally {
activeJobs.add(-1, { queue: ns });
}

View File

@@ -42,4 +42,4 @@ export class JobModule {
}
export { JobQueue };
export { OnJob } from './def';
export { JOB_SIGNAL, OnJob } from './def';

View File

@@ -1,11 +1,11 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleScanner } from '../../nestjs';
import { getJobHandlerMetadata } from './def';
import { getJobHandlerMetadata, JOB_SIGNAL } from './def';
interface JobHandler {
name: string;
fn: (payload: any) => any;
fn: (payload: any) => Promise<JOB_SIGNAL | undefined>;
}
@Injectable()

View File

@@ -1,16 +0,0 @@
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../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

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

View File

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

View File

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