EmailDeliverabilityDeliverability

Deliverability

List-Unsubscribe headers, suppression lists, and DMARC/MTA-STS/TLS-RPT/ARF report parsers for @visulima/email

Deliverability

The @visulima/email/deliverability entry point bundles the tooling you need to meet modern bulk-sender requirements: one-click List-Unsubscribe headers, a pluggable suppression list, and pure parsers for the DNS records and feedback reports that drive your sending reputation.

import {
    buildListUnsubscribe,
    parseListUnsubscribe,
    MemorySuppressionStore,
    createSuppressionStore,
    filterSuppressed,
    parseDmarcRecord,
    parseMtaStsPolicy,
    parseTlsRptRecord,
    parseTlsReport,
    parseArfReport,
} from "@visulima/email/deliverability";

List-Unsubscribe

buildListUnsubscribe() produces the List-Unsubscribe (and, when requested, List-Unsubscribe-Post) headers. Gmail and Yahoo's 2024 bulk-sender rules expect bulk senders to expose a RFC 8058 one-click unsubscribe.

type ListUnsubscribeOptions = {
    url?: string; // an https: URL the recipient (or their client) can use to unsubscribe
    mailto?: string; // a bare address or full mailto: URI
    oneClick?: boolean; // emit the RFC 8058 List-Unsubscribe-Post header
    mailtoSubject?: string; // default subject for a bare mailto address (default: "unsubscribe")
};

type ListUnsubscribeHeaders = {
    "List-Unsubscribe": string;
    "List-Unsubscribe-Post"?: string;
};

At least one of url or mailto must be provided, otherwise buildListUnsubscribe() throws a TypeError.

import { buildListUnsubscribe } from "@visulima/email/deliverability";

const headers = buildListUnsubscribe({
    url: "https://example.com/unsub?id=abc",
    mailto: "unsubscribe@example.com",
    oneClick: true,
});
// {
//   "List-Unsubscribe": "<https://example.com/unsub?id=abc>, <mailto:unsubscribe@example.com?subject=unsubscribe>",
//   "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
// }

One-Click Requires an HTTPS URL

RFC 8058 one-click unsubscribe is only meaningful over HTTPS. Passing oneClick: true without an https: url throws a TypeError:

// Throws: One-click unsubscribe (RFC 8058) requires an https `url`
buildListUnsubscribe({ mailto: "unsubscribe@example.com", oneClick: true });

Merging Into an Email

Merge the returned headers into a message with setHeaders():

import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { buildListUnsubscribe } from "@visulima/email/deliverability";

const mail = createMail(resendProvider({ apiKey: "re_xxx" }));

const message = new MailMessage()
    .to("user@example.com")
    .from("newsletter@example.com")
    .subject("This week at Acme")
    .html("<h1>Hello</h1>")
    .setHeaders(
        buildListUnsubscribe({
            url: "https://example.com/unsub?id=abc",
            mailto: "unsubscribe@example.com",
            oneClick: true,
        }),
    );

await mail.send(message);

Parsing a List-Unsubscribe Header

parseListUnsubscribe() unwraps the angle-bracketed targets from a raw header value into a string[]:

import { parseListUnsubscribe } from "@visulima/email/deliverability";

const targets = parseListUnsubscribe("<https://example.com/unsub?id=abc>, <mailto:unsubscribe@example.com?subject=unsubscribe>");
// ["https://example.com/unsub?id=abc", "mailto:unsubscribe@example.com?subject=unsubscribe"]

Suppression Lists

A suppression list is the addresses you must never send to again — bounces, spam complaints, and unsubscribes. MemorySuppressionStore is an in-memory implementation suitable for tests, single-process apps, or as a write-through cache in front of a durable store. Matching is case-insensitive (addresses are normalized to lower case on write).

type SuppressionReason = "bounce" | "complaint" | "manual" | "unsubscribe";

type SuppressionEntry = {
    address: string; // the normalized (lower-cased) address
    createdAt: Date;
    reason: SuppressionReason;
    metadata?: Record<string, unknown>;
};
import { MemorySuppressionStore } from "@visulima/email/deliverability";

const store = new MemorySuppressionStore();

