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
+35
View File
@@ -219,6 +219,41 @@
"type": "boolean",
"description": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.\n@default false\n@environment `MAILER_IGNORE_TLS`",
"default": false
},
"fallbackDomains": {
"type": "array",
"description": "The emails from these domains are always sent using the fallback SMTP server.\n@default []",
"default": []
},
"fallbackSMTP.host": {
"type": "string",
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"",
"default": ""
},
"fallbackSMTP.port": {
"type": "number",
"description": "Port of the email server (they commonly are 25, 465 or 587)\n@default 465",
"default": 465
},
"fallbackSMTP.username": {
"type": "string",
"description": "Username used to authenticate the email server\n@default \"\"",
"default": ""
},
"fallbackSMTP.password": {
"type": "string",
"description": "Password used to authenticate the email server\n@default \"\"",
"default": ""
},
"fallbackSMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Team <noreply@affine.pro>\")\n@default \"\"",
"default": ""
},
"fallbackSMTP.ignoreTLS": {
"type": "boolean",
"description": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.\n@default false",
"default": false
}
}
},
@@ -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,
},
});
@@ -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 });
+28
View File
@@ -114,6 +114,34 @@
"type": "Boolean",
"desc": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.",
"env": "MAILER_IGNORE_TLS"
},
"fallbackDomains": {
"type": "Array",
"desc": "The emails from these domains are always sent using the fallback SMTP server."
},
"fallbackSMTP.host": {
"type": "String",
"desc": "Host of the email server (e.g. smtp.gmail.com)"
},
"fallbackSMTP.port": {
"type": "Number",
"desc": "Port of the email server (they commonly are 25, 465 or 587)"
},
"fallbackSMTP.username": {
"type": "String",
"desc": "Username used to authenticate the email server"
},
"fallbackSMTP.password": {
"type": "String",
"desc": "Password used to authenticate the email server"
},
"fallbackSMTP.sender": {
"type": "String",
"desc": "Sender of all the emails (e.g. \"AFFiNE Team <noreply@affine.pro>\")"
},
"fallbackSMTP.ignoreTLS": {
"type": "Boolean",
"desc": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates."
}
},
"doc": {