Преглед на файлове

feat: add deliverability headers

AI-Co-Authored-By: Codex
chendeben преди 1 месец
родител
ревизия
9e63730463
променени са 15 файла, в които са добавени 691 реда и са изтрити 13 реда
  1. 12 0
      .env.example
  2. 5 0
      README.md
  3. 0 0
      public/assets/index-DIIQ1K-Z.js
  4. 1 1
      public/index.html
  5. 9 1
      src/db.js
  6. 23 1
      src/dkim.js
  7. 8 0
      src/frontend/types.ts
  8. 175 3
      src/mailer.js
  9. 24 0
      src/pages/Settings.tsx
  10. 69 5
      src/server.js
  11. 29 1
      src/submission.js
  12. 38 1
      test/dkim.test.js
  13. 107 0
      test/mailer-deliverability.test.js
  14. 100 0
      test/server-send-tracking.test.js
  15. 91 0
      test/submission-tracking.test.js

+ 12 - 0
.env.example

@@ -44,6 +44,18 @@ ENGAGEMENT_TRACKING_ENABLED=false
 TRACKING_RETENTION_DAYS=180
 TRACKING_RETENTION_DAYS=180
 TRUST_PROXY=true
 TRUST_PROXY=true
 
 
+# Optional deliverability headers. One-click should only be enabled when the URL accepts compliant unsubscribe POSTs.
+LIST_UNSUBSCRIBE_MAILTO=
+LIST_UNSUBSCRIBE_URL=
+LIST_UNSUBSCRIBE_POST_ENABLED=false
+FEEDBACK_ID_ENABLED=true
+REPORT_ABUSE_TO=
+CSA_COMPLAINTS_TO=
+
+# Optional bounce envelope sender. Keep disabled until this mailbox/domain receives and processes bounces.
+BOUNCE_ADDRESS=
+BOUNCE_ENVELOPE_ENABLED=false
+
 # DMARC defaults.
 # DMARC defaults.
 DMARC_POLICY=none
 DMARC_POLICY=none
 DMARC_RUA=
 DMARC_RUA=

+ 5 - 0
README.md

@@ -47,6 +47,11 @@ docker compose logs -f app postfix
 - `SUBMISSION_TLS_CERT`、`SUBMISSION_TLS_KEY`:TLS 证书路径。证书文件应放在本地 `certs/`,不要提交到 Git。
 - `SUBMISSION_TLS_CERT`、`SUBMISSION_TLS_KEY`:TLS 证书路径。证书文件应放在本地 `certs/`,不要提交到 Git。
 - `DEFAULT_SPF_MECHANISMS`:需要保留的第三方 SPF include,例如事务邮件服务商。
 - `DEFAULT_SPF_MECHANISMS`:需要保留的第三方 SPF include,例如事务邮件服务商。
 - `SEND_REQUIRES_VERIFIED`:是否要求域名 DNS 验证通过后才能发信。
 - `SEND_REQUIRES_VERIFIED`:是否要求域名 DNS 验证通过后才能发信。
+- `LIST_UNSUBSCRIBE_MAILTO`、`LIST_UNSUBSCRIBE_URL`:可选退订头配置,支持 `{eventId}`、`{recipient}`、`{sender}`、`{domain}`、`{userId}` 占位符。
+- `LIST_UNSUBSCRIBE_POST_ENABLED`:是否添加 one-click 退订头;仅在退订 URL 真正支持合规 POST 时开启。
+- `FEEDBACK_ID_ENABLED`:是否为 API 发信添加不含明文邮箱的 `Feedback-Id`。
+- `REPORT_ABUSE_TO`、`CSA_COMPLAINTS_TO`:可选投诉处理邮箱头。
+- `BOUNCE_ADDRESS`、`BOUNCE_ENVELOPE_ENABLED`:可选退信 envelope sender;确认 bounce 邮箱/域名可收信和处理前不要开启。
 
 
 ## SMTP 发信
 ## SMTP 发信
 
 

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-DIIQ1K-Z.js


+ 1 - 1
public/index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-B1y0mlYN.js"></script>
+    <script type="module" crossorigin src="/assets/index-DIIQ1K-Z.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/styles-KD6S-HfV.js">
     <link rel="modulepreload" crossorigin href="/assets/styles-KD6S-HfV.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-BJk6n3Q_.css">
     <link rel="stylesheet" crossorigin href="/assets/styles-BJk6n3Q_.css">

+ 9 - 1
src/db.js

