From 11c428d4ffe7c8aa87aac4d24148aaa95e01507a Mon Sep 17 00:00:00 2001 From: Matthias Endler Date: Thu, 30 Apr 2026 15:20:06 +0200 Subject: [PATCH] fix(ses): emit List-Unsubscribe inside the header section, not the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/services/SESService.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/api/src/services/SESService.ts b/apps/api/src/services/SESService.ts index d96cd73..2a8eacd 100644 --- a/apps/api/src/services/SESService.ts +++ b/apps/api/src/services/SESService.ts @@ -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} `;