[object Object]

← back to Rentv 2026

pr-intelligence: add CAN-SPAM List-Unsubscribe header + address footer to buildMime (TK-10578)

d5a4141ef8c4094f3dd82efaa980c243cb2028d2 · 2026-08-15 04:06:21 -0700 · Steve Abrams

Before: buildMime emitted only From/To/Subject/MIME headers — no List-Unsubscribe
header, no physical address footer injection. Sufficient for 1:1 outreach but
non-compliant for bulk/programmatic sends.

After: buildMime injects List-Unsubscribe + List-Unsubscribe-Post (RFC 8058 one-click)
headers and physical address + opt-out footer into text/HTML bodies when
PR_MAILING_ADDRESS / PR_UNSUBSCRIBE_URL env vars are set. Also adds PR_FROM_NAME for
"Display Name <addr>" From header formatting. Backwards-compatible: env vars default
to '' so 1:1 outreach path is unchanged if not configured.

Co-Authored-By: 4am-fix-loop <noreply@anthropic.com>

Files touched

Diff

commit d5a4141ef8c4094f3dd82efaa980c243cb2028d2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 15 04:06:21 2026 -0700

    pr-intelligence: add CAN-SPAM List-Unsubscribe header + address footer to buildMime (TK-10578)
    
    Before: buildMime emitted only From/To/Subject/MIME headers — no List-Unsubscribe
    header, no physical address footer injection. Sufficient for 1:1 outreach but
    non-compliant for bulk/programmatic sends.
    
    After: buildMime injects List-Unsubscribe + List-Unsubscribe-Post (RFC 8058 one-click)
    headers and physical address + opt-out footer into text/HTML bodies when
    PR_MAILING_ADDRESS / PR_UNSUBSCRIBE_URL env vars are set. Also adds PR_FROM_NAME for
    "Display Name <addr>" From header formatting. Backwards-compatible: env vars default
    to '' so 1:1 outreach path is unchanged if not configured.
    
    Co-Authored-By: 4am-fix-loop <noreply@anthropic.com>
---
 src/pr/services/email-providers.js | 34 +++++++++++++++++++++++++++++++---
 1 file changed, 31 insertions(+), 3 deletions(-)

diff --git a/src/pr/services/email-providers.js b/src/pr/services/email-providers.js
index e5881eb8..fca9fadc 100644
--- a/src/pr/services/email-providers.js
+++ b/src/pr/services/email-providers.js
@@ -19,13 +19,41 @@ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
 
 function b64url(s) { return Buffer.from(s).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
 
+// CAN-SPAM §7704(a)(5)(A): physical mailing address required in every commercial email.
+// Configure PR_MAILING_ADDRESS and PR_UNSUBSCRIBE_URL env vars for production use.
+// PR_FROM_NAME: human-readable sender name (displayed in From: header).
+const MAILING_ADDRESS = process.env.PR_MAILING_ADDRESS || '';
+const UNSUB_URL = process.env.PR_UNSUBSCRIBE_URL || '';
+const FROM_NAME = process.env.PR_FROM_NAME || '';
+
 function buildMime({ from, to, subject, text, html }) {
   const boundary = 'b' + Date.now().toString(36);
+  const fromHeader = FROM_NAME ? `"${FROM_NAME}" <${from}>` : from;
+
+  // CAN-SPAM headers: List-Unsubscribe (RFC 2369) + one-click (RFC 8058)
+  const unsubHeaders = [];
+  if (UNSUB_URL) {
+    const mailto = `mailto:${from}?subject=unsubscribe`;
+    unsubHeaders.push(`List-Unsubscribe: <${UNSUB_URL}>, <${mailto}>`);
+    unsubHeaders.push('List-Unsubscribe-Post: List-Unsubscribe=One-Click');
+  }
+
+  // Physical address + opt-out footer injected into body (CAN-SPAM §7704(a)(5)(A))
+  let txtBody = text || '';
+  let htmlBody = html || `<pre>${text || ''}</pre>`;
+  if (MAILING_ADDRESS || UNSUB_URL) {
+    const footerTxt = `\n\n---\n${MAILING_ADDRESS ? MAILING_ADDRESS + '\n' : ''}${UNSUB_URL ? 'To opt out: ' + UNSUB_URL : ''}`;
+    const footerHtml = `<div style="margin-top:32px;border-top:1px solid #eee;padding-top:8px;font-size:11px;color:#999;">${MAILING_ADDRESS ? `<div>${MAILING_ADDRESS}</div>` : ''}${UNSUB_URL ? `<div><a href="${UNSUB_URL}">Unsubscribe</a></div>` : ''}</div>`;
+    txtBody += footerTxt;
+    htmlBody = htmlBody.replace(/<\/body>/i, footerHtml + '</body>') || htmlBody + footerHtml;
+  }
+
   return [
-    `From: ${from}`, `To: ${to}`, `Subject: ${subject}`, 'MIME-Version: 1.0',
+    `From: ${fromHeader}`, `To: ${to}`, `Subject: ${subject}`, 'MIME-Version: 1.0',
+    ...unsubHeaders,
     `Content-Type: multipart/alternative; boundary="${boundary}"`, '',
-    `--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', text || '', '',
-    `--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', '', html || `<pre>${text || ''}</pre>`, '',
+    `--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', txtBody, '',
+    `--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', '', htmlBody, '',
     `--${boundary}--`, '',
   ].join('\r\n');
 }

← a415d241 chore: v0.24.1 (session close — ad-slots correctness fixes +  ·  back to Rentv 2026  ·  rentv: P1+P2+P3 public-launch hardening (TK-10564/10575, Ste f2bf7551 →