@@ -2692,7 +2692,15 @@ export function saveSettings(patch) {
     'dmarcPolicy',
     'dmarcPolicy',
     'dmarcRua',
     'dmarcRua',
     'sendRequiresVerified',
     'sendRequiresVerified',
-    'engagementTrackingEnabled'
+    'engagementTrackingEnabled',
+    'listUnsubscribeMailto',
+    'listUnsubscribeUrl',
+    'listUnsubscribePostEnabled',
+    'feedbackIdEnabled',
+    'reportAbuseTo',
+    'csaComplaintsTo',
+    'bounceAddress',
+    'bounceEnvelopeEnabled'
   ]);
   ]);
   const updatedAt = now();
   const updatedAt = now();
   for (const [key, value] of Object.entries(patch)) {
   for (const [key, value] of Object.entries(patch)) {

+ 23 - 1
src/dkim.js

@@ -39,7 +39,7 @@ export function buildDkimRecord(publicKey) {
 export function signDkim(rawMessage, options) {
 export function signDkim(rawMessage, options) {
   const headers = parseHeaders(rawMessage);
   const headers = parseHeaders(rawMessage);
   const body = rawMessage.slice(rawMessage.indexOf('\r\n\r\n') + 4);
   const body = rawMessage.slice(rawMessage.indexOf('\r\n\r\n') + 4);
-  const signedHeaderNames = [
+  const baseSignedHeaderNames = [
     'from',
     'from',
     'to',
     'to',
     'subject',
     'subject',
@@ -48,21 +48,43 @@ export function signDkim(rawMessage, options) {
     'mime-version',
     'mime-version',
     'content-type'
     'content-type'
   ];
   ];
+  const optionalSignedHeaderNames = [
+    'list-unsubscribe',
+    'list-unsubscribe-post',
+    'feedback-id',
+    'x-report-abuse-to',
+    'x-csa-complaints',
+    'x-sender',
+    'sender',
+    'reply-to'
+  ];
+  const presentHeaderNames = new Set(headers.map((header) => header.name.toLowerCase()));
+  const signedHeaderNames = [
+    ...baseSignedHeaderNames,
+    ...optionalSignedHeaderNames.filter((name) => presentHeaderNames.has(name))
+  ];
   const bodyHash = crypto
   const bodyHash = crypto
     .createHash('sha256')
     .createHash('sha256')
     .update(canonicalizeBody(body))
     .update(canonicalizeBody(body))
     .digest('base64');
     .digest('base64');
 
 
+  const now = Math.floor(Date.now() / 1000);
   const signatureFields = [
   const signatureFields = [
     'v=1',
     'v=1',
     'a=rsa-sha256',
     'a=rsa-sha256',
     'c=relaxed/relaxed',
     'c=relaxed/relaxed',
+    'q=dns/txt',
     `d=${options.domain}`,
     `d=${options.domain}`,
+    `i=${options.identity || `@${options.domain}`}`,
     `s=${options.selector}`,
     `s=${options.selector}`,
+    `t=${now}`,
     `h=${signedHeaderNames.join(':')}`,
     `h=${signedHeaderNames.join(':')}`,
     `bh=${bodyHash}`,
     `bh=${bodyHash}`,
     'b='
     'b='
   ];
   ];
+  if (Number.isInteger(options.expiresInSeconds) && options.expiresInSeconds > 0) {
+    signatureFields.splice(signatureFields.length - 2, 0, `x=${now + options.expiresInSeconds}`);
+  }
   const dkimValueWithoutSignature = signatureFields.join('; ');
   const dkimValueWithoutSignature = signatureFields.join('; ');
   const signingInput = [
   const signingInput = [
     ...signedHeaderNames.map((name) => canonicalizeHeader(findHeader(headers, name))),
     ...signedHeaderNames.map((name) => canonicalizeHeader(findHeader(headers, name))),

+ 8 - 0
src/frontend/types.ts

@@ -48,6 +48,14 @@ export interface RuntimeConfig {
   dmarcRua: string;
   dmarcRua: string;
   sendRequiresVerified: boolean;
   sendRequiresVerified: boolean;
   engagementTrackingEnabled: boolean;
   engagementTrackingEnabled: boolean;
+  listUnsubscribeMailto: string;
+  listUnsubscribeUrl: string;
+  listUnsubscribePostEnabled: boolean;
+  feedbackIdEnabled: boolean;
+  reportAbuseTo: string;
+  csaComplaintsTo: string;
+  bounceAddress: string;
+  bounceEnvelopeEnabled: boolean;
   systemChecks?: SystemChecks;
   systemChecks?: SystemChecks;
   submission?: {
   submission?: {
     enabled: boolean;
     enabled: boolean;

+ 175 - 3
src/mailer.js

@@ -25,7 +25,7 @@ export function domainFromAddress(value) {
   return address.split('@')[1] || '';
   return address.split('@')[1] || '';
 }
 }
 
 
-export function buildMessage({ from, to, subject, text, html, baseUrl }) {
+export function buildMessage({ from, to, subject, text, html, baseUrl, headers: extraHeaders = [] }) {
   const recipients = Array.isArray(to) ? to : parseAddressList(to);
   const recipients = Array.isArray(to) ? to : parseAddressList(to);
   if (!recipients.length) throw new Error('At least one recipient is required.');
   if (!recipients.length) throw new Error('At least one recipient is required.');
   const messageIdHost = domainFromAddress(from) || 'localhost';
   const messageIdHost = domainFromAddress(from) || 'localhost';
@@ -37,6 +37,7 @@ export function buildMessage({ from, to, subject, text, html, baseUrl }) {
     ['Date', new Date().toUTCString()],
     ['Date', new Date().toUTCString()],
     ['Message-ID', messageId],
     ['Message-ID', messageId],
     ['MIME-Version', '1.0'],
     ['MIME-Version', '1.0'],
+    ...normalizeExtraHeaders(extraHeaders),
     ['X-MailHub', baseUrl || 'mailhub']
     ['X-MailHub', baseUrl || 'mailhub']
   ];
   ];
 
 
@@ -76,10 +77,80 @@ export function signMessageForDomain(rawMessage, domain) {
   return signDkim(rawMessage, {
   return signDkim(rawMessage, {
     domain: domain.domain,
     domain: domain.domain,
     selector: domain.selector,
     selector: domain.selector,
-    privateKey: domain.dkimPrivate
+    privateKey: domain.dkimPrivate,
+    identity: `@${domain.domain}`
   });
   });
 }
 }
 
 
+export function buildDeliverabilityHeaders({
+  from = '',
+  listUnsubscribeMailto = '',
+  listUnsubscribeUrl = '',
+  listUnsubscribePostEnabled = false,
+  feedbackId = '',
+  reportAbuseTo = '',
+  csaComplaintsTo = '',
+  context = {}
+} = {}) {
+  const headers = [];
+  const unsubscribeLinks = [];
+  const mailto = normalizeMailtoListUnsubscribe(listUnsubscribeMailto);
+  const url = normalizeHttpListUnsubscribe(renderDeliverabilityTemplate(listUnsubscribeUrl, context));
+  if (mailto) unsubscribeLinks.push(`<${mailto}>`);
+  if (url) unsubscribeLinks.push(`<${url}>`);
+  if (unsubscribeLinks.length) {
+    headers.push(['List-Unsubscribe', unsubscribeLinks.join(', ')]);
+    if (listUnsubscribePostEnabled && isHttpsUrl(url)) {
+      headers.push(['List-Unsubscribe-Post', 'List-Unsubscribe=One-Click']);
+    }
+  }
+
+  const normalizedFeedbackId = normalizeFeedbackId(feedbackId);
+  if (normalizedFeedbackId) headers.push(['Feedback-Id', normalizedFeedbackId]);
+
+  const abuse = sanitizeHeader(reportAbuseTo);
+  if (abuse) headers.push(['X-Report-Abuse-To', abuse]);
+
+  const complaints = sanitizeHeader(csaComplaintsTo);
+  if (complaints) headers.push(['X-CSA-Complaints', complaints]);
+
+  const sender = extractAddress(from);
+  if (sender) headers.push(['X-Sender', sender]);
+
+  return headers;
+}
+
+export function addHeadersToRawMessage(rawMessage, headers = []) {
+  const extraHeaders = normalizeExtraHeaders(headers);
+  if (!extraHeaders.length) return rawMessage;
+  const normalized = normalizeMimeMessage(rawMessage);
+  const separator = normalized.indexOf('\r\n\r\n');
+  if (separator === -1) return `${formatHeaders(extraHeaders)}\r\n\r\n${normalized}`;
+
+  const headerBlock = normalized.slice(0, separator);
+  const body = normalized.slice(separator + 4);
+  const existingNames = new Set(parseHeaderNames(headerBlock));
+  const missingHeaders = extraHeaders.filter(([name]) => !existingNames.has(name.toLowerCase()));
+  if (!missingHeaders.length) return normalized;
+  return `${headerBlock}\r\n${formatHeaders(missingHeaders)}\r\n\r\n${body}`;
+}
+
+export function createFeedbackId({ userId, domainId, eventId, secret, product = 'MailHub' } = {}) {
+  const key = String(secret || 'mailhub-feedback');
+  const safeProduct = normalizeFeedbackId(product) || 'MailHub';
+  return [
+    'mh',
+    feedbackPart('user', userId, key),
+    feedbackPart('domain', domainId, key),
+    `${feedbackPart('event', eventId, key)}:${safeProduct}`
+  ].join('.');
+}
+
+export function resolveEnvelopeSender(settings = {}, fallback = '') {
+  if (!settings.bounceEnvelopeEnabled) return fallback;
+  return extractAddress(settings.bounceAddress) || fallback;
+}
+
 export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) {
 export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) {
   const deliveryLog = [];
   const deliveryLog = [];
   const addLog = (entry) => deliveryLog.push({ at: new Date().toISOString(), ...entry });
   const addLog = (entry) => deliveryLog.push({ at: new Date().toISOString(), ...entry });
@@ -198,10 +269,111 @@ function encodeHeader(value) {
 function formatHeaders(headers) {
 function formatHeaders(headers) {
   return headers
   return headers
     .filter(([, value]) => value !== undefined && value !== null && value !== '')
     .filter(([, value]) => value !== undefined && value !== null && value !== '')
-    .map(([name, value]) => `${name}: ${value}`)
+    .map(([name, value]) => formatHeader(name, value))
     .join('\r\n');
     .join('\r\n');
 }
 }
 
 
+function formatHeader(name, value) {
+  const line = `${name}: ${value}`;
+  if (line.length <= 78) return line;
+  return foldStructuredHeader(name, value);
+}
+
+function foldStructuredHeader(name, value) {
+  const prefix = `${name}: `;
+  const lines = [];
+  let current = prefix;
+  for (const token of splitHeaderTokens(value)) {
+    if (current.length > prefix.length && current.length + token.length > 78) {
+      lines.push(current.trimEnd());
+      current = ` ${token.trimStart()}`;
+      continue;
+    }
+    current += token;
+  }
+  lines.push(current.trimEnd());
+  return lines.join('\r\n');
+}
+
+function splitHeaderTokens(value) {
+  return String(value).split(/((?:,\s+)|\s+)/).filter(Boolean);
+}
+
+function normalizeExtraHeaders(headers) {
+  return (headers || [])
+    .filter((header) => Array.isArray(header) && isHeaderName(header[0]))
+    .map(([name, value]) => [String(name), sanitizeHeader(value)])
+    .filter(([, value]) => value);
+}
+
+function isHeaderName(value) {
+  return /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/.test(String(value || ''));
+}
+
+function normalizeMailtoListUnsubscribe(value) {
+  const clean = sanitizeHeader(value);
+  if (!clean) return '';
+  if (/^mailto:/i.test(clean)) return clean;
+  const address = extractAddress(clean);
+  return address ? `mailto:${address}` : '';
+}
+
+function normalizeHttpListUnsubscribe(value) {
+  const clean = sanitizeHeader(value);
+  if (!clean) return '';
+  try {
+    const url = new URL(clean);
+    if (!['http:', 'https:'].includes(url.protocol)) return '';
+    return url.toString();
+  } catch {
+    return '';
+  }
+}
+
+function isHttpsUrl(value) {
+  try {
+    return new URL(value).protocol === 'https:';
+  } catch {
+    return false;
+  }
+}
+
+function normalizeMimeMessage(value) {
+  return String(value || '').replace(/\r?\n/g, '\r\n');
+}
+
+function parseHeaderNames(headerBlock) {
+  const names = [];
+  for (const line of String(headerBlock || '').split('\r\n')) {
+    if (/^[\t ]/.test(line)) continue;
+    const index = line.indexOf(':');
+    if (index === -1) continue;
+    names.push(line.slice(0, index).toLowerCase());
+  }
+  return names;
+}
+
+function renderDeliverabilityTemplate(value, context = {}) {
+  return String(value || '').replace(/\{(eventId|recipient|sender|domain|userId)\}/g, (_match, key) => {
+    const replacement = context[key] ?? '';
+    return encodeURIComponent(String(replacement));
+  });
+}
+
+function normalizeFeedbackId(value) {
+  const clean = sanitizeHeader(value);
+  if (!/^[A-Za-z0-9._:-]{1,128}$/.test(clean)) return '';
+  return clean;
+}
+
+function feedbackPart(name, value, secret) {
+  return crypto
+    .createHmac('sha256', secret)
+    .update(`${name}:${String(value ?? '')}`)
+    .digest('hex')
+    .slice(0, 12);
+}
+
 function normalizeBody(value) {
 function normalizeBody(value) {
   return String(value || '').replace(/\r?\n/g, '\r\n');
   return String(value || '').replace(/\r?\n/g, '\r\n');
 }
 }

+ 24 - 0
src/pages/Settings.tsx

@@ -104,6 +104,21 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
             <Form.Item name="dmarcRua" label="DMARC_RUA">
             <Form.Item name="dmarcRua" label="DMARC_RUA">
               <Input />
               <Input />
             </Form.Item>
             </Form.Item>
+            <Form.Item name="listUnsubscribeMailto" label="LIST_UNSUBSCRIBE_MAILTO">
+              <Input placeholder="unsubscribe@example.com" />
+            </Form.Item>
+            <Form.Item name="listUnsubscribeUrl" label="LIST_UNSUBSCRIBE_URL">
+              <Input placeholder="https://example.com/unsubscribe/{eventId}" />
+            </Form.Item>
+            <Form.Item name="reportAbuseTo" label="REPORT_ABUSE_TO">
+              <Input placeholder="abuse@example.com" />
+            </Form.Item>
+            <Form.Item name="csaComplaintsTo" label="CSA_COMPLAINTS_TO">
+              <Input placeholder="csa-complaints@example.com" />
+            </Form.Item>
+            <Form.Item name="bounceAddress" label="BOUNCE_ADDRESS">
+              <Input placeholder="bounce@example.com" />
+            </Form.Item>
           </div>
           </div>
           <Form.Item name="sendRequiresVerified" label="SEND_REQUIRES_VERIFIED" valuePropName="checked">
           <Form.Item name="sendRequiresVerified" label="SEND_REQUIRES_VERIFIED" valuePropName="checked">
             <Switch />
             <Switch />
@@ -111,6 +126,15 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
           <Form.Item name="engagementTrackingEnabled" label={t('settings.engagementTracking')} valuePropName="checked">
           <Form.Item name="engagementTrackingEnabled" label={t('settings.engagementTracking')} valuePropName="checked">
             <Switch />
             <Switch />
           </Form.Item>
           </Form.Item>
+          <Form.Item name="listUnsubscribePostEnabled" label="LIST_UNSUBSCRIBE_POST_ENABLED" valuePropName="checked">
+            <Switch />
+          </Form.Item>
+          <Form.Item name="feedbackIdEnabled" label="FEEDBACK_ID_ENABLED" valuePropName="checked">
+            <Switch />
+          </Form.Item>
+          <Form.Item name="bounceEnvelopeEnabled" label="BOUNCE_ENVELOPE_ENABLED" valuePropName="checked">
+            <Switch />
+          </Form.Item>
           <Button type="primary" htmlType="submit" loading={loading}>
           <Button type="primary" htmlType="submit" loading={loading}>
             {t('settings.save')}
             {t('settings.save')}
           </Button>
           </Button>

+ 69 - 5
src/server.js

@@ -90,10 +90,13 @@ import { assertSafeWebhookUrl, startWebhookWorker } from './webhook-dispatcher.j
 import { buildDnsGuide, buildSystemDnsChecks } from './dns-guide.js';
 import { buildDnsGuide, buildSystemDnsChecks } from './dns-guide.js';
 import { createDkimKeyPair } from './dkim.js';
 import { createDkimKeyPair } from './dkim.js';
 import {
 import {
+  buildDeliverabilityHeaders,
   buildMessage,
   buildMessage,
+  createFeedbackId,
   domainFromAddress,
   domainFromAddress,
   extractAddress,
   extractAddress,
   parseAddressList,
   parseAddressList,
+  resolveEnvelopeSender,
   sendViaSmtp,
   sendViaSmtp,
   signMessageForDomain
   signMessageForDomain
 } from './mailer.js';
 } from './mailer.js';
@@ -175,7 +178,17 @@ const defaultSettings = {
   dmarcRua: process.env.DMARC_RUA || '',
   dmarcRua: process.env.DMARC_RUA || '',
   sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false',
   sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false',
   engagementTrackingEnabled:
   engagementTrackingEnabled:
-    String(process.env.ENGAGEMENT_TRACKING_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false'
+    String(process.env.ENGAGEMENT_TRACKING_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false',
+  listUnsubscribeMailto: process.env.LIST_UNSUBSCRIBE_MAILTO || '',
+  listUnsubscribeUrl: process.env.LIST_UNSUBSCRIBE_URL || '',
+  listUnsubscribePostEnabled:
+    String(process.env.LIST_UNSUBSCRIBE_POST_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false',
+  feedbackIdEnabled: String(process.env.FEEDBACK_ID_ENABLED || 'true').toLowerCase() === 'false' ? 'false' : 'true',
+  reportAbuseTo: process.env.REPORT_ABUSE_TO || '',
+  csaComplaintsTo: process.env.CSA_COMPLAINTS_TO || '',
+  bounceAddress: process.env.BOUNCE_ADDRESS || '',
+  bounceEnvelopeEnabled:
+    String(process.env.BOUNCE_ENVELOPE_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false'
 };
 };
 
 
 const emailVerificationPurpose = 'email_verification';
 const emailVerificationPurpose = 'email_verification';
@@ -285,6 +298,12 @@ startSubmissionServer({
       appBaseUrl: settings.appBaseUrl,
       appBaseUrl: settings.appBaseUrl,
       secret: envConfig.trackingSecret
       secret: envConfig.trackingSecret
     };
     };
+  },
+  getDeliverabilitySettings() {
+    return {
+      ...runtimeSettings(),
+      secret: envConfig.trackingSecret
+    };
   }
   }
 });
 });
 
 
@@ -1106,7 +1125,30 @@ async function sendMailFromBody(body, user) {
       subject,
       subject,
       text: body.text || '',
       text: body.text || '',
       html,
       html,
-      baseUrl: settings.appBaseUrl
+      baseUrl: settings.appBaseUrl,
+      headers: buildDeliverabilityHeaders({
+        from,
+        listUnsubscribeMailto: settings.listUnsubscribeMailto,
+        listUnsubscribeUrl: settings.listUnsubscribeUrl,
+        listUnsubscribePostEnabled: settings.listUnsubscribePostEnabled,
+        feedbackId: settings.feedbackIdEnabled
+          ? createFeedbackId({
+              userId: user.id,
+              domainId: domain.id,
+              eventId,
+              secret: envConfig.trackingSecret
+            })
+          : '',
+        reportAbuseTo: settings.reportAbuseTo,
+        csaComplaintsTo: settings.csaComplaintsTo,
+        context: {
+          eventId,
+          userId: user.id,
+          domain: fromDomain,
+          sender: from,
+          recipient: recipients.length === 1 ? recipients[0] : ''
+        }
+      })
     });
     });
     const signed = signMessageForDomain(rawMessage, domain);
     const signed = signMessageForDomain(rawMessage, domain);
     const smtpResult = await sendViaSmtp({
     const smtpResult = await sendViaSmtp({
@@ -1116,7 +1158,7 @@ async function sendMailFromBody(body, user) {
       username: smtpTransport.username,
       username: smtpTransport.username,
       password: smtpTransport.password,
       password: smtpTransport.password,
       helo: smtpTransport.helo,
       helo: smtpTransport.helo,
-      mailFrom: from,
+      mailFrom: resolveEnvelopeSender(settings, from),
       recipients,
       recipients,
       rawMessage: signed
       rawMessage: signed
     });
     });
@@ -1513,7 +1555,15 @@ function runtimeSettings() {
     dmarcPolicy: normalizeDmarcPolicy(settings.dmarcPolicy),
     dmarcPolicy: normalizeDmarcPolicy(settings.dmarcPolicy),
     dmarcRua: settings.dmarcRua,
     dmarcRua: settings.dmarcRua,
     sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true',
     sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true',
-    engagementTrackingEnabled: String(settings.engagementTrackingEnabled).toLowerCase() === 'true'
+    engagementTrackingEnabled: String(settings.engagementTrackingEnabled).toLowerCase() === 'true',
+    listUnsubscribeMailto: settings.listUnsubscribeMailto,
+    listUnsubscribeUrl: settings.listUnsubscribeUrl,
+    listUnsubscribePostEnabled: String(settings.listUnsubscribePostEnabled).toLowerCase() === 'true',
+    feedbackIdEnabled: String(settings.feedbackIdEnabled).toLowerCase() !== 'false',
+    reportAbuseTo: settings.reportAbuseTo,
+    csaComplaintsTo: settings.csaComplaintsTo,
+    bounceAddress: settings.bounceAddress,
+    bounceEnvelopeEnabled: String(settings.bounceEnvelopeEnabled).toLowerCase() === 'true'
   };
   };
 }
 }
 
 
@@ -1775,7 +1825,12 @@ function settingsPatchFromBody(body) {
     'mailHostname',
     'mailHostname',
     'sendingIp',
     'sendingIp',
     'defaultSpfMechanisms',
     'defaultSpfMechanisms',
-    'dmarcRua'
+    'dmarcRua',
+    'listUnsubscribeMailto',
+    'listUnsubscribeUrl',
+    'reportAbuseTo',
+    'csaComplaintsTo',
+    'bounceAddress'
   ]) {
   ]) {
     if (Object.hasOwn(body, key)) patch[key] = body[key];
     if (Object.hasOwn(body, key)) patch[key] = body[key];
   }
   }
