← 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
M src/pr/services/email-providers.js
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 →