GUIDES / UNSUBSCRIBE

Testing unsubscribe links:
present, working, actually honored.

Nobody clicks the unsubscribe link in staging. It sits in the footer of every commercial email you send, it is the one link a regulator will actually check, and in most suites it has zero coverage — the happy path never touches it, and the person who would notice a broken one is, by definition, leaving. This guide is the missing test: assert the link exists, assert it resolves without a login, assert the opt-out actually sticks, and assert the one-click headers the big mailbox providers now expect from bulk senders.

Quick answer. Send the email to a private test inbox and read its extracted links — each arrives classified as verify, reset, unsubscribe, or other. Assert an unsubscribe-classed link exists, have the API follow it server-side and assert a 2xx that isn't a login page, then complete the opt-out and trigger another send: the pass condition is that nothing arrives. Presence, mechanics, suppression — three assertions, no regex.

NOT LEGAL ADVICE This is an engineer's guide to making unsubscribe testable. The rules below are summarized from the primary sources so you can turn them into assertions — whether your product complies is a question for your lawyer, not your CI pipeline. A green suite proves the mechanism worked at test time; it is not a compliance program.

What the rules ask for, as assertions

The useful thing about email-opt-out rules is that most of them are mechanically checkable. The sources that matter:

Read as an engineer, that's a test plan:

REQUIREMENTASSERTION
An opt-out in every commercial messageAn unsubscribe-classed link exists in the extracted links — and its URL appears in the plain-text part too
The mechanism worksA server-side follow of that link answers 2xx
No login, no fee, a single pageThe follow's final_url is the unsubscribe page, not /login
The request is actually honoredA later send to the same address never arrives (a bounded negative wait)
One-click for bulk sendersList-Unsubscribe and List-Unsubscribe-Post headers present, and a bare POST to the header URL unsubscribes

Why this breaks silently

The unsubscribe path has a specific failure anatomy, and every item on it is invisible to a suite that only tests signup:

None of these throw an error where you're looking. All of them are assertable.

When this is your test to write

How the unsubscribe link is found

The examples below use MailFixture: the test creates a private inbox over the API, your app sends to it through your real mail path (MailFixture only receives — it never sends anything), and every message is run through extractors before your test reads it. Links come from both the HTML part (each anchor, with its text) and the plain-text part (bare URLs), and each gets a class from keywords in the URL and the anchor text. A link classifies as unsubscribe when either contains unsubscribe, opt-out, optout, or list-manage — checked before the verify and reset keywords, because footer URLs often contain words like "confirm." The full rules are in the extraction reference.

Two consequences worth knowing before you write the assertion:

Auth is an mfx_ bearer key read from MAILFIXTURE_API_KEY — keep it in your CI secret store, never in the repo.

Presence and mechanics

unsubscribe.spec.ts · Playwrightnpm i -D mailfixture
import { test, expect } from "@playwright/test";
import { MailFixture } from "mailfixture";

const mfx = new MailFixture(); // reads MAILFIXTURE_API_KEY

test("newsletter carries a working unsubscribe link", async () => {
  const inbox = await mfx.createInbox({ ttlSeconds: 900 });
  try {
    await triggerNewsletter(inbox.emailAddress); // your app's send path
    const msg = await inbox.waitForMessage({ timeout: 30_000 }); // ms

    const unsub = msg.links.find((l) => l.kind === "unsubscribe");
    expect(unsub).toBeDefined();               // opt-out present
    expect(msg.textBody).toContain(unsub!.url);  // …in the text part too

    // Server-side click: the API GETs the link (up to 5 redirects)
    // and reports how the target answered. No body comes back.
    const res = await mfx.followLink(msg.id, { kind: "unsubscribe" });
    expect(res.ok).toBe(true);                     // landed 2xx
    expect(res.finalUrl).not.toContain("/login"); // no extra steps
  } finally {
    await inbox.delete();
  }
});

The waitForMessage timeout is a ceiling, not a sleep — the server holds the request open and returns the moment the message lands. The text-part assertion assumes your template uses the same URL in both parts; if your ESP tracks each part with its own wrapper, assert on a stable substring such as the token path instead.

Suppression: prove the opt-out stuck

The mechanics test proves the link resolves. This one proves it worked — which is what CAN-SPAM's 10 business days, Yahoo's 2 days, and Google's recommended 48 hours are really about — and pins the prefetch-safe design at the same time: a GET renders a confirmation page, the button on it POSTs. (app is your own test helper — subscribe, send, check the flag, submit the confirm form.)

test_unsubscribe.py · pytestpip install mailfixture
import pytest
from mailfixture import MailFixture, MailFixtureTimeout

mfx = MailFixture()  # reads MAILFIXTURE_API_KEY