@@ -1784,6 +1839,15 @@ function settingsPatchFromBody(body) {
   if (Object.hasOwn(body, 'engagementTrackingEnabled')) {
   if (Object.hasOwn(body, 'engagementTrackingEnabled')) {
     patch.engagementTrackingEnabled = boolString(body.engagementTrackingEnabled);
     patch.engagementTrackingEnabled = boolString(body.engagementTrackingEnabled);
   }
   }
+  if (Object.hasOwn(body, 'listUnsubscribePostEnabled')) {
+    patch.listUnsubscribePostEnabled = boolString(body.listUnsubscribePostEnabled);
+  }
+  if (Object.hasOwn(body, 'feedbackIdEnabled')) {
+    patch.feedbackIdEnabled = boolString(body.feedbackIdEnabled);
+  }
+  if (Object.hasOwn(body, 'bounceEnvelopeEnabled')) {
+    patch.bounceEnvelopeEnabled = boolString(body.bounceEnvelopeEnabled);
+  }
   return patch;
   return patch;
 }
 }
 
 

+ 29 - 1
src/submission.js

@@ -13,8 +13,12 @@ import {
 } from './db.js';
 } from './db.js';
 import { parseInboundMessage } from './inbound-mail.js';
 import { parseInboundMessage } from './inbound-mail.js';
 import {
 import {
+  addHeadersToRawMessage,
+  buildDeliverabilityHeaders,
+  createFeedbackId,
   domainFromAddress,
   domainFromAddress,
   extractAddress,
   extractAddress,
+  resolveEnvelopeSender,
   sendViaSmtp,
   sendViaSmtp,
   signMessageForDomain
   signMessageForDomain
 } from './mailer.js';
 } from './mailer.js';
