fix(email): build an RFC 5322 message with a proper From address and name (#5941)

The notification/test email carried only From/To/Subject/MIME headers, and
the From header was the raw SMTP username. Two problems:

- When the SMTP login is not a bare email address (common with relays and
  submission services), the From header has no valid address and strict
  receivers reject the message — e.g. Gmail returns "550-5.7.1 ... Messages
  missing a valid address in From: header".
- There was no Date (mandatory per RFC 5322 section 3.6) and no Message-ID,
  which also raises spam score.

Add smtpFrom (sender address) and smtpFromName (display name) settings and
assemble the message with net/mail: a name-addr From ("Name" <addr>), a
Date, a Message-ID, and an RFC 2047 encoded Subject, in a deterministic
header order. From falls back to the username when smtpFrom is empty, so
existing setups keep working. Wire the settings through the model, the SMTP
send and test paths, the Email settings UI, and all 13 locale files;
regenerate the Zod/OpenAPI artifacts.

Validate smtpFrom in AllSetting.CheckValid (reject anything net/mail cannot
parse), which surfaces a bad address at configuration time and prevents CRLF
header injection; strip CR/LF in buildMessage as defense in depth. Add
buildMessage and CheckValid tests.
This commit is contained in:
Yuri Khachaturyan
2026-07-14 13:55:46 +03:00
committed by GitHub
parent ae0da4c51f
commit 1cfd7b49b0
25 changed files with 292 additions and 14 deletions
+4
View File
@@ -35,6 +35,8 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
@@ -138,6 +140,8 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
+16
View File
@@ -116,6 +116,12 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
@@ -349,6 +355,8 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
@@ -549,6 +557,12 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
@@ -789,6 +803,8 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
+4
View File
@@ -41,6 +41,8 @@ export interface AllSetting {
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
@@ -145,6 +147,8 @@ export interface AllSettingView {
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
+4
View File
@@ -53,6 +53,8 @@ export const AllSettingSchema = z.object({
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
@@ -158,6 +160,8 @@ export const AllSettingViewSchema = z.object({
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
+2
View File
@@ -91,6 +91,8 @@ export class AllSetting {
smtpPort = 587;
smtpUsername = '';
smtpPassword = '';
smtpFrom = '';
smtpFromName = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = '';
+10
View File
@@ -82,6 +82,16 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpFrom')} description={t('pages.settings.smtpFromDesc')}>
<Input value={allSetting.smtpFrom} placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpFrom: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpFromName')} description={t('pages.settings.smtpFromNameDesc')}>
<Input value={allSetting.smtpFromName} placeholder="3x-ui"
onChange={(e) => updateSetting({ smtpFromName: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
<Input value={allSetting.smtpTo} placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })} />