Which headers, and why
- From and Reply-To. They decide who the user sees and where a reply goes. Both usually come from per-environment config, which is exactly what a template test never reads.
- Message-ID. Google's sender guidelines ask that every message include a valid Message-ID. Assert it, so a sending path that leaves it out is caught.
- List-Unsubscribe and List-Unsubscribe-Post. Since February 1, 2024, Gmail requires marketing and subscribed messages from senders of more than 5,000 messages a day to Gmail to support one-click unsubscribe with these two headers. RFC 8058 says the
List-Unsubscribeheader must contain one HTTPS URI. - The DKIM signature covers them. RFC 8058 also requires a valid DKIM signature that covers both list headers. The signature's
h=tag lists the headers it signs.
What the headers look like when you read them
Inbox APIs usually return headers parsed, not as raw text. Zyntra parses them with mailparser: names are lowercase, addresses become objects, and all List-* headers are merged into one list object. For a newsletter with one-click unsubscribe, the headers field looks like this (shortened):
{
"from": { "value": [{ "address": "[email protected]", "name": "Acme" }], "text": "\"Acme\" <[email protected]>" },
"reply-to": { "value": [{ "address": "[email protected]", "name": "" }], "text": "[email protected]" },
"message-id": "<[email protected]>",
"list": {
"unsubscribe": { "url": "https://acme.example/u/xyz", "mail": "[email protected]?subject=unsub" },
"unsubscribe-post": { "name": "List-Unsubscribe=One-Click" }
},
"dkim-signature": {
"value": "v=1",
"params": { "d": "acme.example", "s": "s1", "h": "from:to:subject:list-unsubscribe:list-unsubscribe-post" }
}
}When an email has two DKIM signatures (yours and your provider's), dkim-signature is an array.
The test
import { test, expect } from "@playwright/test";
type Dkim = { params: { d: string; h: string } };
test("newsletter has one-click unsubscribe", async () => {
test.setTimeout(90_000);
const address = `${process.env.ZYNTRA_TEAM_ID}.news-${Date.now()}@zyntramail.com`;
// Your own helper, e.g. a staging-only endpoint that sends one issue to this address.
// A fresh address means the newest email is the newsletter, nothing older.
await sendNewsletterTo(address);
const res = await fetch(
`https://api.zyntra.app/emails/last?email=${encodeURIComponent(address)}`,
{
headers: { "X-API-Key": process.env.ZYNTRA_API_KEY! },
signal: AbortSignal.timeout(45_000),
},
);
const { headers: h } = await res.json();
expect(h["reply-to"]?.value[0].address).toBe("[email protected]");
expect(h["message-id"]).toMatch(/^<[^@\s]+@[^>\s]+>$/);
const unsubscribe: string = h.list?.unsubscribe?.url;
expect(unsubscribe).toMatch(/^https:\/\//);
expect(h.list?.["unsubscribe-post"]?.name).toBe("List-Unsubscribe=One-Click");
// The signature from your own domain must cover both list headers.
const sigs: Dkim[] = [].concat(h["dkim-signature"] ?? []);
const own = sigs.find((s) => s.params.d === "acme.example");
const signed = own?.params.h.toLowerCase().split(/\s*:\s*/) ?? [];
expect(signed).toEqual(expect.arrayContaining(["list-unsubscribe", "list-unsubscribe-post"]));
});Send the one-click POST yourself
RFC 8058 describes the request a mailbox provider sends: a POST to the HTTPS URI with List-Unsubscribe=One-Click in the body, with no cookies or authorization, and the sender must not answer with a redirect. Test your endpoint the same way:
const form = new FormData();
form.append("List-Unsubscribe", "One-Click");
const res = await fetch(unsubscribe, { method: "POST", body: form, redirect: "manual" });
expect(res.status).toBe(200); // not 3xx, not a login page
// Then check in your app (or API) that the address is unsubscribed.What header checks don't prove
The test reads the h= list but does not verify the signature itself or your DNS. For that, paste the raw headers into the email header analyzer or check the domain with the SPF, DKIM and DMARC checker; the staging deliverability post walks through the results.