@@ -475,6 +479,30 @@ class SubmissionSession {
         trackingClicks = requestedClicks && result.linkCount > 0;
         trackingClicks = requestedClicks && result.linkCount > 0;
       }
       }
       preparedMessage = await stripRawMimeHeaders(preparedMessage, ['x-mailhub-track']);
       preparedMessage = await stripRawMimeHeaders(preparedMessage, ['x-mailhub-track']);
+      const deliverabilitySettings = this.config.getDeliverabilitySettings?.() || {};
+      preparedMessage = addHeadersToRawMessage(preparedMessage, buildDeliverabilityHeaders({
+        from: sender || this.mailFrom,
+        listUnsubscribeMailto: deliverabilitySettings.listUnsubscribeMailto,
+        listUnsubscribeUrl: deliverabilitySettings.listUnsubscribeUrl,
+        listUnsubscribePostEnabled: deliverabilitySettings.listUnsubscribePostEnabled,
+        feedbackId: deliverabilitySettings.feedbackIdEnabled
+          ? createFeedbackId({
+              userId: this.user.id,
+              domainId: domain.id,
+              eventId,
+              secret: deliverabilitySettings.secret
+            })
+          : '',
+        reportAbuseTo: deliverabilitySettings.reportAbuseTo,
+        csaComplaintsTo: deliverabilitySettings.csaComplaintsTo,
+        context: {
+          eventId,
+          userId: this.user.id,
+          domain: domainName,
+          sender: sender || this.mailFrom,
+          recipient: this.recipients.length === 1 ? this.recipients[0] : ''
+        }
+      }));
       const signed = signMessageForDomain(preparedMessage, domain);
       const signed = signMessageForDomain(preparedMessage, domain);
       const smtpResult = await sendViaSmtp({
       const smtpResult = await sendViaSmtp({
         host: this.config.relayHost,
         host: this.config.relayHost,
@@ -483,7 +511,7 @@ class SubmissionSession {
         username: this.config.relayUsername,
         username: this.config.relayUsername,
         password: this.config.relayPassword,
         password: this.config.relayPassword,
         helo: this.config.relayHelo,
         helo: this.config.relayHelo,
-        mailFrom: this.mailFrom,
+        mailFrom: resolveEnvelopeSender(deliverabilitySettings, this.mailFrom),
         recipients: this.recipients,
         recipients: this.recipients,
         rawMessage: signed
         rawMessage: signed
       });
       });

+ 38 - 1
test/dkim.test.js

@@ -24,6 +24,42 @@ test('signs messages with a verifiable DKIM relaxed body hash', () => {
   assert.equal(verification.signatureValid, true);
   assert.equal(verification.signatureValid, true);
 });
 });
 
 