store.add("User@Example.com", "bounce", { type: "hard" });

store.has("user@example.com"); // true (case-insensitive)
store.get("user@example.com"); // { address: "user@example.com", reason: "bounce", createdAt: Date, metadata: { type: "hard" } }
store.size; // 1

for (const entry of store.list()) {
    console.log(entry.address, entry.reason);
}

store.remove("user@example.com"); // true

The createSuppressionStore() factory is a convenience for an in-memory store, optionally seeded with existing entries:

import { createSuppressionStore } from "@visulima/email/deliverability";

const store = createSuppressionStore([
    { address: "bounced@example.com", reason: "bounce" },
    { address: "complained@example.com", reason: "complaint", metadata: { source: "fbl" } },
]);

Pre-Send Filtering

filterSuppressed() partitions a recipient list into allowed and suppressed. It accepts EmailAddress objects ({ email, name? }) and is asynchronous, so it works with both in-memory and durable stores:

import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { createSuppressionStore, filterSuppressed } from "@visulima/email/deliverability";

const mail = createMail(resendProvider({ apiKey: "re_xxx" }));
const store = createSuppressionStore([{ address: "bounced@example.com", reason: "bounce" }]);

const recipients = [{ email: "ok@example.com" }, { email: "bounced@example.com" }];

const { allowed, suppressed } = await filterSuppressed(recipients, store);

if (suppressed.length > 0) {
    console.warn(
        "Skipping suppressed recipients:",
        suppressed.map((r) => r.email),
    );
}

if (allowed.length > 0) {
    const message = new MailMessage().to(allowed).from("newsletter@example.com").subject("Hello").html("<h1>Hello</h1>");
    await mail.send(message);
}

Backing the Store With Redis or a Database

For production, implement the SuppressionStore interface to back the list with Redis, Postgres, or a provider's native suppression API. Implementations are expected to normalize addresses to lower case on write, and methods may be sync or async.

type SuppressionStore = {
    add: (address: string, reason: SuppressionReason, metadata?: Record<string, unknown>) => Promise<void> | void;
    has: (address: string) => boolean | Promise<boolean>;
    get: (address: string) => (SuppressionEntry | undefined) | Promise<SuppressionEntry | undefined>;
    remove: (address: string) => boolean | Promise<boolean>;
    list?: () => Iterable<SuppressionEntry> | Promise<Iterable<SuppressionEntry>>;
};
import type { Redis } from "ioredis";
import type { SuppressionEntry, SuppressionReason, SuppressionStore } from "@visulima/email/deliverability";

const createRedisSuppressionStore = (redis: Redis): SuppressionStore => {
    const key = (address: string) => `suppression:${address.trim().toLowerCase()}`;

    return {
        async add(address, reason, metadata) {
            const entry: SuppressionEntry = { address: address.trim().toLowerCase(), createdAt: new Date(), reason, metadata };

            await redis.set(key(address), JSON.stringify(entry));
        },
        async has(address) {
            return (await redis.exists(key(address))) === 1;
        },
        async get(address) {
            const raw = await redis.get(key(address));

            return raw ? (JSON.parse(raw) as SuppressionEntry) : undefined;
        },
        async remove(address) {
            return (await redis.del(key(address))) === 1;
        },
    };
};

Report & Record Parsers

All parsers are pure functions — no I/O, no DNS. Fetch the records or reports yourself (DNS lookup, HTTPS request, inbound webhook) and pass the raw text in.

DMARC

parseDmarcRecord() parses the _dmarc.<domain> TXT policy record. Unknown policy values (anything other than none / quarantine / reject) become undefined. The original tag/value pairs are preserved under tags.

import { parseDmarcRecord } from "@visulima/email/deliverability";

const record = parseDmarcRecord(
    "v=DMARC1; p=reject; sp=quarantine; pct=100; adkim=s; aspf=r; rua=mailto:agg@example.com; ruf=mailto:forensic@example.com; fo=1",
);
// {
//   valid: true,
//   policy: "reject",
//   subdomainPolicy: "quarantine",
//   percent: 100,
//   adkim: "s",
//   aspf: "r",
//   rua: ["mailto:agg@example.com"],
//   ruf: ["mailto:forensic@example.com"],
//   fo: "1",
//   tags: { v: "DMARC1", p: "reject", sp: "quarantine", pct: "100", adkim: "s", aspf: "r", rua: "...", ruf: "...", fo: "1" },
// }