def test_unsubscribe_is_honored(app):
    inbox = mfx.create_inbox(ttl_seconds=900)
    try:
        app.subscribe(inbox.email_address)
        app.send_newsletter()
        msg = inbox.wait_for_message(timeout=30)  # seconds

        # A GET is what mail scanners do: it must render, not unsubscribe.
        res = mfx.follow_link(msg.id, kind="unsubscribe")
        assert res.ok
        assert app.is_subscribed(inbox.email_address)

        # The human's click: the confirm button on that page POSTs.
        app.confirm_unsubscribe(res.final_url)
        assert not app.is_subscribed(inbox.email_address)

        # Prove silence on the real send path.
        inbox.clear()
        app.send_newsletter()
        with pytest.raises(MailFixtureTimeout):
            inbox.wait_for_message(timeout=15)
    finally:
        inbox.delete()

The pytest.raises(MailFixtureTimeout) is the pass condition: the second send must produce nothing. clear() first matters — the wait helpers start from the inbox's full history, so without it the first newsletter would satisfy the wait. And the honesty caveat every negative wait carries: a timeout proves absence within the window, not forever. Fifteen seconds is generous when the positive half of the same test just watched that send path deliver, but a delayed send could still slip past — which is why the test also asserts the flag through app.is_subscribed. The negative wait covers the integration; your own API covers the state.

Units differ between the SDKs: JS timeouts are milliseconds, Python's are seconds. Pass 30_000 to Python and the wait runs for eight hours.

One-click headers (RFC 8058)

Message details carry the full header block as an ordered array of {name, value} pairs, original casing, folded lines unfolded — so the header assertion is a lookup:

test_one_click.py · pytestpip install mailfixture requests
import re
import requests

def assert_one_click(msg, app, address):
    headers = {h["name"].lower(): h["value"] for h in msg.headers}
    assert headers.get("list-unsubscribe-post") == "List-Unsubscribe=One-Click"
    urls = re.findall(r"<(https://[^>]+)>", headers.get("list-unsubscribe", ""))
    assert urls, "List-Unsubscribe needs an HTTPS URL for one-click"

    # RFC 8058: a bare POST with this body unsubscribes. No confirm page —
    # the client posting here is the mailbox provider, not a human.
    r = requests.post(urls[0], data={"List-Unsubscribe": "One-Click"}, timeout=10)
    assert r.ok
    assert not app.is_subscribed(address)

Two boundaries here. The follow endpoint performs a GET, while RFC 8058's one-click flow is a POST — so the test posts to your own endpoint directly, from the test runner; it's your endpoint, hit it. And the extractors read the message body: a URL that appears only in the List-Unsubscribe header won't show up in links, which is exactly why the header check stands on its own.

No SDK? The same test over REST

The extraction and follow shapes belong to the API, not the SDKs, so any language with an HTTP client can run this:

curlany language, same calls
curl -s https://api.mailfixture.com/v1/messages/$MSG_ID/links \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY"
# → {"links": [{"url": "…", "text": "Unsubscribe", "class": "unsubscribe"}, …]}

curl -s -X POST https://api.mailfixture.com/v1/messages/$MSG_ID/links/follow \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"class": "unsubscribe"}'
# → {"requested_url": "…", "final_url": "…", "status": 200, "redirects": 1, "ok": true}

The wire field is class; both SDKs expose it as kind, because class is a reserved word in too many languages. Request and response details are in the API reference.

Failure modes

SYMPTOM & CAUSEEVIDENCEFIX
No unsubscribe-classed link — the footer was dropped from the templatemsg.links lists the template's other links; the follow call answers 404 naming the classes that do existRestore the footer; keep this assertion on every commercial template
Link found, text-part assertion fails — the plain-text alternative lost its footerThe URL appears in html_body onlyPut the opt-out in both MIME parts
Link present but classed other — neither the URL nor the anchor carries an unsubscribe keyword ("Manage preferences" behind an opaque tracker)The link is in msg.links with kind: "other"Say "unsubscribe" in the anchor text — readers look for the word too. Select by url meanwhile
ok: false with status 404 or 500 — the token route moved or the handler brokestatus and final_url in the follow resultFix the route — this is the regression the test exists for
Follow answers 400 before reaching your app — the link is http:// or points at a non-public hostA problem+json 400 from the follow endpointServe unsubscribe pages over HTTPS on a public host
Follow answers 502 — the target was unreachable, slow, or redirected more than 5 timesThe problem detail names whichCheck the staging host; collapse redirect chains
final_url lands on /login — unsubscribe routed through the authenticated preferences centerThe redirect count and final URL in the follow resultToken-authenticate the unsubscribe page; no login wall
The GET alone unsubscribed the address — the page completes the opt-out on loadis_subscribed is false right after follow_linkRender a confirmation on GET; complete on the button's POST
Suppression test still receives mail — the flag went to a cache, the wrong list, or didn't survive a deployThe second wait_for_message returns instead of timing outPersist suppression where the send path actually reads it

Edge cases worth their own test

What this test does not prove

MailFixture is receive-only: it observes what your app sends and never sends, relays, or forwards anything itself, so the sending half of this loop is entirely yours. Test the email flows of products you run — that's what the inboxes are for.

Next steps

present · working · honored · 100 messages/mo free
Start free Read the quickstart