+test('signs deliverability headers and includes DKIM identity metadata', () => {
+  const keys = createDkimKeyPair();
+  const raw = buildMessage({
+    from: 'noreply@example.com',
+    to: 'user@example.net',
+    subject: 'Deliverability headers',
+    text: 'Hello',
+    baseUrl: 'https://mailhub.test',
+    headers: [
+      ['List-Unsubscribe', '<mailto:unsubscribe@example.com>, <https://example.com/unsubscribe/123>'],
+      ['List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'],
+      ['Feedback-Id', 'mh.u123.d456.e789:MailHub'],
+      ['X-Report-Abuse-To', 'abuse@example.com']
+    ]
+  });
+  const signed = signMessageForDomain(raw, {
+    domain: 'example.com',
+    selector: 'mh202607',
+    dkimPrivate: keys.privateKey
+  });
+
+  const verification = verifyDkimSignature(signed, keys.publicKey);
+  const tags = verification.tags;
+  const signedHeaders = tags.h.split(':');
+
+  assert.equal(verification.bodyHashValid, true);
+  assert.equal(verification.signatureValid, true);
+  assert.equal(tags.i, '@example.com');
+  assert.equal(tags.q, 'dns/txt');
+  assert.match(tags.t, /^\d+$/);
+  assert.ok(signedHeaders.includes('list-unsubscribe'));
+  assert.ok(signedHeaders.includes('list-unsubscribe-post'));
+  assert.ok(signedHeaders.includes('feedback-id'));
+  assert.ok(signedHeaders.includes('x-report-abuse-to'));
+});
+
 function verifyDkimSignature(rawMessage, publicKey) {
 function verifyDkimSignature(rawMessage, publicKey) {
   const separator = rawMessage.indexOf('\r\n\r\n');
   const separator = rawMessage.indexOf('\r\n\r\n');
   const headers = parseHeaders(rawMessage.slice(0, separator));
   const headers = parseHeaders(rawMessage.slice(0, separator));
@@ -47,7 +83,8 @@ function verifyDkimSignature(rawMessage, publicKey) {
     .verify(dkimPublicPem(publicKey), tags.b, 'base64');
     .verify(dkimPublicPem(publicKey), tags.b, 'base64');
   return {
   return {
     bodyHashValid: bodyHash === tags.bh,
     bodyHashValid: bodyHash === tags.bh,
-    signatureValid
+    signatureValid,
+    tags
   };
   };
 }
 }
 
 

+ 107 - 0
test/mailer-deliverability.test.js

@@ -0,0 +1,107 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+  buildDeliverabilityHeaders,
+  buildMessage,
+  createFeedbackId
+} from '../src/mailer.js';
+
+test('builds optional deliverability headers without unsafe header injection', () => {
+  const headers = buildDeliverabilityHeaders({
+    from: 'sender@example.com',
+    listUnsubscribeMailto: 'unsubscribe@example.com',
+    listUnsubscribeUrl: 'https://example.com/unsubscribe/{eventId}?r={recipient}',
+    listUnsubscribePostEnabled: true,
+    feedbackId: 'mh.u1.d2.e3:MailHub',
+    reportAbuseTo: 'abuse@example.com\r\nX-Bad: yes',
+    csaComplaintsTo: 'csa@example.com',
+    context: {
+      eventId: 42,
+      recipient: 'reader@example.net'
+    }
+  });
+
+  assert.deepEqual(headers, [
+    ['List-Unsubscribe', '<mailto:unsubscribe@example.com>, <https://example.com/unsubscribe/42?r=reader%40example.net>'],
+    ['List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'],
+    ['Feedback-Id', 'mh.u1.d2.e3:MailHub'],
+    ['X-Report-Abuse-To', 'abuse@example.com X-Bad: yes'],
+    ['X-CSA-Complaints', 'csa@example.com'],
+    ['X-Sender', 'sender@example.com']
+  ]);
+
+  const raw = buildMessage({
+    from: 'sender@example.com',
+    to: 'reader@example.net',
+    subject: 'Deliverability',
+    text: 'hello',
+    headers
+  });
+
+  assert.match(raw, /^List-Unsubscribe: <mailto:unsubscribe@example\.com>,\r\n <https:\/\/example\.com\/unsubscribe\/42\?r=reader%40example\.net>$/m);
+  assert.match(raw, /^List-Unsubscribe-Post: List-Unsubscribe=One-Click$/m);
+  assert.match(raw, /^Feedback-Id: mh\.u1\.d2\.e3:MailHub$/m);
+  assert.match(raw, /^X-Report-Abuse-To: abuse@example\.com X-Bad: yes$/m);
+  assert.doesNotMatch(raw, /^X-Bad:/m);
+});
+
+test('folds long deliverability headers and only adds one-click for HTTPS URLs', () => {
+  const headers = buildDeliverabilityHeaders({
+    from: 'sender@example.com',
+    listUnsubscribeMailto: 'unsubscribe@example.com',
+    listUnsubscribeUrl: 'http://example.com/unsubscribe/{eventId}',
+    listUnsubscribePostEnabled: true,
+    context: { eventId: 42 }
+  });
+
+  assert.deepEqual(headers, [
+    ['List-Unsubscribe', '<mailto:unsubscribe@example.com>, <http://example.com/unsubscribe/42>'],
+    ['X-Sender', 'sender@example.com']
+  ]);
+
+  const longHeaders = buildDeliverabilityHeaders({
+    from: 'sender@example.com',
+    listUnsubscribeMailto: 'unsubscribe@example.com',
+    listUnsubscribeUrl: `https://example.com/u/${'x'.repeat(35)}`,
+    listUnsubscribePostEnabled: true
+  });
+  const raw = buildMessage({
+    from: 'sender@example.com',
+    to: 'reader@example.net',
+    subject: 'Long header',
+    text: 'hello',
+    headers: longHeaders
+  });
+
+  assert.match(raw, /^List-Unsubscribe: <mailto:unsubscribe@example\.com>,\r\n <https:\/\/example\.com\/u\/x+/m);
+  assert.match(raw, /\r\n <https:\/\/example\.com\/u\/x+>\r\nList-Unsubscribe-Post:/);
+  for (const line of raw.split('\r\n')) {
+    if (/^(List-Unsubscribe:| )/.test(line)) assert.ok(line.length <= 78, line);
+  }
+});
+
+test('creates stable opaque Feedback-Id values', () => {
+  const first = createFeedbackId({
+    userId: 7,
+    domainId: 9,
+    eventId: 11,
+    secret: 'feedback-secret'
+  });
+  const second = createFeedbackId({
+    userId: 7,
+    domainId: 9,
+    eventId: 11,
+    secret: 'feedback-secret'
+  });
+  const different = createFeedbackId({
+    userId: 7,
+    domainId: 9,
+    eventId: 12,
+    secret: 'feedback-secret'
+  });
+
+  assert.equal(first, second);
+  assert.notEqual(first, different);
+  assert.match(first, /^mh\.[a-f0-9]{12}\.[a-f0-9]{12}\.[a-f0-9]{12}:MailHub$/);
+});

