From 61fa3ef6f69421b00fcd45cdd4266755aa52a3c5 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:56:30 +0800 Subject: [PATCH] feat(server): add fallback smtp config (#13377) fix AF-2749 ## 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. --- .docker/selfhost/schema.json | 35 ++++++++++++++++ .../backend/server/src/core/mail/config.ts | 42 +++++++++++++++++++ .../backend/server/src/core/mail/sender.ts | 33 ++++++++++++--- packages/frontend/admin/src/config.json | 28 +++++++++++++ 4 files changed, 132 insertions(+), 6 deletions(-) diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 55b057c55f..972a0f8675 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -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 \")\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 } } }, diff --git a/packages/backend/server/src/core/mail/config.ts b/packages/backend/server/src/core/mail/config.ts index ee63102ac4..fb4d2273b5 100644 --- a/packages/backend/server/src/core/mail/config.ts +++ b/packages/backend/server/src/core/mail/config.ts @@ -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; + 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 ")', + default: '', + }, + 'fallbackSMTP.ignoreTLS': { + desc: "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.", + default: false, + }, }); diff --git a/packages/backend/server/src/core/mail/sender.ts b/packages/backend/server/src/core/mail/sender.ts index 7b0c45cf69..424dea85f0 100644 --- a/packages/backend/server/src/core/mail/sender.ts +++ b/packages/backend/server/src/core/mail/sender.ts @@ -36,6 +36,8 @@ function configToSMTPOptions( export class MailSender { private readonly logger = new Logger(MailSender.name); private smtp: Transporter | null = null; + private fallbackSMTP: Transporter | 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 }); diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index b9491a1f3a..ba760bf121 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -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 \")" + }, + "fallbackSMTP.ignoreTLS": { + "type": "Boolean", + "desc": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates." } }, "doc": {