fix(server): normalize mail server name (#14564)

fix #14562 
fix #14226 
fix #14192


#### PR Dependency Tree


* **PR #14564** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-03 15:51:32 +08:00
committed by GitHub
parent 75efa854bf
commit 2137f68871
8 changed files with 89 additions and 11 deletions

View File

@@ -197,8 +197,8 @@
"properties": { "properties": {
"SMTP.name": { "SMTP.name": {
"type": "string", "type": "string",
"description": "Name of the email server (e.g. your domain name)\n@default \"AFFiNE Server\"\n@environment `MAILER_SERVERNAME`", "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": "AFFiNE Server" "default": ""
}, },
"SMTP.host": { "SMTP.host": {
"type": "string", "type": "string",
@@ -237,8 +237,8 @@
}, },
"fallbackSMTP.name": { "fallbackSMTP.name": {
"type": "string", "type": "string",
"description": "Name of the fallback email server (e.g. your domain name)\n@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": "AFFiNE Server" "default": ""
}, },
"fallbackSMTP.host": { "fallbackSMTP.host": {
"type": "string", "type": "string",

1
.gitignore vendored
View File

@@ -48,6 +48,7 @@ testem.log
/typings /typings
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.context .context
/*.md
# System Files # System Files
.DS_Store .DS_Store

View File

@@ -6,6 +6,7 @@
# MAILER_HOST=127.0.0.1 # MAILER_HOST=127.0.0.1
# MAILER_PORT=1025 # MAILER_PORT=1025
# MAILER_SERVERNAME="mail.example.com"
# MAILER_SENDER="noreply@toeverything.info" # MAILER_SENDER="noreply@toeverything.info"
# MAILER_USER="noreply@toeverything.info" # MAILER_USER="noreply@toeverything.info"
# MAILER_PASSWORD="affine" # MAILER_PASSWORD="affine"

View File

@@ -1,5 +1,6 @@
import test from 'ava'; import test from 'ava';
import { normalizeSMTPHeloHostname } from '../core/mail/utils';
import { Renderers } from '../mails'; import { Renderers } from '../mails';
import { TEST_DOC, TEST_USER } from '../mails/common'; 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); 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);
});

View File

@@ -31,8 +31,8 @@ declare global {
defineModuleConfig('mailer', { defineModuleConfig('mailer', {
'SMTP.name': { 'SMTP.name': {
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.',
default: 'AFFiNE Server', default: '',
env: 'MAILER_SERVERNAME', env: 'MAILER_SERVERNAME',
}, },
'SMTP.host': { 'SMTP.host': {
@@ -72,8 +72,8 @@ defineModuleConfig('mailer', {
shape: z.array(z.string()), shape: z.array(z.string()),
}, },
'fallbackSMTP.name': { 'fallbackSMTP.name': {
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.',
default: 'AFFiNE Server', default: '',
}, },
'fallbackSMTP.host': { 'fallbackSMTP.host': {
desc: 'Host of the email server (e.g. smtp.gmail.com)', desc: 'Host of the email server (e.g. smtp.gmail.com)',

View File

@@ -9,6 +9,7 @@ import {
import SMTPTransport from 'nodemailer/lib/smtp-transport'; import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { Config, metrics, OnEvent } from '../../base'; import { Config, metrics, OnEvent } from '../../base';
import { resolveSMTPHeloHostname } from './utils';
export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & { export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & {
to: string; to: string;
@@ -19,8 +20,10 @@ export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & {
function configToSMTPOptions( function configToSMTPOptions(
config: AppConfig['mailer']['SMTP'] config: AppConfig['mailer']['SMTP']
): SMTPTransport.Options { ): SMTPTransport.Options {
const name = resolveSMTPHeloHostname(config.name);
return { return {
name: config.name, ...(name ? { name } : {}),
host: config.host, host: config.host,
port: config.port, port: config.port,
tls: { tls: {

View File

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

View File

@@ -91,7 +91,7 @@
"mailer": { "mailer": {
"SMTP.name": { "SMTP.name": {
"type": "String", "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" "env": "MAILER_SERVERNAME"
}, },
"SMTP.host": { "SMTP.host": {
@@ -130,7 +130,7 @@
}, },
"fallbackSMTP.name": { "fallbackSMTP.name": {
"type": "String", "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": { "fallbackSMTP.host": {
"type": "String", "type": "String",