feat(server): add fallback smtp config (#13377)

fix AF-2749

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for configuring a fallback SMTP server for outgoing
emails.
* Introduced the ability to specify email domains that will always use
the fallback SMTP server.
* Enhanced email sending to automatically route messages to the
appropriate SMTP server based on recipient domain.

* **Documentation**
* Updated configuration options and descriptions in the admin interface
to reflect new fallback SMTP settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-07-31 17:56:30 +08:00
committed by GitHub
parent 77950cfc1b
commit 61fa3ef6f6
4 changed files with 132 additions and 6 deletions

View File

@@ -1,3 +1,5 @@
import z from 'zod';
import { defineModuleConfig } from '../../base';
declare global {
@@ -11,6 +13,16 @@ declare global {
ignoreTLS: boolean;
sender: string;
};
fallbackDomains: ConfigItem<string[]>;
fallbackSMTP: {
host: string;
port: number;
username: string;
password: string;
ignoreTLS: boolean;
sender: string;
};
};
}
}
@@ -46,4 +58,34 @@ defineModuleConfig('mailer', {
default: false,
env: ['MAILER_IGNORE_TLS', 'boolean'],
},
fallbackDomains: {
desc: 'The emails from these domains are always sent using the fallback SMTP server.',
default: [],
shape: z.array(z.string()),
},
'fallbackSMTP.host': {
desc: 'Host of the email server (e.g. smtp.gmail.com)',
default: '',
},
'fallbackSMTP.port': {
desc: 'Port of the email server (they commonly are 25, 465 or 587)',
default: 465,
},
'fallbackSMTP.username': {
desc: 'Username used to authenticate the email server',
default: '',
},
'fallbackSMTP.password': {
desc: 'Password used to authenticate the email server',
default: '',
},
'fallbackSMTP.sender': {
desc: 'Sender of all the emails (e.g. "AFFiNE Team <noreply@affine.pro>")',
default: '',
},
'fallbackSMTP.ignoreTLS': {
desc: "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.",
default: false,
},
});

View File

@@ -36,6 +36,8 @@ function configToSMTPOptions(
export class MailSender {
private readonly logger = new Logger(MailSender.name);
private smtp: Transporter<SMTPTransport.SentMessageInfo> | null = null;
private fallbackSMTP: Transporter<SMTPTransport.SentMessageInfo> | null =
null;
private usingTestAccount = false;
constructor(private readonly config: Config) {}
@@ -61,11 +63,17 @@ export class MailSender {
}
private setup() {
const { SMTP } = this.config.mailer;
const { SMTP, fallbackDomains, fallbackSMTP } = this.config.mailer;
const opts = configToSMTPOptions(SMTP);
if (SMTP.host) {
this.smtp = createTransport(opts);
if (fallbackDomains.length > 0 && fallbackSMTP?.host) {
this.logger.warn(
`Fallback SMTP is configured for domains: ${fallbackDomains.join(', ')}`
);
this.fallbackSMTP = createTransport(configToSMTPOptions(fallbackSMTP));
}
} else if (env.dev) {
createTestAccount((err, account) => {
if (!err) {
@@ -83,21 +91,34 @@ export class MailSender {
} else {
this.logger.warn('Mailer SMTP transport is not configured.');
this.smtp = null;
this.fallbackSMTP = null;
}
}
private getSender(domain: string) {
const { SMTP, fallbackSMTP, fallbackDomains } = this.config.mailer;
if (this.fallbackSMTP && fallbackDomains.includes(domain)) {
return [this.fallbackSMTP, fallbackSMTP.sender] as const;
}
return [this.smtp, SMTP.sender] as const;
}
async send(name: string, options: SendOptions) {
if (!this.smtp) {
const [, domain, ...rest] = options.to.split('@');
if (rest.length || !domain) {
this.logger.error(`Invalid email address: ${options.to}`);
return null;
}
const [smtpClient, from] = this.getSender(domain);
if (!smtpClient) {
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.SMTP.sender,
...options,
});
const result = await smtpClient.sendMail({ from, ...options });
if (result.rejected.length > 0) {
metrics.mail.counter('rejected_total').add(1, { name });