Every commercial email includes one-click unsubscribe in header and body
Why it matters
CAN-SPAM §5, GDPR Art. 21, and Gmail/Yahoo's 2024 bulk sender requirements all mandate a working one-click unsubscribe path — RFC 8058 specifically requires both the List-Unsubscribe and List-Unsubscribe-Post headers plus a POST endpoint. Missing either header or burying the body link in illegible gray text is a direct regulatory violation that exposes the business to FTC enforcement action and causes Gmail to route bulk sends to spam, tanking deliverability for every user on the list. The business impact compounds: a single CAN-SPAM complaint can trigger an FTC investigation; a spam classification tanks open rates across the entire subscriber base.
Severity rationale
Critical because missing unsubscribe headers violates CAN-SPAM §5, GDPR Art. 21, and 2024 Gmail/Yahoo bulk sender policy simultaneously, risking FTC enforcement and domain-level spam classification that destroys deliverability for all users.
Remediation
Add List-Unsubscribe and List-Unsubscribe-Post headers to every commercial email send call, and implement both GET and POST endpoints at /api/unsubscribe.
// lib/email.ts — add unsubscribe headers to every marketing send
export async function sendMarketingEmail({ to, subject, html, unsubscribeToken }: {
to: string; subject: string; html: string; unsubscribeToken: string
}) {
const unsubUrl = `https://example.com/api/unsubscribe?token=${unsubscribeToken}`
return resend.emails.send({
from: 'Your Product <hello@example.com>',
to, subject, html,
headers: {
'List-Unsubscribe': `<${unsubUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
})
}
The POST handler (RFC 8058 one-click) must suppress without requiring login. Store the unsubscribe token on the subscriber record, not the campaign.
Detection
-
ID:
one-click-unsubscribe -
Severity:
critical -
What to look for: Enumerate every relevant item. Find all locations where marketing or commercial emails are sent. For each send call, inspect whether a
List-Unsubscribeheader is included in the email headers. CAN-SPAM requires an unsubscribe mechanism in the body; Gmail and Yahoo's 2024 bulk sender requirements additionally require the RFC 2369List-Unsubscribeheader with an HTTPS unsubscribe URL. RFC 8058 one-click unsubscribe also requires aList-Unsubscribe-Post: List-Unsubscribe=One-Clickheader and a POST endpoint. Check the email body templates: does every commercial email contain a visible, working unsubscribe link — not buried in tiny gray text, not a link that goes to a page where users must confirm multiple steps? Check that the unsubscribe link is in the footer of every marketing email template. This check does not apply to purely transactional emails (password reset, purchase receipt, account security alerts) — only to commercial/marketing email. -
Pass criteria: At least 1 of the following conditions is met. Every commercial email includes: (1) a
List-Unsubscribeheader with an HTTPS URL, (2) aList-Unsubscribe-Postheader for RFC 8058 one-click compliance, and (3) a visible unsubscribe link in the email body. The unsubscribe link does not require the user to log in and processes the opt-out in a single click or a single confirmation step with no additional persuasion. Before evaluating, extract and quote the relevant configuration or code patterns found. Report the count of items checked even on pass. -
Fail criteria: No
List-Unsubscribeheader in email-sending code. Unsubscribe link is absent from email templates. Unsubscribe requires login or presents a multi-step flow designed to discourage opt-out. -
Do NOT pass when: The item exists only as a placeholder, stub, or TODO comment — partial implementation does not count as passing.
-
Skip (N/A) when: The application sends no commercial or marketing email of any kind — only purely transactional email (password reset, receipts, account notifications with no promotional content).
-
Cross-reference: For related security patterns, the Security Headers audit covers server-side hardening.
-
Detail on fail: Specify what is missing. Example:
"Marketing emails sent via SendGrid with no List-Unsubscribe header set. No unsubscribe link found in email templates in src/emails/."or"List-Unsubscribe header present but points to a login-gated preferences page. RFC 8058 List-Unsubscribe-Post header absent.". -
Remediation: Add
List-Unsubscribeheaders and a one-click unsubscribe endpoint. Example using the Resend SDK (pattern applies to SendGrid, Postmark, etc.):// lib/email.ts — add unsubscribe headers to every commercial email import { Resend } from 'resend' const resend = new Resend(process.env.RESEND_API_KEY) export async function sendMarketingEmail({ to, subject, html, unsubscribeToken, }: { to: string subject: string html: string unsubscribeToken: string }) { const unsubUrl = `https://example.com/api/unsubscribe?token=${unsubscribeToken}` return resend.emails.send({ from: 'Your Product <hello@example.com>', to, subject, html, headers: { // RFC 2369 — required by Gmail/Yahoo bulk sender policy 'List-Unsubscribe': `<${unsubUrl}>`, // RFC 8058 — enables one-click unsubscribe in Gmail UI 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', }, }) }// app/api/unsubscribe/route.ts — handle both GET (link click) and POST (one-click) export async function GET(req: Request) { const { searchParams } = new URL(req.url) const token = searchParams.get('token') if (!token) return new Response('Invalid token', { status: 400 }) const subscriber = await db.subscriber.findUnique({ where: { unsubToken: token } }) if (!subscriber) return new Response('Not found', { status: 404 }) await db.subscriber.update({ where: { id: subscriber.id }, data: { marketingOptOut: true, optOutAt: new Date() }, }) // Redirect to a confirmation page — no login required return Response.redirect('https://example.com/unsubscribed') } // RFC 8058 one-click — Gmail sends a POST to this same URL export async function POST(req: Request) { const { searchParams } = new URL(req.url) const token = searchParams.get('token') if (!token) return new Response('Invalid token', { status: 400 }) await db.subscriber.updateMany({ where: { unsubToken: token }, data: { marketingOptOut: true, optOutAt: new Date() }, }) return new Response('OK', { status: 200 }) }Also add an unsubscribe link to every email body template:
<!-- In every marketing email footer --> <p style="color: #999; font-size: 12px;"> You're receiving this because you signed up for updates from Example. <a href="{{unsubscribeUrl}}">Unsubscribe</a> at any time. </p>
External references
- external · CAN-SPAM-§5 — CAN-SPAM Act Section 5 — Commercial Email Requirements
- gdpr · Art. 21 — Right to object — withdrawal of consent / opt-out
- external · RFC-8058 — RFC 8058 — One-Click Unsubscribe (List-Unsubscribe-Post)
- eprivacy · Art. 13 — ePrivacy Directive — unsolicited communications opt-out
Taxons
History
- 2026-04-18·v1.0.0·Initial import from email-sms-compliance·automated