@softure-ai/mailing
v0.1.13
Published
SOFTURE AI mailing module: sendMail over a provider adapter (Resend first) with plain-text and HTML bodies, sender and reply-to from config, idempotency keys, a timeout and a typed result; signed one-click unsubscribe (RFC 8058) with a suppression list fo
Readme
@softure-ai/mailing
Sends one mail to one recipient through a provider adapter and answers with a typed result, never a
throw, with the sender, reply-to, provider and timeout taken from configuration, an HTML body, and a
fake provider for tests. List mail gets signed one-click unsubscribe (RFC 8058) and a suppression
list. A delivery ledger sends a mail at most once per scope and recipient, an app's earlier history
can be imported into it, and softure-mail sends campaigns from a content file.
1. What it provides
mailing({ from, replyTo, provider, timeoutMs, ... })forsofture.config.ts(section 3).sendMail(context, mail, options)in@softure-ai/mailing/serverandsendMail(mail, options)in@softure-ai/mailing/next(on the registered config). The result is a coreResult; a failure the provider answered with an HTTP status also carries it ashttpStatus:| Result | Meaning | What the caller does | | --- | --- | --- | |
ok({ id, provider })| the provider accepted the mail | keepidif it needs one | |err("mailing.invalid_input")| refused before anything left the process | fix the input; never retry | |err("mailing.rejected")| the provider refused this mail (4xx: bad address, key reused with another body) | do not retry the same request | |err("mailing.provider_refused")| the provider refused the account (401/403: missing, wrong or restricted API key, account not allowed to send) | stop sending: every mail fails the same way; fix the key or account, then retry | |err("mailing.quota_exceeded")| the account's sending quota is spent (429 that is not a rate limit: Resend's daily or monthly quota) | stop sending; retry when the quota renews | |err("mailing.unavailable")| provider down (5xx), 408, a rate limit (429rate_limit_exceeded), a busy idempotency key, network, timeout, an answer without an id, no API key; for list mail also no unsubscribe secret or an unreadable suppression list | retry later with the sameidempotencyKey| |err("mailing.suppressed")| a list mail to a recipient who unsubscribed; nothing was sent | skip the recipient; never retry |List mail.
kind: "transactional"(the default) is mail the recipient needs whatever they unsubscribed from: resets, receipts, account notices. Any other kebab-case kind (newsletter,product-updates) is list mail:sendMailappends a footer with a signed unsubscribe link to the text and HTML bodies, addsList-Unsubscribe: <one-click URL>andList-Unsubscribe-Post: List-Unsubscribe=One-Click, and refuses the mail withmailing.suppressedwhen the recipient unsubscribed. An unsubscribe covers every list kind.The unsubscribe page (
/unsubscribe) and the one-click route (/api/mailing/unsubscribe) in/next, each mounted with one line (section 4).@softure-ai/mailing/server:isSuppressed(ctx, address),findSuppressedAddresses(ctx, addresses)(which of many addresses unsubscribed, for a report),suppressRecipient(ctx, address)(for scripts, bounce or complaint handlers),unsubscribe(ctx, link, source)(a link fromreadUnsubscribeLink(params, config), or a bare token),liftSuppression(ctx, address)(a new explicit consent lifts the person's own opt-out),buildUnsubscribeLinks,getRecipientKey, and the footer and header helpers.An
onUnsubscribedhook, run in the opt-out's transaction, so the consent ledger can record the withdrawal, andlegacyUnsubscribe, which keeps unsubscribe links the app sent before it adopted the module working (section 10).Sending once.
deliverOnce(ctx, { scope, mail })(/server, anddeliverOnce({ scope, mail })in/next) sends a mail at most once per scope and recipient through the delivery ledger;runDeliveriesdoes it for a whole list of recipients, with a limit, a pause and a dry run;importDeliveries(andsofture-mail import) seeds it with the app's earlier history.Preview and test send.
previewMail(config, mail)returns exactly whatsendMailhands the provider;softure-mail testsends a mail to the configuredtestAddressand nowhere else.A SQL surface for the app's own queries:
mailing.recipient_key(address),mailing.is_suppressed(address),mailing.was_delivered(scope, address)and the viewsmailing.delivery_outcomesandmailing.suppressed_recipients(section 5).Campaigns.
sendCampaign,planCampaignand the content file parser in/server; thesofture-mail campaigncommand sends a campaign from a content file.Sender DNS.
checkSenderDns(domain)andsofture-mail dnsreport SPF, DKIM and DMARC, hold DMARC to a required minimum, and check the reply-to domain's MX, the provider's return-path hosts and an inbound service (Cloudflare Email Routing).The
MailProvidercontract andresend(), the first adapter.@softure-ai/mailing/testing:fakeMailProvider()andreadMailOutbox(file).
import { sendMail } from "@softure-ai/mailing/next";
const result = await sendMail(
{ to: user.email, subject: messages.summary.subject, text, html },
{ idempotencyKey: `weekly-summary:${user.id}:${week}` },
);
if (!result.ok) return result; // a code; the UI shows getMailingErrorMessage(messages, result.error)
// List mail: footer, headers and the suppression check come with the kind.
await sendMail({ to: user.email, subject, text, html, kind: "newsletter" }, { idempotencyKey: `newsletter-12:${user.id}` });From scripts and other modules, /server takes the context: sendMail({ config }, mail) for
transactional mail, sendMail({ config, db }, mail) for list mail (the suppression check reads
the database; list mail without db throws, as a wiring bug).
Rules that hold for every send:
- One recipient.
tois exactly one address; a list is a loop. No cc or bcc. - Reply-To per mail.
replyTo(one address, checked liketo) sends replies to that address instead ofmailing({ replyTo }), e.g. the buyer of a request mailed to the operator:sendMail({ to: operator, replyTo: request.account.email, subject, text }). Without it the configured one is used; theReply-Toheader stays refused. - Text always, HTML optionally.
textis required;htmlis a string the app rendered (from a template string or React email components rendered to a string: the module does not render). - Headers cannot take over the mail.
headersis for headers likeList-Unsubscribe;From,Sender,To,Cc,Bcc,Reply-To,Subject,Return-Path,Content-Type,Content-Transfer-EncodingandMIME-Versionare refused in any case, as are line breaks in values and the subject. - Nothing of the mail is logged. One line per failure:
mailing: send failed provider=resend reason=rejected status=422(withfields=to,subjectfor invalid input). Provider refusal messages are never read: they can echo the address. - Timeout. After
timeoutMsthe provider's signal aborts and the send answersunavailable, even when the provider ignores the signal. - List mail fails closed. No secret or an unreadable suppression list means nothing is sent.
A list mail cannot bring its own
List-UnsubscribeorList-Unsubscribe-Post: the module writes them (invalid_input).
How a link works. The link carries r, the base64url SHA-256 of the trimmed, lowercased
address, and t, the base64url HMAC-SHA256 of a fixed prefix plus r under
MAILING_UNSUBSCRIBE_SECRET. The address never appears in a URL, and the suppression table stores
only r. The page (footer link) shows a button and changes nothing on open: mail scanners open
links. The one-click route takes a mail client's POST without a session, verifies before touching
the database and answers 200 (recorded, also again), 400 (link does not verify; 200 with
oneClickInvalidLinkStatus: 200) or 500 (database failure, so the client retries); a GET redirects
to the page. The whole link is a credential: it is
never logged.
Sending once: the delivery ledger
deliverOnce claims a row in mailing.deliveries for the scope and the recipient before it calls
sendMail, and closes it with one outcome. A second call for the same scope and recipient (any
case or spacing of the address) sends nothing:
import { deliverOnce } from "@softure-ai/mailing/next";
const outcome = await deliverOnce({
scope: `billing.trial-ending:${subscription.id}`,
mail: { to: user.email, subject, text, kind: "account-notices" },
});| Outcome | Meaning |
| --- | --- |
| { status: "sent", id } | sent now |
| { status: "rejected", reason, httpStatus? } | refused now, for good: mailing.suppressed, rejected, invalid_input, or unavailable on the last attempt |
| { status: "done", outcome } | an earlier call closed it (sent or rejected); nothing sent |
| { status: "in-flight" } | another sender holds a fresh claim; nothing sent |
| { status: "uncertain" } | a claim older than uncertainClaimMs is open: its send may have gone out; nothing sent (see Retries) |
| { status: "retry-later", httpStatus? } | the provider was unavailable; the claim is released, call again later |
| { status: "halted", reason, httpStatus? } | mailing.provider_refused or mailing.quota_exceeded: the account cannot send. The claim is released with its attempt given back; stop sending and call again once the account can send |
- Scopes name what the mail is about and are the only registration a lifecycle mail needs:
<module>.<event>:<entity>(billing.trial-ending:sub_42,waitlist.welcome:<signup id>). Lowercase letters, digits and._:-, at most 128 characters. Campaigns usecampaign:<id>. - Idempotency key. The ledger sends with
<scope>:<recipient key>; callers do not pass one. - Retries.
unavailablereleases the claim (pending); the fifth attempt (maxAttempts) that is still unavailable closes the row asrejected. Five rides out a short provider outage across a few runs, while a provider that keeps failing on one mail does not keep it open forever. An app that retries on its own schedule setsmailing({ maxAttempts: null })(or passes it per call):unavailablethen always releases the claim and never closes the row. A halt (provider_refused,quota_exceeded) never closes a row and never uses up an attempt, so a run with a bad key loses no recipient. The provider's HTTP status of the last failed answer is kept inprovider_status. - Interrupted sends. A process that dies between the send and the outcome leaves a claim; after
staleClaimMs(15 minutes) another call takes it over and sends again with the same idempotency key, which the provider folds into the first send while it keeps the key (Resend: 24 hours). A claim older thanuncertainClaimMs(23 hours) isuncertain: the provider may have forgotten the key, so a retake could mail twice, and it waits for an operator. Check the provider's log, then passretakeUncertain: true(softure-mail campaign --resend-uncertain) to send it anyway. Both windows are module options, anddeliverOnce(ctx, delivery, { staleClaimMs, uncertainClaimMs })overrides them per call. Outcomes are fenced by the attempt number, so a sender that lost its claim cannot overwrite the one that took over. - A malformed scope or kind throws (a bug); a database failure propagates.
Many recipients at once: runDeliveries
Lifecycle mail (a trial that ends, a renewal) goes to every recipient a query finds, each at most
once. runDeliveries loops over the recipients, builds each one's delivery and sends it through
deliverOnce:
import { runDeliveries } from "@softure-ai/mailing/next"; // or /server with ctx first
const summary = await runDeliveries(
{
recipients: await listTrialsEndingThisWeek(),
build: (trial) => (trial.email === null ? null : {
scope: `billing.trial-ending:${trial.subscriptionId}`,
mail: { to: trial.email, subject: messages.trialEnding.subject, text: renderText(trial), kind: "account-notices" },
}),
},
{ limit: 80, pauseMs: 500 },
);
// { dryRun, recipients, skipped, sent, rejected: { "mailing.suppressed": 1, … }, done, inFlight, retryLater,
// uncertain, halted, remaining }recipientsis an iterable or async iterable of anything;buildreturns the delivery (or a promise of it), ornullto skip the recipient (skipped, nothing stored).- It stops at the account. A refused key or a spent quota (
halted) ends the run at once:halted: { reason, httpStatus },remaining: null; that recipient and the rest go out on the next run. Rejections of one mail (suppressed,rejected,invalid_input) are counted and the run goes on. - It stops at an outage. The first mail the provider cannot take now (
retry-later: 5xx, a timeout, a 429 rate limit) ends the run the same way, ashalted: { reason: "mailing.unavailable", httpStatus }: every following mail would get the same answer and spend one of itsmaxAttempts.stopOnRetryLater: falsekeeps the run going instead. limitcounts the mails handed to the provider, likesendCampaign's; once reached, the rest of the list is only counted:remainingis how many the next run would send.pauseMspauses after each of those mails.dryRun: truereads the ledger and the suppression list and checks each mail, and writes and sends nothing:sentis then what would reach the provider.- List mail needs
MAILING_UNSUBSCRIBE_SECRET: without it the run throws at the first list mail, before any claim, instead of using up every recipient's attempts. onDelivery(outcome)sees every outcome (never the address);deliverOnce's options (maxAttempts,retakeUncertain, the claim windows) pass through.
Importing an existing history
An app that already kept its own once-only records (which campaign went to which signup, which
notice went to which user for which period) seeds the ledger before its first deliverOnce or
campaign run; otherwise that run mails everyone again. Map each record to the scope the module
will use for it and import:
import { importDeliveries } from "@softure-ai/mailing/server";
const result = await importDeliveries(ctx, [
{ scope: "campaign:2026-09-launch", address: "[email protected]", status: "sent", finishedAt: "2026-09-20T10:15:00Z", kind: "newsletter" },
{ scope: "account.trial-ending:user_7", address: "[email protected]", status: "rejected", finishedAt: row.sentAt, reason: "mailing.rejected" },
]);
if (!result.ok) console.error(result.problems); // [{ index, problem }]: nothing was written
else console.log(result.value); // { rows, imported, alreadyPresent, duplicates }Or from a file, one JSON object per line with the same fields:
softure-mail import history.jsonl --dry-run # checks the file, writes nothing
softure-mail import history.jsonl
docker compose exec -T app npx softure-mail import - < history.jsonl # inside the app's container- Rows:
scope,address(only its key is stored),status(sentorrejected),finishedAt(a date or ISO string, not in the future), optionalproviderMessageId(sent rows; history rarely kept it, and an imported sent row may go without),reason(rejected rows: one ofmailing.invalid_input,rejected,unavailable,suppressed; defaultmailing.rejected) andkind(defaulttransactional). - Every row is checked before anything is written; problems name the row (the file's line) and the field, never the address.
- Idempotent. A row whose scope and recipient the ledger already has (imported earlier, or sent
by the module) is left as it is and counted as
alreadyPresent; re-run the same import after a failure. Two input rows for the same scope and recipient count once (duplicates). - Imported rows are closed (
sentorrejected), withimported_atset and nocampaign_id:deliverOnce,planCampaignandsendCampaignmatch a delivery by scope, so they skip it. - Unsubscribes the app recorded go to
suppressRecipient, not here: the suppression list is what stops future campaigns.
Campaigns: softure-mail campaign
A campaign is one list mail to many recipients through the ledger. The content file is frontmatter
and a plain-text body; html (optional) names a file next to it:
---
id: 2026-10-launch
kind: newsletter
subject: Something new in Plan
html: launch.html
---
Hello,
we shipped something.softure-mail campaign launch.md --recipients recipients.txt --dry-run # counts and previews, sends nothing
softure-mail campaign launch.md --recipients recipients.txt # sendsThe dry run previews the mail as the provider would get it: sender, reply-to, subject, the RFC 8058 headers, the text and HTML bodies with the footer. It is rendered for
testAddress(else[email protected]), with addresses masked (o*******@example.com) and the link signatures replaced by<signature>, so the output is safe to paste. WithoutMAILING_UNSUBSCRIBE_SECRETit says so instead.No pasted footers. Content that carries an unsubscribe link (the app's unsubscribe or one-click path, or a signed
r=…&t=…pair) or the footer's copy in any locale is refused before anything runs: the module adds a footer signed for each recipient, and a link pasted from a sent mail would let every recipient unsubscribe the person it was signed for.getCampaignProblems(content, config)runs the same check (links on the app's own origin; a link to another site is left alone), andsendCampaignandplanCampaignrefuse such content.Kind aliases.
mailing({ kindAliases: { news: "newsletter" } })lets a content file (andsofture-mail test --kind) saykind: news; the campaign is stored and sent asnewsletter.Masked output. No command prints a full address or a working unsubscribe link.
The recipients file holds one address per line (
#comments and blank lines skipped).Recipients from the database. Without
--recipientsthe command asksmailing({ listCampaignRecipients }), which lists the addresses from the app's own data (duplicates are fine; the filter and the suppression list still apply):listCampaignRecipients: async ({ kind }, ctx) => listOptedInAddresses(ctx.db, kind),Where it runs. The command needs the database (the ledger) and the provider key, so it runs where both are: inside the app's container. When the database is not reachable from the operator's machine, send the content file on standard input (
-) and let the recipients come fromlistCampaignRecipients(or pass--recipients -with the content as a file in the image):ssh app-host 'cd /srv/app && docker compose exec -T app npx softure-mail campaign - --dry-run' < launch.md ssh app-host 'cd /srv/app && docker compose exec -T app npx softure-mail campaign -' < launch.mdWith the content on standard input, its
html:file is looked up in the working directory.Through
softure-deploy run. An app deployed with@softure-ai/deployruns the command in the live container without a shell on the server.softure-deploy runpasses only--key=valuewords, so the content file goes as--content-file=<file>(read on the operator's machine and sent on standard input) and the recipients come fromlistCampaignRecipients. One file travels per run, so such a campaign has nohtml:file. One runner per command inscripts/ops/, which the app's Dockerfile bundles intoops/<name>.mjs:// scripts/ops/mail-campaign.ts import { runMailCli } from "@softure-ai/mailing/cli"; import config from "../../softure.config"; process.exitCode = await runMailCli({ config, argv: ["campaign", ...process.argv.slice(2)] });softure-deploy run --host=app-prod mail-campaign --content-file=launch.md --dry-run softure-deploy run --host=app-prod mail-campaign --content-file=launch.md --limit=80 softure-deploy run --host=app-prod mail-test --content-file=launch.md # scripts/ops/mail-test.ts: argv ["test", ...]The exit status is the command's: 0 done, 1 failed or incomplete, 2 usage.
The command loads
softure.config.*likesofture migrate(or--config <file>), opens the config'sdatabase.handlewhen set, otherwise its own connection ondatabase.url, and needsMAILING_UNSUBSCRIBE_SECRET(campaigns are list mail). Runsofture migratefirst.Sends go one at a time with a 500 ms pause (
--pause-ms; Resend allows 2 requests per second by default). Unsubscribed recipients are rejected without a send and never retried.Recipient filter. With
mailing({ filterCampaignRecipient })every recipient is checked before the ledger is touched, e.g. against the consent scope the app stored for the address. A refused recipient is counted asfiltered outand gets no row, so a later run sends to them once they qualify:mailing({ from: "…", provider: resend(), filterCampaignRecipient: async ({ address }, ctx) => hasNewsletterConsent(ctx.db, address), }),A refused key, a spent quota or an outage stops the run at the first such answer (an outage is
retry-later: 5xx, a timeout, a 429 rate limit): the command prints why (with the HTTP status) and exits 1; that recipient and every one after it are left for the next run.sendCampaignreturns the same ashalted: { reason, httpStatus }(reasonismailing.unavailablefor an outage);stopOnRetryLater: falsekeeps it going past an outage.Instalments with
--limit <n>. On a plan whose daily quota is shared with other mail (Resend's free plan gives 100 a day),--limit 80hands at most 80 mails to the provider in this run and leaves room for the day's transactional mail. Only mails that reach the provider count (sent, rejected by it, or not taken now); done, unsubscribed, filtered, in-flight and uncertain recipients never use a slot. The command printslimit reached: <n> recipient(s) left for the next runand exits 0; run it again the next day. With--dry-runit also prints what one limited run would send.sendCampaign(ctx, input, { limit: 80 })does the same and returnsremaining(recipients past the cut that the next run would send to, counted likeplanCampaign'stoSend, one suppression lookup per address;0when the run reached the end of the list,nullafter a halt).Re-runs are safe. Recipients with an outcome are skipped; the command exits 1 while some have none yet (
retry later,in flight): run the same command again. It also exits 1 foruncertainrecipients (an interrupted send older thanuncertainClaimMs) and names--resend-uncertain.mailing.campaignspins the content by hash: other content under the same id is refused, so a changed campaign needs a new id.From a script (a bundled container, a list built from the database), call
runMailCli({ config, argv })from@softure-ai/mailing/cli, orsendCampaign(ctx, { campaign, recipients })from/server, whererecipientsmay be an async iterable.
Test send: softure-mail test
softure-mail test # a short fixed mail, transactional
softure-mail test --kind newsletter # the same as list mail: footer and unsubscribe headers
softure-mail test launch.md # a campaign's content, as the campaign would send it
softure-mail test launch.md --preview # print it, send nothingIt sends to mailing({ testAddress }) and to no one else; without that option it refuses. A
campaign file is sent outside the ledger (nothing is registered or recorded), so it can be sent
again after every edit. --kind (an alias of kindAliases too) overrides the file's kind; list
mail opens the database for the suppression check, so an unsubscribed test address answers
suppressed. The output masks the address and names the provider's failure with its HTTP status.
--preview prints what previewMail returns, like the campaign's dry run. The preview reads the
secret from runMailCli({ env }), a real send from process.env, as every send does.
previewMail(config, mail, { env, idempotencyKey }) (/server, and previewMail(mail) in /next) is
the function behind both: ok(message) with exactly the ProviderMessage sendMail would hand the
provider, { error: "mailing.invalid_input", fields }, or { error: "mailing.unavailable", cause:
"no_unsubscribe_secret" } for list mail without the secret. It sends, logs and reads nothing: a
suppressed recipient's mail renders too. Its links work for mail.to; show them to that person
only, or pass the text through redactUnsubscribeSignatures. maskAddress(address) masks an
address the same way the commands do.
Sender DNS check: softure-mail dns
softure-mail dns # the domain of `from` (and replyTo) in the config
softure-mail dns --domain mail.example.com --spf-host send.mail.example.com
softure-mail dns --spf-host send.mail.example.com --resend-return-path \
--dmarc-policy reject --dmarc-sp reject --dmarc-adkim s --dmarc-aspf sIt reports each check as pass, warn or fail, exiting 1 on any fail:
- SPF: one
v=spf1record on the domain or each--spf-host;+allwarns. Resend publishes SPF onsend.<domain>. - DKIM: a non-empty
p=at<selector>._domainkey.<domain>; selectorresendunless--dkim-selectoris given. - DMARC:
_dmarc.<domain>or a parent domain's record;p=nonewarns: it only reports. With a required minimum (expectDmarc: { policy, subdomainPolicy, adkim, aspf }, or the--dmarc-*flags) a weaker record fails asweak:p=quarantinewhererejectis required,adkim=r(or noadkim) wheresis required,pctunder 100 when a policy is required. A stricter record passes, so addingrua=or tightening a tag never turns it red. For an inherited record the domain's policy issp, elsep. - REPLY (when
replyTois configured and--domainis not given, or with--reply-to;replyTooption of the function): the reply domain must have MX records that are not a null MX (0 ., "accepts no mail") and at most one SPF record. Without it replies bounce while every sender check is green. - PATH (
returnPath: [{ host, targetDomain? }],--return-path <host>,--resend-return-path): each host must be a CNAME (totargetDomainor under it, when set) or have MX records.resendReturnPath(domain)(the flag) gives Resend'ssend.<domain>andrsend.<domain>with targetrmta.net; a domain Resend set up with MX records onsendand norsendchecks--return-path send.<domain>alone. - INBOUND (
inbound: "cloudflare",--inbound cloudflare): Cloudflare Email Routing receives the reply-to domain's mail (else the checked domain's). Every MX record must be undermx.cloudflare.net(unexpected-targetotherwise,null-mx,missing), and the domain must publish exactly one SPF record (multiple) that includes_spf.mx.cloudflare.net(missing-include). The report'sinboundholds the MX and the SPF check,[]when not asked.
It cannot see whether the provider signs with the published key. The function takes resolveTxt, resolveMx and
resolveCname for tests; by default it asks the system resolver.
2. Installation
npm install @softure-ai/mailing @softure-ai/core @softure-ai/db @softure-ai/ui drizzle-ormPeer dependencies: drizzle-orm, and next and react for the /next adapter. The module needs a
database (its suppression list). The /testing entry imports node:fs.
3. Configuration
import { mailing, resend } from "@softure-ai/mailing";
// in defineSoftureConfig({ modules: [...] }):
mailing({
from: "Plan <[email protected]>",
replyTo: "[email protected]",
provider: resend(),
}),| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| from | string | required | addr or Name <addr> (no commas, semicolons or quotes in the name). Must be on the domain the provider signs (DKIM); with DMARC adkim=s exactly that subdomain. |
| replyTo | string | none | One address for replies; may be on another domain (DMARC does not check it). |
| provider | MailProvider | required | resend(), fakeMailProvider() or your own adapter. |
| timeoutMs | number | 10000 | 1000 to 60000. |
| onUnsubscribed | (event, ctx) => Promise<void> | — | Runs on every verified unsubscribe, in its transaction (section 10). |
| legacyUnsubscribe | { params, verify } | — | Verifies unsubscribe links the app sent before it adopted the module (section 10). |
| oneClickInvalidLinkStatus | 200 \| 400 | 400 | What the one-click route answers for a link that does not verify. 200 gives no oracle on whether a token is live; a failure still answers 500, and the page still shows its message. |
| filterCampaignRecipient | (recipient, ctx) => Promise<boolean> | — | Decides per recipient whether a campaign goes to them (section 1, Campaigns). |
| listCampaignRecipients | (campaign, ctx) => Iterable<string> \| AsyncIterable<string> \| Promise<Iterable<string>> | — | Lists a campaign's recipients from the app's data; softure-mail campaign uses it without --recipients (section 1, Campaigns). |
| maxAttempts | number \| null | 5 | Claims before mailing.unavailable closes a delivery as rejected, 1 to 100; null never closes it (section 1, Retries). |
| staleClaimMs | number | 900000 (15 min) | How long a delivery claim may stay open before another sender takes it over. 1 minute to 23 hours. |
| uncertainClaimMs | number | 82800000 (23 h) | How old a claim may get before it waits for an operator (uncertain). More than staleClaimMs, at most 30 days; keep it under the provider's idempotency window. |
| testAddress | string | none | The only address softure-mail test sends to (section 1, Test send). |
| kindAliases | Record<string, string> | none | Alias to kind for campaign files and softure-mail test --kind, e.g. { news: "newsletter" }. Kebab-case on both sides; an alias never names a kind another alias points at. |
| routes | { unsubscribe?, oneClick? } | /unsubscribe, /api/mailing/unsubscribe | Where you mount the page and the route; links are built on appOrigin plus these paths. |
resend({ apiKey?, endpoint?, fetch? }): without apiKey it reads RESEND_API_KEY on every send,
so the configuration loads at build time without the secret.
Your own provider:
const provider: MailProvider = {
name: "postmark",
async send(message, { signal }) {
const response = await fetch(URL, { method: "POST", body: toBody(message), signal });
if (response.ok) return { status: "sent", id: (await response.json()).MessageID };
const httpStatus = response.status;
if (httpStatus === 401 || httpStatus === 403) return { status: "refused", httpStatus };
if (httpStatus === 429) return { status: "unavailable", httpStatus }; // or "quota_exceeded" for a spent quota
return { status: httpStatus >= 500 ? "unavailable" : "rejected", httpStatus };
},
};A provider receives a validated ProviderMessage (from, to, replyTo, subject, text, html,
headers, idempotencyKey, nulls for what is absent) and answers sent with an id, or rejected
(this mail), refused (the account or key), quota_exceeded (the account's quota) or unavailable
(try later), each with an optional httpStatus. A throw or anything else reads as unavailable.
4. Mounting
Two files, one line each:
// app/unsubscribe/page.tsx
export { UnsubscribePage as default } from "@softure-ai/mailing/next";// app/api/mailing/unsubscribe/route.ts
export { getUnsubscribeRoute as GET, postUnsubscribeRoute as POST } from "@softure-ai/mailing/next";The app's look. createUnsubscribePage({ classNames, unstyled, Layout }) gives the same page
with the app's classes on its slots (root, the main wrapper; card; form; submit), or with
its own layout around the copy and the form (the root and card classes then do not apply; the
referrer policy tag is kept):
// app/unsubscribe/page.tsx
import { createUnsubscribePage, type UnsubscribeLayoutProps } from "@softure-ai/mailing/next";
function AppShell({ title, lead, children }: UnsubscribeLayoutProps) {
return (
<SiteFrame>
<h1>{title}</h1>
<p>{lead}</p>
{children}
</SiteFrame>
);
}
export default createUnsubscribePage({ Layout: AppShell, classNames: { submit: "w-full" } });Both must stay public: keep them out of the auth guard's protect list. The route has no per-IP
rate limit on purpose: mail providers post from a few shared addresses that act for millions of
people, so a bucket would refuse real unsubscribes; the signature is checked before any database
access and the only write is an idempotent insert. Behind a proxy that logs failed requests with
their query, a 500 puts the link in that log; it is the one place it can appear.
Call sendMail from server actions, route handlers (/next) or scripts and other modules
(/server, with { config } or { config, db }).
5. Migrations and tables
Schema mailing, applied by softure migrate:
| Migration | Table | What it holds |
| --- | --- | --- |
| 0001_create_suppressions.sql | mailing.suppressions | recipient_key (primary key, 43-character base64url), source (one-click, page, operator), created_at. One row per address, the first opt-out kept. |
| 0002_create_campaigns_and_deliveries.sql | mailing.campaigns | id (kebab-case), kind (never transactional), subject, content_hash (sha256 of kind, subject and bodies), created_at. |
| | mailing.deliveries | primary key (scope, recipient_key), kind, campaign_id (then scope is campaign:<id>), status (pending, claimed, sent, rejected), attempts, claimed_at, finished_at, provider_message_id (exactly when sent; see 0004), reason (exactly when rejected). |
| 0003_add_delivery_provider_status.sql | mailing.deliveries | provider_status (the HTTP status of the last failed or released answer, 100 to 599, else null); attempts may be 0 on a pending row (a halt gave its attempt back). |
| 0004_allow_imported_deliveries.sql | mailing.deliveries | imported_at (set by importDeliveries, only on sent/rejected rows); provider_message_id only on sent rows, and required there unless the row was imported. |
| 0005_add_sql_surface.sql | functions and views | the SQL surface below. |
SQL for the app's own queries. Reports and recipient selections use these, never the tables, so a later change to the tables does not silently break them:
| Name | What it gives |
| --- | --- |
| mailing.recipient_key(address text) → text | getRecipientKey in SQL: base64url SHA-256 of the trimmed, lowercased address. IMMUTABLE, so it can sit in an index on the app's table. |
| mailing.is_suppressed(address text) → boolean | whether the address unsubscribed from list mail |
| mailing.was_delivered(scope text, address text) → boolean | whether a mail of that scope was sent to the address (sent by the module or imported) |
| view mailing.delivery_outcomes | scope, recipient_key, kind, campaign_id, status (pending, claimed, sent, rejected), attempts, claimed_at, finished_at, imported_at |
| view mailing.suppressed_recipients | recipient_key, source (one-click, page, operator), created_at |
-- trials ending this week that were not told yet and did not unsubscribe
select s.id, u.email
from subscriptions s join users u on u.id = s.user_id
where s.trial_ends_at < now() + interval '7 days'
and not mailing.was_delivered('billing.trial-ending:' || s.id, u.email)
and not mailing.is_suppressed(u.email);lower() follows the database's ctype: under a UTF-8 locale it lowercases like JavaScript, under the
C locale only ASCII letters, so an address with non-ASCII capitals then keys differently in SQL.
The functions and views are granted like the tables (the app role reads them).
The health check (checkMailingTables) runs select 1 from mailing.<table> limit 0 for the three
tables.
6. Environment variables
| Variable | Required | What it does |
| --- | --- | --- |
| RESEND_API_KEY | with resend() and no apiKey | Resend API key. Missing at send time: unavailable and a log line naming the variable. |
| MAILING_UNSUBSCRIBE_SECRET | to send list mail | Signs and verifies unsubscribe links; at least 32 characters (openssl rand -base64 32), a shorter one counts as missing. Missing at send time: list mail answers unavailable with a log line naming the variable; transactional mail is unaffected. |
| MAILING_UNSUBSCRIBE_SECRET_PREVIOUS | during a rotation | The secret before the current one: verifies links in mail already sent, never signs. |
Rotating the secret. Links live as long as the mail that carries them. Never replace the
secret outright: move the current value to MAILING_UNSUBSCRIBE_SECRET_PREVIOUS, set a new
MAILING_UNSUBSCRIBE_SECRET, and keep the previous one for as long as old mail may still be
clicked. Losing the secret means every link already sent stops working.
7. Switches
None.
8. Appearance
The unsubscribe page is a Card from @softure-ai/ui with a plain form (no client JavaScript),
styled by the app's tokens. createUnsubscribePage({ classNames, unstyled, Layout }) adds the app's
classes or its own layout (section 4).
9. Copy
src/messages/{en,pl}.ts: errors.mailing.{invalid_input,rejected,unavailable,suppressed,provider_refused,quota_exceeded},
footer.{text,htmlLead,htmlLink} (the list-mail footer, in the app's locale) and
unsubscribe.* (the page). getMailingErrorMessage(messages, code) returns the copy for a code;
override any text with mailing({ messages: { en: { footer: { text: "…" } } } }).
10. Hooks
The provider is the extension point (section 3).
onUnsubscribed(event, ctx) runs on every verified unsubscribe (the page's button or a mail
client's one-click POST), also a repeated one, in the transaction that stores the opt-out:
event is { recipientKey, source, link } (the key is privacy's email key of the same address, never the
address; link is { scheme: "signed" } or { scheme: "legacy", values } with the values verify
accepted, so a hook can find the row a legacy link named), ctx.db is the transaction. A throw rolls the opt-out back: the route answers 500 and the
page offers a retry, so the opt-out and what the hook records never disagree. It does not run for
suppressRecipient (a bounce or a script is not the person's choice). Wire the waitlist's handler,
which withdraws its consents in privacy's ledger:
import { withdrawWaitlistConsents } from "@softure-ai/waitlist/server";
mailing({ from: "…", provider: resend(), onUnsubscribed: withdrawWaitlistConsents }),legacyUnsubscribe: { params, verify } keeps unsubscribe links working that the app sent
before it adopted the module, in its own scheme (an HMAC over a sign-up id, say, on its own path).
params are the old link's query names (never r or status, which the module's links and page
own; t may be shared). An array names parameters the link must all carry; { required, optional }
also names ones it may carry, for an app that sent more than one form on the same path (at most 8
names in all, at least one required). A link without r that carries every required one
(non-empty, at most 512 characters) is a legacy link, and its values also hold the optional ones it
has (an empty one counts as absent, one over 512 characters makes the link invalid): the page shows the same button with the values in hidden fields,
and the action and the one-click POST call verify(values, ctx, env). env is the environment the
unsubscribe runs with (process.env in the page and the route; unsubscribe(ctx, link, source, env)
passes its own), so verify reads the old signing key from it and a test hands it one without
stubbing process.env. It returns the recipient's
address when the link is genuine, else null; the module records the opt-out under that
address's key and runs onUnsubscribed, exactly as for its own links. verify must check the
link's signature itself (in constant time): whatever address it returns is unsubscribed. A throw is
a failure (the page offers a retry, the route answers 500), not an invalid link. Mount the module's
page and route at the old paths too:
mailing({
from: "…",
provider: resend(),
legacyUnsubscribe: { params: ["u", "t"], verify: ({ u, t }, ctx, env) => verifyOldLink(ctx.db, env.OLD_UNSUBSCRIBE_KEY, u, t) },
}),
// app/old-unsubscribe/page.tsx
export { UnsubscribePage as default } from "@softure-ai/mailing/next";Two forms on one path (a signed ?u=<id>&t=<hmac> in mail and a bare ?t=<token> shown after
sign-up), an opt-out recorded on the app's own row, and a one-click route that never tells a live
token from a dead one:
mailing({
from: "…",
provider: resend(),
oneClickInvalidLinkStatus: 200,
legacyUnsubscribe: {
params: { required: ["t"], optional: ["u"] },
verify: ({ u, t }, ctx) => (u === undefined ? findAddressByToken(ctx.db, t) : verifyOldLink(ctx.db, u, t)),
},
onUnsubscribed: async (event, ctx) => {
if (event.link.scheme === "legacy") await markSignupUnsubscribed(ctx.db, event.link.values);
},
}),liftSuppression(ctx, address) is the other direction: a module that has just recorded a new
explicit consent calls it in that consent's transaction. The consent must be provably the
recipient's (the waitlist calls it only when a sign-up's confirmation link is used, never for an
address typed into a form). It deletes the row only when its source is page or one-click; an
operator row stays. It returns whether it lifted one.
Tests and e2e: fakeMailProvider() keeps accepted mail in provider.sent (with the id it
answered) and honours idempotency keys like a real provider. respond: (message) => ({ status:
"rejected" }) simulates failures. With outboxFile it also appends each mail as one JSON line, so a
test in another process (Playwright against next start) reads it with readMailOutbox(file, { to }).
Without an outbox file it refuses to run under NODE_ENV=production, where in-memory mail would
vanish. The example app sets MAIL_OUTBOX for its e2e (examples/next-app/e2e/mailing-transport.spec.ts).
11. GDPR
Addresses and content go to the provider only; never to logs or results. The suppression list
stores a SHA-256 of the address, not the address: pseudonymous data (it can be matched against a
known address), kept after an account is deleted because it records the person's objection to
mail. The delivery ledger also stores recipient keys, never addresses, and campaigns hold no
personal data. So the module neither exports nor deletes per user (privacy: { exports: false, deletes: false }).
12. Limitations
- One recipient per call; no cc, bcc or attachments.
sendMaildoes not retry: the caller retriesunavailablewith the sameidempotencyKey(Resend keeps keys for 24 hours), or usesdeliverOnce, which keeps the state between runs.- Exactly-once ends where the provider's idempotency window ends: a sender that dies after the
provider accepted a mail and before the outcome was written leaves an
uncertainclaim onceuncertainClaimMspasses; retaking it (retakeUncertain) may send that mail twice. No delivery, bounce or complaint webhooks yet. - Campaigns have no personalisation, scheduling or markdown: the body is sent as written.
- Suppression is global per address: no per-list preferences. A person comes back in through a
module's explicit consent (
liftSuppression, e.g. a confirmed waitlist sign-up); an operator row is removed by hand (getRecipientKey(address)). - Lowercasing the whole address merges
Ada@andada@(allowed to differ by RFC 5321, never in practice); an unsubscribe then covers both. - The display name in
fromcannot contain commas, semicolons or quotes (no RFC 5322 quoting). - Address checks are structural (one
@, a dot in the domain, no separators); the provider has the last word.
Build
npm run build -w modules/mailing runs tsc -p tsconfig.build.json.
