Manages sender domains, auto-generates DKIM keys, and verifies DNS records (SPF, DKIM, DMARC).
src/modules/domains/
├── domains.plugin.ts ← Elysia plugin (route group)
├── errors.ts ← DomainAlreadyRegisteredError, InvalidDomainNameError (#406), InvalidDkimSelectorError, DkimRotationError (#250)
├── services/
│ ├── domain.service.ts ← CRUD + DKIM key generation
│ ├── domain-name.ts ← Name normalisation and the host name rule on create (#406)
│ ├── dkim-keys.ts ← Key generation, selector rules, dated selectors, TXT match (#250)
│ ├── dkim-rotation.service.ts ← DKIM key rotation: start, complete, cancel (#250)
│ ├── domain-dkim.service.ts ← DKIM options for mail sent outside the queue (#106, #217)
│ ├── dns-verification.service.ts ← SPF/DKIM/DMARC DNS verification (pass, fail or unknown)
│ ├── domain-check-state.ts ← Verification state machine: failure count, flip, schedule (#250)
│ ├── domain-check.service.ts ← Stores a check and its webhook event in one transaction (#250)
│ ├── domain-reverify.service.ts ← Scheduled re-verification worker: claim, check, reschedule (#250)
│ ├── dmarc-check.service.ts ← DMARC discovery, org-domain fallback, strict tag parsing (#250)
│ ├── domain-dns-status.service.ts ← Cached live DMARC check + MX status, mail-flow summary (#250)
│ ├── required-dns-records.ts ← The DNS records to publish (`dnsRecords`, #250)
│ ├── sending-ip-dnsbl.service.ts ← Daily DNSBL self-check of the sending IPs (#250)
│ ├── sending-ips.service.ts ← Sending IPs and the reverse DNS check (#247, #248)
│ ├── spf-evaluator.ts ← SPF `checkHost()`, RFC 7208 strict: lookups, limits, deadline (#373)
│ ├── spf-syntax.ts ← SPF record parsing, domain-spec and macro expansion (#373)
│ └── spf-address.ts ← IP parsing, IPv4-mapped clients, CIDR matching (#373)
├── dtos/
│ ├── create-domain.dto.ts ← POST body validation
│ └── update-domain.dto.ts ← PATCH body validation (unsubscribe settings, tracking opt-in #187)
├── models/
│ └── domain.schema.ts ← Drizzle pgTable definition
├── serializations/
│ └── domain.serialization.ts ← Strips private keys, exposes DKIM DNS record
└── types/
├── domain.types.ts ← Domain type, CreateDomainInput, DomainSettingsChanges (#187)
├── domain-check.types.ts ← Check outcome (`pass`, `fail`, `unknown`) and result (#250)
└── spf.types.ts ← SPF result, DNS resolver, `checkHost()` input and options (#373)
Table: domains
| Column | Type | Constraints |
|---|---|---|
| id | varchar(36) | PK, prefixed dom_ |
| name | varchar(255) | NOT NULL, UNIQUE |
| dkim_private_key | text | nullable (auto-generated, AES-256-GCM encrypted at rest: v1:<iv>:<ct>:<tag> format, see #23) |
| dkim_public_key | text | nullable (auto-generated, plaintext: published in DNS) |
| dkim_selector | varchar(63) | NOT NULL, default 'bunmail': the active key's selector, chosen on create (#250) or set by a completed rotation |
| dkim_pending_selector | varchar(63) | nullable: the dated selector of a pending key rotation (#250) |
| dkim_pending_private_key | text | nullable: the pending private key, AES-256-GCM encrypted like dkim_private_key (#250) |
| dkim_pending_public_key | text | nullable: the pending public key (#250) |
| dkim_pending_created_at | timestamp | nullable: when the pending rotation started (#250) |
| dkim_previous_selector | varchar(63) | nullable: the selector active before the last completed rotation, informational only (#250) |
| dkim_previous_public_key | text | nullable: its public key, to recognise the record to keep for now (#250) |
| dkim_rotated_at | timestamp | nullable: when the last rotation switched keys (#250) |
| unsubscribe_email | varchar(255) | nullable: overrides the default unsubscribe@<from-domain> mailto in the List-Unsubscribe header (#40). On single-recipient mail with inbound SMTP off it is kept next to the per-recipient link; with inbound SMTP on, the unsub+<token> mailto replaces it (#249) |
| unsubscribe_url | text | nullable: adds an https URL form to List-Unsubscribe and enables List-Unsubscribe-Post: List-Unsubscribe=One-Click (#40). Used only for mail with cc, bcc or several recipients, or when APP_BASE_URL is not https://: otherwise BunMail's per-recipient /u/<token> link takes precedence, since a static URL cannot tell who clicked (#249) |
| notify_email | varchar(255) | nullable, when set, inbound mail received for this domain triggers a summary notification email to this address (#106). Use an external mailbox. |
| track_opens | boolean | NOT NULL, default false: open tracking opt-in, set by admins (#187). See Open and click tracking |
| track_clicks | boolean | NOT NULL, default false: click tracking opt-in, set by admins (#187) |
| spf_verified | boolean | NOT NULL, default false |
| dkim_verified | boolean | NOT NULL, default false |
| dmarc_verified | boolean | NOT NULL, default false |
| verified_at | timestamp | nullable: when every required check (SPF, DKIM, DMARC) passed; kept while they keep passing or a lookup fails, cleared only when the domain flips to failing (#250) |
| last_checked_at | timestamptz | nullable: when the checks last ran, by Verify or the loop (#250) |
| next_check_at | timestamptz | nullable, indexed: when the loop checks the domain next; NULL = due now (#250) |
| consecutive_failures | integer | NOT NULL, default 0: checks in a row with a definitive failure (#250) |
| last_check_result | jsonb | nullable: { spf, dkim, dmarc, checkedAt }, each check { status: "pass" | "fail" | "unknown", reason } (#250) |
| created_at | timestamp | NOT NULL, default now() |
| updated_at | timestamp | NOT NULL, default now() |
| created_by_user_id | varchar(36) | nullable, FK → users ON DELETE SET NULL, indexed: who created it (#219, attribution only; NULL = the owner) |
POST /api/v1/domains and the dashboard's add form normalise the name, then check it (domain-name.ts):
- Normalise: trim; convert a Unicode IDN to its A-label with
node:url'sdomainToASCII(bücher.examplebecomesxn--bcher-kva.example; only a name with non-ASCII characters and no ASCII space or control character is converted); lowercase ASCII letters; drop one final dot (Example.COM.becomesexample.com). - Check: a host name of at least two labels, each 1 to 63 letters, digits or inner hyphens (no leading or trailing hyphen), 253 characters at most, whose top label is not all digits (so an IPv4 address is refused). A URL (
https://example.com), a port (example.com:25), a space, an@, an empty label (example..com,example.com..) and a single label (localhost) are all refused.
A refused name is 422 INVALID_DOMAIN_NAME over REST (the message never repeats the submitted value) and an error flash on the dashboard; nothing is stored. The normalised name is what is stored and compared: Example.COM. and example.com are the same domain (409 DOMAIN_ALREADY_REGISTERED).
Rows stored before #406 are never changed. At boot (warnNonCanonicalDomainNames in src/boot.ts), each stored name that creation would now refuse, or store differently than by case alone (a final dot, a Unicode IDN), is logged once as a warn with its domainId, name and normalized form (null when refused); the boot goes on. Such a name can miss sender-domain lookups and DNS checks: delete the domain and add it again under its normalised name (its DKIM key changes, so publish the new TXT record).
When a domain is created, BunMail automatically generates a 2048-bit RSA keypair:
- Private key: generated as PEM, then encrypted with AES-256-GCM using
DKIM_ENCRYPTION_KEYfrom.envbefore insert (#23). The plaintext PEM only lives in memory insidecreateDomain()and is never logged. Decrypted on read by the queue's domain lookup. A key that does not decrypt fails closed in production (#244): the boot refuses to start, naming the domains (findUndecryptableDomainKeys), and the queue never sends unsigned: the email is held on the retry backoff and fails once it is too old to retry. Development warns and sends unsigned. - Public key: stored plaintext in
dkim_public_key, provided as a DNS TXT record value
The DKIM DNS record the user needs to add is returned in the API response as dkimDnsRecord.
The key's TXT record lives at <selector>._domainkey.<domain>. The selector defaults to bunmail; POST /api/v1/domains (and the dashboard's add form) accept an optional dkimSelector, for example when bunmail is already taken in the zone. It must be one lowercase DNS label: 1 to 63 characters, [a-z0-9], with inner hyphens allowed (^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$). Anything else is 422 (VALIDATION_ERROR from the DTO, INVALID_DKIM_SELECTOR from the service), and the dashboard shows the reason as an error flash.
A key can be replaced without a window where mail is unsigned or fails verification. Rotation is admin-only (admin API key, or adminGuard on the dashboard) and takes three steps:
- Start:
POST /api/v1/domains/:id/dkim/rotate(dashboard: Rotate DKIM key on the domain page, with a confirmation). BunMail generates a new RSA 2048 key pair, the same way as on create, under a dated selectorbm<YYYYMMDD>(UTC). When that name is the current or the previous selector (a second rotation the same day),-2,-3, ... is appended, so it is never the current selector. The pending selector, the encrypted private key, the public key and the start time are stored. The response carries the TXT record to publish (dnsRecord), which also appears indnsRecordswithpending: true. Only one rotation can be pending: starting another is409 DKIM_ROTATION_PENDING. - Publish the pending TXT record at your DNS provider next to the current one. Do not replace the current record: mail is still signed with the current key while the rotation is pending.
- Complete:
POST /api/v1/domains/:id/dkim/rotate/complete(dashboard: Complete rotation). BunMail looks up<pending selector>._domainkey.<domain>and compares it with the pending public key, the same way Verify compares the active key. Only when it matches does one transaction switch: the pending key becomes the active key, the old selector and public key are kept aspreviouswith the switch time (dkim_rotated_at), anddkim_verifiedis set (the new record was just found). Otherwise the answer is409 DKIM_PENDING_NOT_PUBLISHEDwith the reason (no record yet, a record with another key, or a temporary DNS failure) and nothing changes. A pending private key that does not decrypt withDKIM_ENCRYPTION_KEYis refused with409 DKIM_PENDING_KEY_UNREADABLE: cancel and start again. Verify DNS never switches on its own: the operator decides when.
Cancel with DELETE /api/v1/domains/:id/dkim/rotate (dashboard: Cancel rotation): the pending fields are cleared and the active key is untouched. Completing or cancelling with nothing pending is 409 DKIM_NO_PENDING_ROTATION.
The previous selector. After a switch, the API (dkimPrevious) and the domain page show the previous selector with the advice: keep its DNS record published for at least 7 days after the switch, then remove it. Messages signed before the switch (still queued, delayed, forwarded or re-checked later) are verified against it. It is informational only: its private key is dropped at the switch and BunMail never signs with it. A later rotation replaces it.
Audit. domain.dkim_rotation_started, domain.dkim_rotation_completed and domain.dkim_rotation_cancelled are recorded in the same transaction as the change (actor: the dashboard user or the API key; target: the domain; metadata: { selector } only, never key material). A complete refused because the record is not published changes nothing, so it is only logged (info, DKIM rotation not completed: pending key not published).
Boot check. findUndecryptableDomainKeys() also checks pending keys and returns { active, pending }. An active key that does not decrypt refuses the boot in production (#244); a pending one only warns, in every environment, since mail is never signed with it.
The dns-verification.service.ts checks three DNS record types:
| Record | Host | What it checks |
|---|---|---|
| SPF | <domain> |
exactly one v=spf1 record that gives pass (RFC 7208, evaluated by the built-in evaluator, #373) for every sending IP of this server (#247) |
| DKIM | <selector>._domainkey.<domain> |
TXT record containing the expected public key |
| DMARC | _dmarc.<domain>, else _dmarc.<organizational domain> |
exactly one record matching v=DMARC1 with a valid p= (#250, see below) |
Trigger verification via POST /api/v1/domains/:id/verify or the dashboard "Verify DNS Records" button, or let the scheduled loop run it (below). The dashboard button (and its Complete rotation) allows 5 DNS checks per domain per minute (src/pages/lib/dns-verify-throttle.ts, shared by every replica with REDIS_URL, #185); the API route is under the per-key API rate limit.
Each check is pass, fail (a definitive answer: the record is missing or does not match) or unknown (the lookup itself failed: SERVFAIL, a timeout, the network, an SPF temperror, or no known sending IP). verified_at is set only when all three pass (#250).
domain-reverify.service.ts re-checks every domain in the background, so a record removed or changed after verification is noticed. It is started at boot (DOMAIN_REVERIFY_ENABLED, default true) and never sends mail.
- Schedule. The worker wakes every 5 minutes (plus or minus 20%, so replicas do not wake in lockstep). A run claims due domains in batches of 50 (at most 10 batches) and checks them 8 at a time with the same code as Verify (
runDnsChecks+applyDomainCheck). After a pass the next check isDOMAIN_REVERIFY_INTERVAL_HOURSlater (default 24, 6 to 168) plus or minus one hour; after a failure or anunknown, one hour later. A new domain (next_check_atNULL) is due at once. - Claim. One statement:
UPDATE domains SET next_check_at = NOW() + 15 minutes WHERE id IN (SELECT id FROM domains WHERE next_check_at IS NULL OR next_check_at <= NOW() ORDER BY next_check_at NULLS FIRST LIMIT n FOR UPDATE SKIP LOCKED) RETURNING *. Concurrent claims return disjoint domains, and the lease means a replica that dies mid-check releases its domains after 15 minutes. - DMARC cache. The loop always looks DMARC up afresh (never the page's status cache) and stores its answer in that cache, except a failed lookup, so a DNS blip never replaces a good cached answer.
- Logs. Each run runs in
runWithLogContext({ job: "domain-reverify", jobId: "drv_..." })and logs one line,Domain re-verification run completedwithchecked,passed,failed,unknown,flipped,recovered,errorsanddurationMs(atdebugwhen nothing was due). Per-domain detail is atdebug; a domain whose check or store throws is logged aterrorand retried when its lease ends.
DNS blips must never stop sending. domain-check-state.ts decides what one check does, for Verify, the inline check of REQUIRE_VERIFIED_DOMAIN and the loop alike:
| Outcome | consecutive_failures |
Flags | verified_at |
Next check |
|---|---|---|---|---|
| All pass | reset to 0 | all true | kept, or set now (event domain.verified) |
interval plus or minus 1 h |
No failure, some unknown |
unchanged | passing checks set, the others kept | kept | 1 h |
| A failure, first in a row | +1 | passing checks set, a true flag stays true | kept | 1 h |
| A failure, second (or later) in a row | +1 | follow the result | cleared (event domain.verification_failed when it was set) |
1 h |
The overall outcome is a failure when any check failed, else unknown when any lookup failed, else a pass. The flip runs in one transaction (applyDomainCheck): the row is locked and re-read, the new state written, and the event enqueued through the webhook outbox (enqueueEvent(tx, ...), #184), so the flip and its event commit together. It also logs Domain verification failed: verifiedAt cleared at warn with the domain id and the failed checks. Both events go to admin keys' webhooks only ({ adminOnly: true }): a domain has no owning API key and managing domains is admin-only, like email.received. Payloads: domain.verification_failed { domainId, domain, failedChecks, checkedAt }, domain.verified { domainId, domain, checkedAt }. See webhooks.md.
With REQUIRE_VERIFIED_DOMAIN=true a send needs the DKIM flag: it stays true through an unknown and through one failure, and turns false at the flip when DKIM is among the failed checks; the send is then refused (after the usual inline re-check). The domain page shows Last checked, each check's last result and reason, and a banner while the domain is failing (two or more failures in a row and not verified).
Migration 0033 cleared verified_at on domains where a flag was false: it used to be set by every Verify, whatever the result.
dmarc-check.service.ts follows RFC 7489 6.6.3:
- The TXT records at
_dmarc.<domain>are read with their chunks joined, and only those matching/^v\s*=\s*DMARC1\s*(;|$)/icount (xv=DMARC1orv=DMARC10do not). - No such record: the organizational domain's record applies (
inherited). More than one, at either name: the check fails (multiple), since receivers then apply no policy. A DNS error, or the 5 s budget the two lookups share running out, iserror, without a further fallback; NXDOMAIN and "no TXT" count as no record. - The tags
p,sp,pct,adkim,aspfandruaare parsed strictly: names and values case-insensitive, unknown tags ignored, an invalid value reported inproblemsand replaced by its default (pct100, alignmentr);ruakeeps onlymailto:URIs. - The check passes when exactly one record has a valid
p=(none,quarantineorreject). The effective policy isspwhen the record is inherited andspis set, elsep.p=none(or an inheritedsp=none) passes with a warning that it only monitors;pctbelow 100 and a missingruawarn too.
The organizational domain heuristic. There is no Public Suffix List dependency: the organizational domain is the last two labels, or the last three when the second-to-last label is a common second-level label (co, com, net, org, gov, ac, edu, ne, or, mil) under a two-letter TLD, so mail.example.co.uk gives example.co.uk. It is wrong for other public suffixes (github.io, pvt.k12.ma.us, blogspot.com...): the fallback then queries a name above the real organizational domain, which normally has no record, so the check reports missing instead of a wrong policy, and the fix is to publish a record at _dmarc.<domain> itself.
The result is returned as dmarc (below). verifyDomain stores dmarc_verified and refreshes the cached check, so the page shows it at once.
spf-evaluator.ts is check_host() of RFC 7208 for one domain and one IP, strict mode only, on dns/promises with no dependency (it replaced mailauth's spf()); its TXT queries go through src/utils/dns-txt.ts, which keeps a record of several strings whole where Bun's resolveTxt splits it (oven-sh/bun#44692):
- Record: exactly one TXT record starting
v=spf1(then a space or the end, any case), its strings joined: none isnone, two or more apermerror. A domain that is not a valid multi-label name isnone. - Syntax: the whole record is parsed before evaluation; any error anywhere is a
permerror, even after a matching term. Terms are separated by spaces only. Mechanismsall,include,a,mx,ptr,ip4,ip6,exists(dual CIDR onaandmx), theredirectmodifier;expis checked for syntax but never evaluated; unknown modifiers must hold a valid macro-string. At most oneredirectand oneexp. - Macros:
s l o d i p v hwith digit andrtransformers and delimiters,%%,%_,%-, uppercase letters URL-escaped;c r t(explanation only) and any other letter are apermerror. An expanded name loses labels on the left until it fits in 253 characters; an expansion that is not a valid name makesa,mx,ptrandexistsnot match and makesincludeandredirectapermerror. - Limits: at most 10 DNS-querying terms (
include,a,mx,ptr,exists,redirect, plus the PTR lookup of each%{p}), counted when evaluated, across includes and redirects; anmxanswer of more than 10 names is apermerror; only the first 10ptrnames are checked; more than 2 void lookups (empty answers to a term's query) is apermerror. Past the deadline (10 s) the result istemperror. - Results:
includemaps the inner result per RFC 7208 5.2 (pass matches; fail, softfail and neutral do not; none is apermerror; errors propagate);redirecttakes the target's result, with none as apermerror. No match and no redirect isneutral. - DNS errors:
ENOTFOUNDandENODATAare an empty answer (Bun reports both NXDOMAIN and no data asENOTFOUND); anything else is atemperror, except inptrand%{p}lookups, where a failed PTR lookup means no match (orunknown) and a failed address lookup skips that name. - Addresses: an IPv4-mapped IPv6 client (
::ffff:192.0.2.1or::ffff:c000:201) is an IPv4 client: it matchesip4terms and A records, neverip6terms. CIDR matching is done on the address bytes.
The tests run the OpenSPF RFC 7208 suite (test/fixtures/spf/rfc7208-tests.yml, BSD-3-Clause, vendored with its license) and BunMail's own cases (bunmail-spf-tests.yml), and compare every case with mailauth's recorded results (mailauth-results.json); see test/unit/spf-evaluator.test.ts for the two intended differences.
domain-dns-status.service.ts gives each domain's current DMARC check and MX status, for the API (GET and verify) and the domain page. Lookups use dns/promises for MX and src/utils/dns-txt.ts for TXT (Bun's resolveTxt splits a multi-string record into several, oven-sh/bun#44692), are cached in memory (an hour, five minutes after an error, at most 10,000 domains) and never fail a request: a DNS failure is an error status. The DMARC check and the MX lookup run in parallel under one overall 5 s deadline per domain; the DMARC fallback to the organizational domain runs after the domain's own lookup but within the same budget, so a request waits at most one timeout. A part still pending at the deadline is reported as error and its lookup keeps filling the cache. Create, update and delete never wait on DNS (dmarc and mx are null). resolveDomainTxt() is the TXT lookup verifyDomain uses by default, so one seam covers both. The MX answer shares the cache of the signed return path (resolveMxStatus in bounces/services/return-path-mx.ts, #236), so the page shows what the mailer does. setDomainDnsDeps() replaces the lookups in tests; the integration run installs an NXDOMAIN stub for everything in test/integration/_preload.ts, so no integration test does real DNS.
mx is { status, hosts }: here (the MX set names MAIL_HOSTNAME), elsewhere (other hosts only), none (NXDOMAIN, no MX or a null MX) or error, with the host names lowercased. It is informational and never required: a domain whose MX serves mailboxes elsewhere (Google Workspace, Microsoft 365) sends normally. With SMTP_ENABLED, mailFlowFeatures() says on the page whether signed return paths, async bounces, DMARC reports and inbound mail are active for the domain: all four need inbound SMTP on and the MX here.
getRequiredDnsRecords(domain, { mailHostname, publicIps }) (pure; defaults from config) returns { type, host, value, purpose, optional, note?, pending? } entries, in this order:
| Type | Host | Value | Notes |
|---|---|---|---|
| TXT | <domain> |
v=spf1 a:<MAIL_HOSTNAME> -all |
merge the a: term into an existing SPF record instead |
| TXT | <selector>._domainkey.<domain> |
the DKIM record as quoted strings of at most 255 characters | left out when the domain has no public key |
| TXT | <pending selector>._domainkey.<domain> |
the pending key's DKIM record, split the same way | only while a key rotation is pending; pending: true (#250) |
| TXT | _dmarc.<domain> |
v=DMARC1; p=none; rua=mailto:dmarc@<domain> |
BunMail stores the reports when inbound SMTP is on and the MX points here; otherwise use an address you read |
| A / AAAA | MAIL_HOSTNAME |
each MAIL_PUBLIC_IP |
described ("this server's public IP address") when MAIL_PUBLIC_IP is unset; once per server |
| PTR | each sending IP | MAIL_HOSTNAME |
set at the hosting provider, not in the zone |
| MX | <domain> |
10 <MAIL_HOSTNAME> |
optional: only to receive bounces, DMARC reports and inbound mail; leave an MX that serves mailboxes as it is |
There is no DMARC report address setting, so the suggestion is dmarc@<domain>.
Creates a domain record and auto-generates a 2048-bit RSA keypair for DKIM signing. The name is normalised and checked (see Domain Name above; a refused one throws InvalidDomainNameError, 422 INVALID_DOMAIN_NAME, #406) and stored in that form; a name already registered in any form throws DomainAlreadyRegisteredError (409 DOMAIN_ALREADY_REGISTERED). Lookups by name (domainNameIs, used by getDomainByName, domainExistsByName, the sender-domain check and the queue's legacy lookup) ignore case and normalise the name the same way; when that changes more than the case, the name trimmed and lowercased as before #406 matches too, so a row stored in that form is still found. The key is generated under input.dkimSelector (default bunmail; an invalid one throws InvalidDkimSelectorError, #250). input.createdByUserId (#219) records who added it: the dashboard user, or over REST the calling key's user (null = the owner). input.trackOpens / input.trackClicks (#187) set the tracking opt-in, off when omitted.
Returns the DKIM DNS TXT record value (v=DKIM1; k=rsa; p=<base64>) for a domain.
Returns all registered domains.
Returns a single domain by ID.
Returns a single domain by its name. Used by the inbound-notification path (#106) to resolve the recipient domain's notify_email + DKIM material.
Sets (or clears, when passed null) the inbound-notification address for a domain (#106). Returns undefined when no row matches the id.
Updates unsubscribeEmail and/or unsubscribeUrl (#243) and trackOpens / trackClicks (#187); only the fields present in changes are written, null clears an unsubscribe override. Called updateDomainUnsubscribe before #187. Used by PATCH /api/v1/domains/:id and the domain page in the dashboard.
Hard-deletes a domain and its keys.
The stored names that creation would now refuse or store differently than by case alone (#406), as { id, name, normalized } (normalized is null when refused). Read-only; the boot logs one warn per entry.
Pure helpers, no database: normalizeDomainName(input) (trim, IDN to A-label, ASCII lowercase, one final dot dropped; never refuses) and parseDomainName(input) (the normalised name, or null when it is not a host name; see Domain Name above). The label and length rule is isLowerAsciiDomain from src/config/sso.ts, shared with SSO's allowed domains (#224); the all-digit top label check is added here.
Pure helpers shared by create, rotation and Verify: generateDkimKeyPair(), isValidDkimSelector() and DKIM_SELECTOR_PATTERN (also the DTO's pattern), datedDkimSelector(now, taken), dkimRecordValue(pem), dkimRecordHost(selector, domain) and dkimRecordsMatchKey(records, pem) (the publish comparison: one record or all of them joined carries v=DKIM1 and p=<key>, whitespace ignored).
startDkimRotation(id, audit, now?),completeDkimRotation(id, audit, deps?, now?),cancelDkimRotation(id, audit): the steps above. Each returns the updated domain, orundefinedfor an unknown id, and throwsDkimRotationError(409withcode) when the domain's state refuses the step. Each locks the row (FOR UPDATE) and writes the change and its audit event in one transaction. Complete looks up DNS before the transaction and, inside it, checks that the pending key is still the one it looked up.checkDkimKeyPublished(domain, selector, pem, deps?): the publish check with its reason; the TXT lookup goes through the domains DNS seam (resolveDomainTxt), sosetDomainDnsDeps()stubs it in tests.
resolveDomainDkim(domain): DkimOptions | undefined: decrypts the domain's stored DKIM key (DKIM_ENCRYPTION_KEY; a plaintext key is used with awarn, a decrypt failure is logged aterrorand yields no key) and returns the signing options, orundefinedwhen there is no usable key. Used by inbound notifications (#106: with no key at all they send unsigned; with a stored key that does not decrypt they are not sent in production, #244) and invitation emails (#217, which then do not send). The queue keeps its own copy of the decrypt step.
Runs SPF, DKIM, and DMARC checks in parallel (runDnsChecks), stores the outcome with applyDomainCheck (the failure rule above), and returns { spf, dkim, dmarc, spfReason, dkimReason, dmarcReason, checks }: the booleans are true only for pass, checks has each check's status and reason. options.source is manual (default) or scheduled (the loop: per-domain logs at debug, a failed DMARC lookup not cached). SPF (#247): the record must exist exactly once, contain no <placeholder>, and give pass for every sending IP (MAIL_PUBLIC_IP, or the A/AAAA records of MAIL_HOSTNAME, from sending-ips.service.ts), evaluated in strict RFC 7208 mode by checkHost() (see SPF evaluation) with sender postmaster@<domain> and HELO MAIL_HOSTNAME; a failed DNS lookup or an SPF temperror is reported as temporary (unknown). The dashboard suggests v=spf1 a:<MAIL_HOSTNAME> -all (suggestedSpfRecord()), to be merged into an existing record. DMARC (#250): checkDmarc() from dmarc-check.service.ts, described above.
The three checks without storing anything. Never rejects on a DNS error: that check is unknown.
checkHost({ ip, domain, sender, helo }, { resolver?, timeoutMs? }): Promise<{ result, reason }>: the evaluation described in SPF evaluation.resultispass,fail,softfail,neutral,none,temperrororpermerror;reasonsays which term matched or what went wrong (for logs and tests).resolver(SpfDnsResolver:resolveTxt,resolve4,resolve6,resolveMx,resolvePtr) defaults todns/promises, withresolveTxtfromsrc/utils/dns-txt.ts;timeoutMsto 10 000. Never rejects for DNS errors; an invalid IP is atemperror.
spf-syntax.ts holds parseSpfRecord(), parseDomainSpec(), expandMacros(), targetName() and isValidDomainName(); spf-address.ts holds parseIp(), clientAddress() (IPv4-mapped to IPv4), inNetwork(), reverseName() and macroIp().
nextDomainCheckState(previous, result, now, intervalMs, random?): the failure rule, pure. Returns the next state, the overall outcome, the next check time, the failed checks and the event (failed,verifiedor none).overallOutcome(result),nextCheckAfterPass(now, intervalMs, random?); constantsFLIP_AFTER_FAILURES(2),RECHECK_AFTER_PROBLEM_MS(1 h),RECHECK_JITTER_MS(1 h).
applyDomainCheck(domainId, result, now?, intervalMs?): one transaction that locks the row, appliesnextDomainCheckState, writeslast_checked_at,next_check_atandlast_check_result, and enqueuesdomain.verification_failedordomain.verifiedthrough the outbox. Logs the flip atwarn.undefinedwhen the domain was deleted meanwhile.
start()/stop(): the worker (no-op whileDOMAIN_REVERIFY_ENABLED=false).runReverifyCycle(deps?): one run, in its own log context; returns the run summary. Does nothing while disabled.claimDueDomains(limit, executor?): the claim described above.
checkDmarc(domain, { resolveTxt, timeoutMs? }): Promise<DmarcCheck>: the DMARC check described above, within one budget (timeoutMs, 5 s) for both lookups. Never rejects. Logs the status and record count atdebug(never the TXT values) and a failed lookup atwarn.parseDmarcRecord(record): ParsedDmarcRecord: strict tag parsing of one record.organizationalDomain(domain): string: the heuristic above.
getDomainDnsStatus(name): Promise<{ dmarc, mx }>: the cached live status. Never rejects.getDomainsDnsStatus(names, concurrency = 8): the same for a list, in order, with at mostDNS_STATUS_CONCURRENCY(8) domains looked up at once (a small worker pool), so listing a large install with a cold cache stays bounded. Used byGET /api/v1/domains.rememberDmarcCheck(name, check): stores a fresh check (used byverifyDomain).mailFlowFeatures(mx, smtpEnabled): the four mail flows withactiveand a one-line explanation.
getRequiredDnsRecords(domain, settings?): the table above.pendingDkimDnsRecord(domain)is the pending rotation's entry alone (ornull).splitTxtValue(value)splits a TXT value into quoted strings of at most 255 characters.
The DNSBL self-check of the sending IPs (MAIL_PUBLIC_IP, or the A/AAAA records of MAIL_HOSTNAME, via resolveSendingIps) against Spamhaus ZEN, Barracuda and SpamCop. Information only: nothing gates on it. Results live in memory, per replica.
start()/stop(): the worker, first run 45 to 75 s after boot, then every 24 hours plus or minus 5% (no-op whileSENDING_IP_DNSBL_CHECK=false).runDnsblSelfCheck(deps?, memory?): one run in its own log context (job: "dnsbl-self-check"), oneDNSBL self-check completedline with the counts;warnwhen an address becomes listed on a zone,infowhen it is delisted, nothing while a state holds.nullwhile disabled.checkAddress(ip, deps?, zones?)/checkSendingIpsDnsbl(deps?): the lookups. IPv4 as reversed octets, IPv6 as reversed nibbles and only on zones that list IPv6 (ZEN), an IPv4-mapped address as its IPv4; addresses that are not publicly routable are skipped. 5 s timeout per lookup.classifyAnswers(zone, answers)/classifyError(error):127.0.0.2to127.0.0.11listed (ZEN codes named: SBL, CSS, XBL, DROP, PBL; PBL marked as a policy listing), any127.255.255.xunavailable(query refused, typically a public resolver), NXDOMAIN or no recordnot-listed, any other answer, error or timeoutunknown.getDnsblStatus():{ enabled, snapshot }for the dashboard home, no lookup.setDnsblDeps(override | null): the DNS seam for tests.
The serializeDomain(domain, dns) function:
- Strips every private key, active and pending, and the public keys (never exposed in API responses)
- Exposes
dkimDnsRecord: the TXT record value users need to add to their DNS (unsplit) - Exposes
dkimPending({ selector, host, dnsRecord, createdAt }ornull) anddkimPrevious({ selector, host, dnsRecord, rotatedAt, advice }ornull) for key rotation (#250) - Adds
dnsRecords(always), anddmarcandmxfromdns, which isnullonDELETE(no lookup for a removed domain) (#250) - Exposes
lastCheckedAtandlastCheckResult({ spf, dkim, dmarc, checkedAt }, each check{ status, reason }, ornullbefore the first check) (#250);next_check_atandconsecutive_failuresstay internal
All routes require Bearer token auth and are rate-limited.
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/domains | Register domain (auto-DKIM) |
| GET | /api/v1/domains | List domains |
| GET | /api/v1/domains/:id | Get domain details |
| POST | /api/v1/domains/:id/verify | Verify DNS records |
| PATCH | /api/v1/domains/:id | Edit unsubscribe settings (#243) |
| DELETE | /api/v1/domains/:id | Delete domain |
| POST | /api/v1/domains/:id/dkim/rotate | Start a DKIM key rotation (#250) |
| POST | /api/v1/domains/:id/dkim/rotate/complete | Switch to the pending key once published (#250) |
| DELETE | /api/v1/domains/:id/dkim/rotate | Cancel the pending rotation (#250) |
POST /api/v1/domains accepts an optional notifyEmail field to set the
inbound-notification address at create time (see below), and an optional
dkimSelector (see Selector above). The name is normalised and checked
(see Domain Name above): 422 INVALID_DOMAIN_NAME when it is not a host name.
Each domain can carry a notify_email. When BunMail's inbound SMTP receiver
accepts a message for a recipient on that domain, it sends a short "you have
new mail" email (New email for <recipient address>: sender, arrival time,
size and, when APP_BASE_URL is configured, a dashboard link; no subject or
preview of the message unless INBOUND_NOTIFY_PREVIEW=true, #250) to the
notify address. Automatic mail is not notified, each address gets at most
5 notifications per 10 minutes and the domain at most 30 (see inbound.md).
The notification is sent from <INBOUND_NOTIFY_FROM_LOCAL>@<domain> (default
notifications@<domain>) and DKIM-signed with the domain's own key, so it
passes the same SPF/DKIM you already set up for outbound.
- Set it from the dashboard (the domain detail page) via
POST /dashboard/domains/:id/notify-email, or at create time with thenotifyEmailfield onPOST /api/v1/domains. An empty submission clears it. - Point it at an external mailbox. A notify address on a domain BunMail itself receives for would loop; such loops (and any mail whose sender domain is a registered BunMail domain) are skipped by the sender-domain loop guard.
- Disable globally with
INBOUND_NOTIFY_ENABLED=false(operator kill switch); per-domain, just leavenotify_emailempty. - Bounces (DSNs) and DMARC aggregate reports never trigger a notification: they are routed away before the inbound store.
Each domain carries an opt-in for open and click tracking, track_opens and track_clicks, off by default. Opens are counted with a 1x1 pixel and clicks through a signed redirect on APP_BASE_URL (tracking.md).
- Who sets it: admins only. Over REST with an admin key (
trackOpens/trackClicksonPOST /api/v1/domainsandPATCH /api/v1/domains/:id; every domain route is admin-only), or on the domain page in the dashboard (two checkboxes,POST /dashboard/domains/:id/tracking,adminGuard; a box left unticked turns that kind off). Every change is audited asdomain.updatedwith the field names. - Per domain, not per key: the domain is the reputation and disclosure unit, and keys share domains. A send can opt out (
tracking: { opens: false }) but never turn on what the domain has off, whatever its key's role. - Not alone: tracking also needs
TRACKING_ENABLED=trueon the server, exactly one recipient and anhttps://APP_BASE_URL;transactionalmail is tracked only when the send asks. See emails.md. - Queued mail keeps its decision: it is made when the email is created and stored on it, so changing the opt-in affects mail created afterwards only.