+ 100 - 0
test/server-send-tracking.test.js

@@ -99,6 +99,103 @@ test('instruments tracked API HTML before DKIM and supports per-send opt-out', a
   }
   }
 });
 });
 
 
+test('API sends configured deliverability headers without changing the default envelope sender', async () => {
+  const relay = await startFakeSmtpServer();
+  const port = await freePort();
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-send-headers-'));
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      APP_BASE_URL: `http://127.0.0.1:${port}`,
+      ADMIN_PASSWORD: 'password123',
+      SESSION_SECRET: 'session-secret',
+      TRACKING_SECRET: 'tracking-secret',
+      SMTP_HOST: '127.0.0.1',
+      SMTP_PORT: String(relay.port),
+      SMTP_HELO: 'mail.headers.example',
+      SEND_REQUIRES_VERIFIED: 'false',
+      SUBMISSION_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: '0',
+      DNS_AUTO_CHECK_ENABLED: 'false',
+      DELIVERY_TRACKING_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+
+  try {
+    await waitForOutput(child, 'MailHub listening');
+    const baseUrl = `http://127.0.0.1:${port}`;
+    const cookie = await login(baseUrl);
+    const domainResponse = await fetch(`${baseUrl}/api/domains`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: cookie },
+      body: JSON.stringify({ domain: 'headers.example' })
+    });
+    assert.equal(domainResponse.status, 201);
+
+    const settings = await fetch(`${baseUrl}/api/admin/settings`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json', Cookie: cookie },
+      body: JSON.stringify({
+        listUnsubscribeMailto: 'unsubscribe@headers.example',
+        listUnsubscribeUrl: 'https://headers.example/unsubscribe/{eventId}/{recipient}',
+        listUnsubscribePostEnabled: true,
+        feedbackIdEnabled: true,
+        reportAbuseTo: 'abuse@headers.example',
+        csaComplaintsTo: 'csa@headers.example',
+        bounceAddress: 'bounce@headers.example',
+        bounceEnvelopeEnabled: false
+      })
+    });
+    assert.equal(settings.status, 200);
+    const settingsPayload = await settings.json();
+    assert.equal(settingsPayload.settings.listUnsubscribePostEnabled, true);
+    assert.equal(settingsPayload.settings.bounceEnvelopeEnabled, false);
+
+    await sendHtml(baseUrl, cookie, {
+      from: 'noreply@headers.example',
+      to: 'reader@example.net',
+      subject: 'Headers',
+      html: '<p>Hello</p>'
+    });
+
+    await waitFor(() => relay.messages.length === 1);
+    const rawMessage = relay.messages[0];
+    assert.match(rawMessage, /^List-Unsubscribe: <mailto:unsubscribe@headers\.example>,\n <https:\/\/headers\.example\/unsubscribe\/\d+\/reader%40example\.net>$/m);
+    assert.match(rawMessage, /^List-Unsubscribe-Post: List-Unsubscribe=One-Click$/m);
+    assert.match(rawMessage, /^Feedback-Id: mh\.[a-f0-9]{12}\.[a-f0-9]{12}\.[a-f0-9]{12}:MailHub$/m);
+    assert.match(rawMessage, /^X-Report-Abuse-To: abuse@headers\.example$/m);
+    assert.match(rawMessage, /^X-CSA-Complaints: csa@headers\.example$/m);
+    assert.match(rawMessage, /^X-Sender: noreply@headers\.example$/m);
+    assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<noreply@headers.example>'));
+    assert.ok(!relay.commands.some((command) => command === 'MAIL FROM:<bounce@headers.example>'));
+
+    const bounceSettings = await fetch(`${baseUrl}/api/admin/settings`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json', Cookie: cookie },
+      body: JSON.stringify({ bounceEnvelopeEnabled: true })
+    });
+    assert.equal(bounceSettings.status, 200);
+
+    await sendHtml(baseUrl, cookie, {
+      from: 'noreply@headers.example',
+      to: 'reader@example.net',
+      subject: 'Bounce envelope',
+      html: '<p>Hello again</p>'
+    });
+
+    await waitFor(() => relay.messages.length === 2);
+    assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<bounce@headers.example>'));
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+    await relay.close();
+  }
+});
+
 async function sendHtml(baseUrl, cookie, data) {
 async function sendHtml(baseUrl, cookie, data) {
   const response = await fetch(`${baseUrl}/api/send`, {
   const response = await fetch(`${baseUrl}/api/send`, {
     method: 'POST',
     method: 'POST',
@@ -131,6 +228,7 @@ function decodeHtmlPart(rawMessage) {
 }
 }
 
 
 function startFakeSmtpServer() {
 function startFakeSmtpServer() {
+  const commands = [];
   const messages = [];
   const messages = [];
   const server = net.createServer((socket) => {
   const server = net.createServer((socket) => {
     socket.setEncoding('utf8');
     socket.setEncoding('utf8');
@@ -155,6 +253,7 @@ function startFakeSmtpServer() {
           }
           }
           continue;
           continue;
         }
         }
+        commands.push(line);
         if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
         if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
         else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
         else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
         else if (line === 'DATA') {
         else if (line === 'DATA') {
@@ -171,6 +270,7 @@ function startFakeSmtpServer() {
     server.once('error', reject);
     server.once('error', reject);
     server.listen(0, '127.0.0.1', () => resolve({
     server.listen(0, '127.0.0.1', () => resolve({
       port: server.address().port,
       port: server.address().port,
+      commands,
       messages,
       messages,
       close: () => new Promise((closeResolve) => server.close(closeResolve))
       close: () => new Promise((closeResolve) => server.close(closeResolve))
     }));
     }));

+ 91 - 0
test/submission-tracking.test.js

@@ -98,7 +98,96 @@ test('SMTP submission instruments HTML before DKIM and relays one tracked messag
   }
   }
 });
 });
 
 
+test('SMTP submission adds deliverability headers before DKIM and honors bounce envelope opt-in', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-headers-')), 'session-secret');
+  const user = createUser({ username: 'submission-headers-user', email: 'submission-headers-user@example.com', password: 'password123' });
+  saveSmtpCredential(user.id, { username: 'smtp-headers-user', password: 'smtp-password' });
+  const keys = createDkimKeyPair();
+  createDomain(user.id, {
+    domain: 'submission-headers.example',
+    selector: 'mh202607',
+    verificationToken: 'verify',
+    dkimPublic: keys.publicKey,
+    dkimPrivate: keys.privateKey,
+    senderHost: 'mail.submission-headers.example',
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const relay = await startFakeSmtpServer();
+  const [submission] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'submission-headers.example',
+    allowInsecureAuth: true,
+    relayHost: '127.0.0.1',
+    relayPort: relay.port,
+    relaySecure: false,
+    relayUsername: '',
+    relayPassword: '',
+    relayHelo: 'mail.submission-headers.example',
+    getTrackingSettings: () => ({
+      enabled: false,
+      appBaseUrl: 'https://mail.example.com',
+      secret: 'tracking-secret'
+    }),
+    getDeliverabilitySettings: () => ({
+      listUnsubscribeMailto: 'unsubscribe@submission-headers.example',
+      listUnsubscribeUrl: 'https://submission-headers.example/unsubscribe/{eventId}/{recipient}',
+      listUnsubscribePostEnabled: true,
+      feedbackIdEnabled: true,
+      reportAbuseTo: 'abuse@submission-headers.example',
+      csaComplaintsTo: 'csa@submission-headers.example',
+      bounceAddress: 'bounce@submission-headers.example',
+      bounceEnvelopeEnabled: true,
+      secret: 'deliverability-secret'
+    })
+  });
+  await waitForListening(submission);
+
+  try {
+    const rawMessage = [
+      'From: noreply@submission-headers.example',
+      'To: reader@example.net',
+      'Subject: Submission headers',
+      'MIME-Version: 1.0',
+      'Content-Type: text/plain; charset=UTF-8',
+      '',
+      'Hello',
+      ''
+    ].join('\r\n');
+    const response = await sendViaSmtp({
+      host: '127.0.0.1',
+      port: submission.address().port,
+      secure: false,
+      username: 'smtp-headers-user',
+      password: 'smtp-password',
+      helo: 'client.example',
+      mailFrom: 'noreply@submission-headers.example',
+      recipients: ['reader@example.net'],
+      rawMessage
+    });
+    assert.match(response.message, /Message queued/i);
+    await waitFor(() => relay.messages.length === 1);
+
+    const relayed = relay.messages[0];
+    assert.match(relayed, /^DKIM-Signature:[\s\S]*h=from:to:subject:date:message-id:mime-version:content-type:list-unsubscribe:list-unsubscribe-post:feedback-id:x-report-abuse-to:x-csa-complaints:x-sender/im);
+    assert.match(relayed, /^List-Unsubscribe: <mailto:unsubscribe@submission-headers\.example>,\n <https:\/\/submission-headers\.example\/unsubscribe\/\d+\/reader%40example\.net>$/m);
+    assert.match(relayed, /^List-Unsubscribe-Post: List-Unsubscribe=One-Click$/m);
+    assert.match(relayed, /^Feedback-Id: mh\.[a-f0-9]{12}\.[a-f0-9]{12}\.[a-f0-9]{12}:MailHub$/m);
+    assert.match(relayed, /^X-Report-Abuse-To: abuse@submission-headers\.example$/m);
+    assert.match(relayed, /^X-CSA-Complaints: csa@submission-headers\.example$/m);
+    assert.match(relayed, /^X-Sender: noreply@submission-headers\.example$/m);
+    assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<bounce@submission-headers.example>'));
+  } finally {
+    await closeServer(submission);
+    await relay.close();
+  }
+});
+
 function startFakeSmtpServer() {
 function startFakeSmtpServer() {
+  const commands = [];
   const messages = [];
   const messages = [];
   const server = net.createServer((socket) => {
   const server = net.createServer((socket) => {
     socket.setEncoding('utf8');
     socket.setEncoding('utf8');
@@ -123,6 +212,7 @@ function startFakeSmtpServer() {
           }
           }
           continue;
           continue;
         }
         }
+        commands.push(line);
         if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
         if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
         else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
         else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
         else if (line === 'DATA') {
         else if (line === 'DATA') {
@@ -139,6 +229,7 @@ function startFakeSmtpServer() {
     server.once('error', reject);
     server.once('error', reject);
     server.listen(0, '127.0.0.1', () => resolve({
     server.listen(0, '127.0.0.1', () => resolve({
       port: server.address().port,
       port: server.address().port,
+      commands,
       messages,
       messages,
       close: () => closeServer(server)
       close: () => closeServer(server)
     }));
     }));

Някои файлове не бяха показани, защото твърде много файлове са промени