MTA-STS

parseMtaStsPolicy() parses the policy file served at https://mta-sts.<domain>/.well-known/mta-sts.txt:

import { parseMtaStsPolicy } from "@visulima/email/deliverability";

const policy = parseMtaStsPolicy(["version: STSv1", "mode: enforce", "mx: mail.example.com", "mx: *.example.net", "max_age: 604800"].join("\n"));
// {
//   valid: true,
//   version: "STSv1",
//   mode: "enforce",
//   mx: ["mail.example.com", "*.example.net"],
//   maxAge: 604800,
// }

TLS-RPT

parseTlsRptRecord() parses the _smtp._tls.<domain> TXT record, and parseTlsReport() normalizes a TLS-RPT JSON report (passed as a JSON string or an already-parsed object) into a session-count summary.

import { parseTlsRptRecord, parseTlsReport } from "@visulima/email/deliverability";

const record = parseTlsRptRecord("v=TLSRPTv1; rua=mailto:tlsrpt@example.com");
// { valid: true, version: "TLSRPTv1", rua: ["mailto:tlsrpt@example.com"] }

const report = parseTlsReport(rawTlsRptJson);
// {
//   organizationName: "google.com",
//   reportId: "2024-01-01T00:00:00Z_example.com",
//   startDate: "2024-01-01T00:00:00Z",
//   endDate: "2024-01-02T00:00:00Z",
//   totalSuccessful: 1234,
//   totalFailure: 5,
//   policies: [ /* raw policy objects */ ],
// }

ARF (Feedback-Loop Complaints)

parseArfReport() parses an ARF feedback-loop complaint — the report mailbox providers send when a recipient marks your message as spam. Pass the full raw ARF email or just its message/feedback-report part. Use originalRcptTo to drive suppression of the complaining recipient.

import { parseArfReport } from "@visulima/email/deliverability";

const report = parseArfReport(rawArfEmail);
// {
//   feedbackType: "abuse",
//   originalRcptTo: "complainer@example.com",
//   originalMailFrom: "newsletter@example.com",
//   reportedDomain: "example.com",
//   sourceIp: "192.0.2.1",
//   arrivalDate: "Mon, 01 Jan 2024 00:00:00 +0000",
//   fields: { /* every lower-cased field from the report part */ },
// }

The 2024 Gmail / Yahoo Bulk-Sender Pattern

Putting the pieces together: emit a one-click List-Unsubscribe, filter every send against your suppression list, and feed inbound ARF complaints back into that same list. This closes the loop the bulk-sender rules require — recipients can always unsubscribe in one click, and a spam complaint permanently suppresses the complainer.

import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { buildListUnsubscribe, createSuppressionStore, filterSuppressed, parseArfReport } from "@visulima/email/deliverability";

const mail = createMail(resendProvider({ apiKey: "re_xxx" }));
const suppression = createSuppressionStore();

// 1. Pre-send: drop anyone on the suppression list, attach a one-click unsubscribe.
const sendBulk = async (recipients: { email: string }[]) => {
    const { allowed } = await filterSuppressed(recipients, suppression);

    if (allowed.length === 0) {
        return;
    }

    const message = new MailMessage()
        .to(allowed)
        .from("newsletter@example.com")
        .subject("This week at Acme")
        .html("<h1>Hello</h1>")
        .setHeaders(
            buildListUnsubscribe({
                url: "https://example.com/unsub?id=abc",
                mailto: "unsubscribe@example.com",
                oneClick: true,
            }),
        );

    await mail.send(message);
};

// 2. Inbound ARF webhook: a spam complaint suppresses the complainer.
const handleComplaint = async (rawArfEmail: string) => {
    const report = parseArfReport(rawArfEmail);

    if (report.originalRcptTo) {
        await suppression.add(report.originalRcptTo, "complaint", { source: "fbl", reportedDomain: report.reportedDomain });
    }
};
Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues