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.
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:
- CAN-SPAM (US). Every commercial email needs a clear opt-out; the mechanism must keep processing requests for at least 30 days after the send; requests must be honored within 10 business days; and you can't charge a fee, ask for anything beyond an email address, or require any step other than a reply email or a visit to a single web page. Each violating email carries its own civil penalty, adjusted annually. Purely transactional messages are mostly exempt — until marketing content creeps into them. Source: the FTC's CAN-SPAM compliance guide.
- GDPR (EU). Withdrawing consent must be as easy as giving it (Article 7(3)), and once someone objects to direct marketing, the marketing stops (Article 21(2)–(3)). The ePrivacy Directive adds that when you email existing customers under the soft opt-in, every message must offer a free, easy way to object (2002/58/EC, Article 13(2)).
- RFC 8058 one-click. The standard: a
List-Unsubscribeheader carrying an HTTPS URL, plusList-Unsubscribe-Post: List-Unsubscribe=One-Click, where a bare POST to that URL unsubscribes — no confirmation page. - Gmail and Yahoo bulk-sender rules. Not laws — the enforcement is deliverability. Gmail requires one-click unsubscribe on marketing and subscribed mail from bulk senders (close to 5,000 or more messages a day to personal Gmail accounts) and recommends fulfilling requests within 48 hours (sender guidelines, FAQ). Yahoo requires a working one-click
List-Unsubscribefor bulk senders — the RFC 8058 POST highly recommended, mailto acceptable — and unsubscribes honored within 2 days (Yahoo sender best practices).
Read as an engineer, that's a test plan:
unsubscribe-classed link exists in the extracted links — and its URL appears in the plain-text part toofinal_url is the unsubscribe page, not /loginList-Unsubscribe and List-Unsubscribe-Post headers present, and a bare POST to the header URL unsubscribesWhy 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:
- The link rots. A template refactor drops the footer from the plain-text part, or the token route moves and nobody updates the template. Every other email test stays green; the unsubscribe link 404s for months.
- The link works, the suppression doesn't. The click lands on "You've been unsubscribed," the flag is written to a cache or the wrong list, and the next campaign mails them anyway. That failure gets reported to spam buttons and regulators, not to your support inbox.
- The link unsubscribes people who never clicked it. Corporate mail scanners GET every URL in a message before the human sees it. If a bare GET completes the opt-out, security appliances are silently unsubscribing your most enterprise-shaped readers. It's the same prefetch problem that burns verification tokens — here it manufactures opt-outs instead.
- The link hides behind a login. Someone routes the unsubscribe page through the authenticated preferences center. Now opting out needs a password — a step CAN-SPAM doesn't allow, and a dead end for anyone who forgot theirs.
None of these throw an error where you're looking. All of them are assertable.
When this is your test to write
- Your app renders and sends the email — digests, notifications with marketing content, homegrown campaigns: everything on this page is yours to test.
- An ESP owns the footer — hosted campaign tools inject and operate the unsubscribe link themselves, and testing their infrastructure from your suite mostly measures their uptime. Still assert presence on any template you control: the classic regression is a custom template that overwrites the ESP's footer block.
- Transactional-only senders — receipts and password resets are mostly exempt, and the right assertion is the inverse: the transactional guide asserts a receipt carries no unsubscribe link. The day a "product updates" paragraph lands in the receipt template, that test is what notices.
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:
- URLs are de-duplicated across parts. If the HTML anchor and the text part carry the same URL, it appears once. So "an unsubscribe link was found" does not prove the plain-text part has one — assert that separately against
text_body. - Anchor text carries the classification when the URL can't. Click-tracking wrappers turn the URL into an opaque redirect; the HTML anchor "Unsubscribe" still classifies it correctly. A bare tracked URL in the text part has no anchor text, so it classifies as
other.
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
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.)
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:
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:
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
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 templatehtml_body onlyPut the opt-out in both MIME partsother — 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 meanwhileok: 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 forhttp:// or points at a non-public hostA problem+json 400 from the follow endpointServe unsubscribe pages over HTTPS on a public hostfinal_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 wallis_subscribed is false right after follow_linkRender a confirmation on GET; complete on the button's POSTwait_for_message returns instead of timing outPersist suppression where the send path actually reads itEdge cases worth their own test
- Two contracts, two endpoints. The body link renders a confirmation on GET and completes on POST — that survives scanner prefetch and still meets the single-page rule. The RFC 8058 header URL is the opposite: a bare POST unsubscribes immediately, because the client posting is the mailbox provider. Test both; they're different endpoints with opposite contracts.
- Token scope. The token must identify the recipient, not the campaign. Create two inboxes, subscribe both, unsubscribe one, and assert the other still receives — the cheapest test for the "token unsubscribes whoever clicks it last" bug.
- The 30-day window. Links must keep working for at least 30 days after the send. Real sleeps are off the table; if your tokens expire, move the clock through a test hook and assert a 29-day-old link still lands 2xx.
- Tracking wrappers. If your ESP wraps links through a click tracker, the follow reports the final URL after redirects — assert on
final_url, notrequested_url, and the test survives the wrapper. - Parallel workers. Every test above creates its own inbox, so workers can't fish each other's messages out of a shared mailbox — and that's what makes the negative wait mean anything. Silence in a shared inbox proves nothing. The parallel guide covers the isolation model.
- Cleanup. Delete the inbox in teardown — its messages go with it immediately. Otherwise retention follows your plan's window (days, not forever).
What this test does not prove
- Not the landing page's words. The server-side follow reports status and final URL; it never returns the page body. "The page says you've been unsubscribed" needs a browser assertion if you want it.
- Not your ESP's suppression list. The suite proves your endpoints at test time. Whether a hosted ESP's list agrees, and whether every historical send carried the link, are outside it.
- Not compliance. The rules above are the primary sources' headlines turned into assertions — useful tripwires, not legal sign-off.
- Not every template, forever. Link classification is a keyword heuristic. It's the right default; for a template that defeats it, select the link by
urlorindexand send the email to support so the miss becomes a fixture.
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
- Exactly how links are collected and classified: the extraction reference.
- The same GET-renders, POST-commits pattern from the other side: testing email verification, where scanner prefetch burns tokens instead of manufacturing opt-outs.
- Content, links, auth verdicts, and spam score as one bundle: the transactional email testing guide.
- Asserting the rest of the message without matching markup: HTML email testing.