fix(ses): emit List-Unsubscribe inside the header section, not the body

The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.

Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.

Collect optional headers (custom + List-Unsubscribe) into an array, filter empties, and append them to the Content-Type line with a single newline separator. No codepath can now produce a blank line inside the header block.
This commit is contained in:
Matthias Endler
2026-04-30 15:20:06 +02:00
parent 2eb407993b
commit 11c428d4ff
+11 -9
View File
@@ -133,21 +133,23 @@ export async function sendRawEmail({
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
}
// Build the additional headers (custom headers + List-Unsubscribe), filtering
// out empties so we never emit a blank line inside the header section.
// Per RFC 5322 §2.1, a blank line terminates the header section, so any blank
// line here would push subsequent headers (notably List-Unsubscribe) into the body.
const extraHeaderLines = [
...(headers ? Object.entries(headers).map(([key, value]) => `${key}: ${value}`) : []),
...(unsubscribeHeader ? [unsubscribeHeader] : []),
];
const extraHeaders = extraHeaderLines.length > 0 ? `\n${extraHeaderLines.join('\n')}` : '';
// Build raw MIME message
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}
${
headers
? Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
: ''
}
${unsubscribeHeader}
Content-Type: ${rootContentType}${extraHeaders}
`;