From 2137f68871ea922588bf59d44270a1687db25255 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:51:32 +0800 Subject: [PATCH] fix(server): normalize mail server name (#14564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #14562 fix #14226 fix #14192 #### PR Dependency Tree * **PR #14564** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * SMTP and fallback SMTP name now default to empty and will use the system hostname when not set. * HELO hostname resolution includes stricter normalization/validation for more reliable mail handshakes. * **Documentation** * Updated admin and config descriptions to explain hostname/HELO behavior and fallback. * **Tests** * Added tests covering hostname normalization and rejection of invalid HELO values. * **Chores** * Updated example env and ignore rules. --- .docker/selfhost/schema.json | 8 +-- .gitignore | 1 + packages/backend/server/.env.example | 1 + .../server/src/__tests__/mails.spec.ts | 20 +++++++ .../backend/server/src/core/mail/config.ts | 8 +-- .../backend/server/src/core/mail/sender.ts | 5 +- .../backend/server/src/core/mail/utils.ts | 53 +++++++++++++++++++ packages/frontend/admin/src/config.json | 4 +- 8 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 packages/backend/server/src/core/mail/utils.ts diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 1feba431ba..6c027fbec1 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -197,8 +197,8 @@ "properties": { "SMTP.name": { "type": "string", - "description": "Name of the email server (e.g. your domain name)\n@default \"AFFiNE Server\"\n@environment `MAILER_SERVERNAME`", - "default": "AFFiNE Server" + "description": "Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"\n@environment `MAILER_SERVERNAME`", + "default": "" }, "SMTP.host": { "type": "string", @@ -237,8 +237,8 @@ }, "fallbackSMTP.name": { "type": "string", - "description": "Name of the fallback email server (e.g. your domain name)\n@default \"AFFiNE Server\"", - "default": "AFFiNE Server" + "description": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"", + "default": "" }, "fallbackSMTP.host": { "type": "string", diff --git a/.gitignore b/.gitignore index 15e44010e7..08d069b6f2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ testem.log /typings tsconfig.tsbuildinfo .context +/*.md # System Files .DS_Store diff --git a/packages/backend/server/.env.example b/packages/backend/server/.env.example index 2d8a2362b7..6f26b561ea 100644 --- a/packages/backend/server/.env.example +++ b/packages/backend/server/.env.example @@ -6,6 +6,7 @@ # MAILER_HOST=127.0.0.1 # MAILER_PORT=1025 +# MAILER_SERVERNAME="mail.example.com" # MAILER_SENDER="noreply@toeverything.info" # MAILER_USER="noreply@toeverything.info" # MAILER_PASSWORD="affine" diff --git a/packages/backend/server/src/__tests__/mails.spec.ts b/packages/backend/server/src/__tests__/mails.spec.ts index 7ed3bb8893..c0ee374769 100644 --- a/packages/backend/server/src/__tests__/mails.spec.ts +++ b/packages/backend/server/src/__tests__/mails.spec.ts @@ -1,5 +1,6 @@ import test from 'ava'; +import { normalizeSMTPHeloHostname } from '../core/mail/utils'; import { Renderers } from '../mails'; import { TEST_DOC, TEST_USER } from '../mails/common'; @@ -21,3 +22,22 @@ test('should render mention email with empty doc title', async t => { }); t.snapshot(content.html, content.subject); }); + +test('should normalize valid SMTP HELO hostnames', t => { + t.is(normalizeSMTPHeloHostname('mail.example.com'), 'mail.example.com'); + t.is(normalizeSMTPHeloHostname(' localhost '), 'localhost'); + t.is(normalizeSMTPHeloHostname('[127.0.0.1]'), '[127.0.0.1]'); + t.is(normalizeSMTPHeloHostname('[IPv6:2001:db8::1]'), '[IPv6:2001:db8::1]'); +}); + +test('should reject invalid SMTP HELO hostnames', t => { + t.is(normalizeSMTPHeloHostname(''), undefined); + t.is(normalizeSMTPHeloHostname(' '), undefined); + t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined); + t.is(normalizeSMTPHeloHostname('-example.com'), undefined); + t.is(normalizeSMTPHeloHostname('example-.com'), undefined); + t.is(normalizeSMTPHeloHostname('example..com'), undefined); + t.is(normalizeSMTPHeloHostname('[bad host]'), undefined); + t.is(normalizeSMTPHeloHostname('[foo]'), undefined); + t.is(normalizeSMTPHeloHostname('[IPv6:foo]'), undefined); +}); diff --git a/packages/backend/server/src/core/mail/config.ts b/packages/backend/server/src/core/mail/config.ts index 97c61f41a7..fdac7b4eb1 100644 --- a/packages/backend/server/src/core/mail/config.ts +++ b/packages/backend/server/src/core/mail/config.ts @@ -31,8 +31,8 @@ declare global { defineModuleConfig('mailer', { 'SMTP.name': { - desc: 'Name of the email server (e.g. your domain name)', - default: 'AFFiNE Server', + desc: 'Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.', + default: '', env: 'MAILER_SERVERNAME', }, 'SMTP.host': { @@ -72,8 +72,8 @@ defineModuleConfig('mailer', { shape: z.array(z.string()), }, 'fallbackSMTP.name': { - desc: 'Name of the fallback email server (e.g. your domain name)', - default: 'AFFiNE Server', + desc: 'Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.', + default: '', }, 'fallbackSMTP.host': { desc: 'Host of the email server (e.g. smtp.gmail.com)', diff --git a/packages/backend/server/src/core/mail/sender.ts b/packages/backend/server/src/core/mail/sender.ts index 70bf39007e..aadcaa976d 100644 --- a/packages/backend/server/src/core/mail/sender.ts +++ b/packages/backend/server/src/core/mail/sender.ts @@ -9,6 +9,7 @@ import { import SMTPTransport from 'nodemailer/lib/smtp-transport'; import { Config, metrics, OnEvent } from '../../base'; +import { resolveSMTPHeloHostname } from './utils'; export type SendOptions = Omit & { to: string; @@ -19,8 +20,10 @@ export type SendOptions = Omit & { function configToSMTPOptions( config: AppConfig['mailer']['SMTP'] ): SMTPTransport.Options { + const name = resolveSMTPHeloHostname(config.name); + return { - name: config.name, + ...(name ? { name } : {}), host: config.host, port: config.port, tls: { diff --git a/packages/backend/server/src/core/mail/utils.ts b/packages/backend/server/src/core/mail/utils.ts new file mode 100644 index 0000000000..33adf329a5 --- /dev/null +++ b/packages/backend/server/src/core/mail/utils.ts @@ -0,0 +1,53 @@ +import { isIP } from 'node:net'; +import { hostname as getHostname } from 'node:os'; + +const hostnameLabelRegexp = /^[A-Za-z0-9-]+$/; + +function isValidSMTPAddressLiteral(hostname: string) { + if (!hostname.startsWith('[') || !hostname.endsWith(']')) return false; + + const literal = hostname.slice(1, -1); + if (!literal || literal.includes(' ')) return false; + if (isIP(literal) === 4) return true; + + if (literal.startsWith('IPv6:')) { + return isIP(literal.slice('IPv6:'.length)) === 6; + } + + return false; +} + +export function normalizeSMTPHeloHostname(hostname: string) { + const normalized = hostname.trim().replace(/\.$/, ''); + if (!normalized) return undefined; + if (isValidSMTPAddressLiteral(normalized)) return normalized; + if (normalized.length > 253) return undefined; + + const labels = normalized.split('.'); + for (const label of labels) { + if (!label || label.length > 63) return undefined; + if ( + !hostnameLabelRegexp.test(label) || + label.startsWith('-') || + label.endsWith('-') + ) { + return undefined; + } + } + + return normalized; +} + +function readSystemHostname() { + try { + return getHostname(); + } catch { + return ''; + } +} + +export function resolveSMTPHeloHostname(configuredName: string) { + const normalizedConfiguredName = normalizeSMTPHeloHostname(configuredName); + if (normalizedConfiguredName) return normalizedConfiguredName; + return normalizeSMTPHeloHostname(readSystemHostname()); +} diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index 687a54518d..32c0e45b89 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -91,7 +91,7 @@ "mailer": { "SMTP.name": { "type": "String", - "desc": "Name of the email server (e.g. your domain name)", + "desc": "Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.", "env": "MAILER_SERVERNAME" }, "SMTP.host": { @@ -130,7 +130,7 @@ }, "fallbackSMTP.name": { "type": "String", - "desc": "Name of the fallback email server (e.g. your domain name)" + "desc": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname." }, "fallbackSMTP.host": { "type": "String",