@flashyos/mail
v0.3.0
Published
mail/1 and the estate's mail control plane: a lane policy that refuses, a capture that demands a consent basis, a content-free event record, and a transport seam so the provider is a variable.
Maintainers
Readme
@flashyos/mail
The estate's mail control plane, and mail/1, the record underneath it.
Not a mail provider. The provider is a constructor argument.
import { createMailGateway, resendTransport, capture } from '@flashyos/mail';
const gateway = createMailGateway({
from: 'FlashyOS <[email protected]>',
org: 'org/flashyos',
transport: resendTransport({ apiKey: process.env.RESEND_API_KEY! }),
recorder: { append: (leaf) => db.mailLeaf.create({ data: leaf }) },
suppression: { suppressed: (a) => db.suppression.exists(a) },
});
await gateway.send(
{ to: user.email, subject: 'Reset your password', text: link },
{ lane: 'transactional', from: 'org/flashyos', author: 'agent/flashyos-ci' },
);The package takes configuration and never reads your environment or imports
your error class. That boundary is what makes it a package rather than a copy
with extra steps — the same boundary @flashyos/llm-gateway draws, for the
same reason.
What it is for
The estate carries 11 independent mail integration files across 7
repositories, all pointed at one provider, and no two of them agree about
anything: one throws on failure and one never throws, one has a suppression
list and six do not, one has an unsubscribe and six do not. None of them
records that a message existed. So a domain that silently stopped delivering
looks exactly like a week when nobody wrote to anyone — and on 6 September 2026
three estate domains were sending unauthenticated into their own published
DMARC policy, which nothing anywhere was red about. docs/mail.md in this
repository has the measurement and the decision.
The five things it does
It refuses. lanes.ts is the policy and every rule in it is a refusal. An
agent may not send to a person who did not just ask us for something; it may
draft, and a named human sends. That is estate doctrine — agents suggest,
humans consent — arriving on the one channel nobody had gated.
It demands a basis. capture() will not turn an address into a usable
record without an https URL showing what the person was told. An address with
no basis is not an asset; it is a liability with a mailing list attached.
It records. Every outcome — sent, held, refused, failed — produces a
mail/1 leaf. A refusal that leaves no trace is indistinguishable from a
message nobody tried to send.
It folds in what the provider says afterwards. A delivery outcome is a
transition carrying supersedes, never an edit — so sent does not stay
sent forever, and the current state of a message is derived from its
transitions the way holding/1 derives a position's. Bounces and complaints
feed the suppression list, which every lane consults except transactional.
It keeps the provider replaceable. resendTransport, logTransport,
failover(...). Swapping providers is a constructor argument, which is what
makes "are we locked in" a question with a measurable answer.
The two contracts it inherits, and the one it adds
Both inherited contracts come from packages/api/src/lib/email.ts, which had
them right:
- Never throw on delivery. Mail is always attached to something more important than itself. A provider outage must not become a 500 on the settlement that triggered it.
- Fall back to logging. With no transport configured the flow works end to end, so self-hosting and local development need no account anywhere.
And the one that is new, and is the reason the package exists:
- Always record.
A refusal is the one thing that throws, because a refusal is a bug in the caller rather than weather.
Reading the record
mail/1 is content-free by construction: the counterparty's domain, never the
address; no subject, body or message id; and an address is never hashed into a
leaf, because a hashed small-space identifier is a reversible identifier. The
full rules and the reasoning are in SPEC.md.
Travelling
The format travels by file, the way every record format in this estate does.
vendor-mail.mjs is dependency-free — node: builtins only — and runs in a
repository with no package.json and no node_modules:
node vendor-mail.mjs emit # mail.leaves.json → mail.fragment.json
node vendor-mail.mjs validate # what is wrong with a projection
node vendor-mail.mjs check flashy.gold # can this domain authenticate its mail?tools/adoptable.test.mjs copies it alone into an empty directory, runs it with
bare node and hands the output to this package's own validator.
tools/vendored-mail.test.mjs runs both implementations over one corpus and
fails on any disagreement about a verdict — never by comparing constants, which
is the check that would pass on the day one copy stopped using the rule.
Status
Published, at 0.2.0. The excuse for keeping it private said the gateway had no
caller in production; packages/api is one, and a format the platform itself
runs on is past the bar the estate's other unpublished packages are waiting at.
Domain ownership is refused from 0.2.0 and a receipt footer is appended
from 0.3.0 — DOMAIN_OWNERSHIP_SINCE and RECEIPT_FOOTER_SINCE are exported
so both figures are read from code rather than remembered, and
tools/behaviour-markers.mjs holds these sentences and those constants to each
other. A minor bump on a 0.x package crosses the caret boundary, so a
consumer pinned at ^0.1.0 must move: that is the cost of the behaviour
changing, paid once, rather than two programs under one version.
The receipt footer
One line at the foot of every message the gateway sends, pointing at a public
record of who sent it. Opt-in: with no receipt in the config there is no
footer, because appending a link to a page an adopter never published would put
a 404 at the foot of their mail.
const mail = createMailGateway({
from: 'Flashy Gold <[email protected]>',
org: 'org/flashy-gold',
transport, recorder,
receipt: { base: 'https://flashyos.com/receipt', org: 'org/flashy-gold' },
})The link carries the organisation and the lane, and nothing else. Not the
message — mail/1 §6a refuses a per-message row in a public document, and a
footer link is the worst place to put something that has to stay unguessable:
it ends up in spam corpora, forwarded threads and support tickets. The lane is
a class of message the recipient's own inbox already tells them, so it adds no
fact anybody did not have, and it lets the page answer what permits an
organisation to send you this kind of thing.
Two refusals. It never contains the word "unsubscribe" — the gateway
decides whether a bulk message carries a working unsubscribe by looking for
that word beside a URL in the body, and a footer on every message that used it
would satisfy that check for a campaign carrying none. transactional gets no
footer: a password reset is caused by something the reader did seconds ago.
Proving the domain is yours
check() proves a domain authenticates mail. It does not prove anybody
owns it — reading somebody's public DNS says nothing about who put the
records there, so a tenant can name a domain they have nothing to do with and
check() will call it healthy. That was tolerable while every organisation on
a platform belonged to one estate. It stops being tolerable the moment a
stranger can create one.
import { challenge, recordFor, proveOwnership } from '@flashyos/mail'
const c = challenge('flashy.gold', nonceIssuedByThePlatform)
recordFor(c) // → { host: '_flashyos-challenge.flashy.gold', value: 'flashyos-domain-verification=…' }
const reading = await proveOwnership(c, resolver)
// → { verdict: 'proven' | 'absent' | 'expired' | 'unasked', host, value, expiresAt }Or, with no toolchain at all, from the machine that can see your own zone:
node vendor-mail.mjs prove flashy.gold <the nonce you were given>Neither this package nor the vendored file will mint you a nonce. A nonce
no platform recorded proves nothing to anybody, and a command that appeared to
work and settled nothing is worse than no command. challenge() refuses
anything under 32 hex characters for the same reason one step earlier: a
guessable challenge is a challenge anybody can satisfy.
Four verdicts, and unasked is again the one that matters — a resolver that
did not answer has not told you the record is missing, and recording that as a
failure would let one flaky lookup revoke a proof sitting correctly in a zone.
The compensating check
A record committed to a repository says nothing about a zone. check() reads
SPF, DKIM and DMARC over DNS the way a receiving mail server would, and it is
the only question in this package a checkout cannot answer.
import { check, alignsVia } from '@flashyos/mail'
import { Resolver } from 'node:dns/promises'
const reading = await check('flashy.gold', new Resolver({ timeout: 4000 }))
// → { verdict: 'refused-by-own-policy', spf: null, dkim: null, dmarc: 'reject', why: … }
alignsVia(reading) // → 'none'Or, with no toolchain at all:
node vendor-mail.mjs check flashy.goldFour verdicts, and the fourth is the one that matters. unasked means the
resolver did not answer — reported as itself, never as absent, because
reporting the asking machine's silence as a fact about somebody's domain is how
a report invents a defect.
alignsVia folds a reading into the three states a sender record stores, and
spf-only is deliberately its own answer rather than part of ok: SPF
authorises a set of hosts, and whether that set contains the provider actually
sending cannot be read without resolving every nested include and knowing that
provider's current egress. A DKIM key on the apex is provable from outside.
