Your own email-sending service — a free, self-hosted alternative to Web3Forms / Formspree.
Put mailstack between your website's contact form (or your app) and your inbox. It catches the submission, blocks spam, and sends you a clean, branded email. Run it on Cloudflare Workers with D1 and Amazon SES, or in Docker with Node, SQLite and SES or SMTP. Each brand has its own sender, inbox, allowed origins and theme.
Requires Docker Compose and Node 24+ for key generation. Prepare the local files once:
npm ci
cp config/mailstack.example.json mailstack.json
mkdir -p secrets
chmod 700 secrets
npm run --silent keys -- init secrets/panel-private.json > .env
chmod 600 .envKeep secrets/panel-private.json separately from the data volume and its backups; the API
only needs the public key and index key printed into .env. Keys are never baked into the image.
Edit mailstack.json with your brand settings. Select the global sender with
"sender": {"type": "smtp"} or "sender": {"type": "ses"} (the default).
For trusted requests append a token assignment without printing it:
node -e 'console.log("API_KEY=" + require("node:crypto").randomBytes(32).toString("hex"))' >> .envFor SMTP append SMTP_URL=smtps://user:password@smtp.example.com:465, or use
SMTP_HOST, SMTP_PORT (default 587), SMTP_USER, SMTP_PASS, SMTP_SECURE=true|false
(default false). With credentials and secure=false, STARTTLS is required;
certificate verification remains enabled. Without credentials, STARTTLS remains opportunistic. URL credentials must be percent-encoded.
For SES set SES_REGION, SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY instead.
Public forms also need TURNSTILE_SECRET or ALTCHA_HMAC_KEY for their configured captcha.
Start the API with one command:
docker compose up --build -d --waitThe API is published on 127.0.0.1:8080 by default. For public traffic, put a reverse
proxy in front and configure TRUSTED_PROXY as described below, or deliberately set
MAILSTACK_BIND=0.0.0.0 to publish on all host interfaces. /health reports store: "ok" after a database query;
ok remains true if storage goes down, while the container healthcheck reports unhealthy.
For diagnostics, store: "ok" proves database reads only, not writes or email delivery:
requireLog: true can return 503 log_unavailable on read-only storage or a busy writer
while /health still returns 200 with store: "ok". Check the send response and
storage permissions or write locks; after restoring writes, verify a controlled send.
Use MAILSTACK_CONFIG to mount a different configuration file and MAILSTACK_PORT to
change the published port. The container runs as a non-root user on a read-only root filesystem;
only /data and the /tmp tmpfs are writable. The named data volume survives container restarts.
No panel listener is included yet.
TRUSTED_PROXY is unset by default: the socket address is used and incoming
CF-Connecting-IP and X-Forwarded-For are overwritten. Set TRUSTED_PROXY=cloudflare
only when requests arrive through a trusted Cloudflare proxy that supplies CF-Connecting-IP.
Set TRUSTED_PROXY=proxy for exactly one trusted reverse proxy hop: that proxy must
append the real client address to X-Forwarded-For; the API uses the last entry.
Restrict direct access to the API when trusting either header. Missing or invalid address
headers fall back to the socket address. Unknown TRUSTED_PROXY values stop startup.
Retention first runs after 30 seconds, then hourly. Startup reads the newest backup mtime
from mailstack-*.db in /data/backups and immediately creates a snapshot if none exists
or the last is at least 24 hours old. Daily SQLite snapshots keep seven copies;
deleted message data can remain in those backups for up to seven days. Back up the data
volume and keys separately. SIGTERM stops accepting requests, drains requests and background
work, stops maintenance, and closes SQLite. Configure a longer stop_grace_period if needed.
To try SMTP locally, select smtp in the config and run:
docker compose -p mailstack-smtp -f docker-compose.yml -f docker-compose.test.yml up --build -d --waitMailpit is available at http://localhost:8025. Load the local token, then send twice
with the same idempotency key; the second response has idempotent: true and Mailpit
contains one message:
set -a
. ./.env
set +a
curl -sS http://127.0.0.1:8080/health
for attempt in 1 2; do
curl -sS http://127.0.0.1:8080/v1/send \
-H 'content-type: application/json' \
-H "authorization: Bearer $API_KEY" \
-H 'Idempotency-Key: demo-smtp-1' \
-d '{"brand":"acme","template":"notice","to":"buyer@example.com","heading":"Demo","paragraphs":"Hello"}'
doneUse a fresh idempotency key for each new logical message. A send records the final SMTP response and
queue identifier, with sealed evidence labelled smtp-response. This proves relay acceptance;
it does not prove delivery to the recipient. SMTP refusals become send_failed; network
exceptions become unknown and are never retried automatically. With multiple recipients,
partial acceptance remains a successful send and per-recipient events record refusals.
Remove the disposable test environment with
docker compose -p mailstack-smtp -f docker-compose.yml -f docker-compose.test.yml down -v --rmi local.
For Node without Docker (load .env into the environment first): npm run build:node, then MAILSTACK_CONFIG=/path/to/mailstack.json npm start.
The process needs a writable /data directory. Configuration is read and validated at startup;
invalid configuration stops the process before listening. The Node bundle includes no brand configuration.
Pushing a v* release tag runs typecheck and tests, then builds the image for
linux/amd64 and linux/arm64. CI smoke-tests the amd64 image with read-only example
configuration and SQLite on tmpfs; /health must report ok: true and store: "ok"
before the same OCI archive is published to ghcr.io/jurczykpawel/mailstack.
Tags are the version without v (for example 1.2.3), sha-<full-commit-sha>, and
latest for stable vMAJOR.MINOR.PATCH releases only. Prereleases do not update latest.
OCI labels record the source, revision, release version and MIT license. Configuration
and secrets are supplied at runtime. The complete publication path will be verified
on the first release tag.
- Someone fills in the contact form on your website.
- mailstack checks they're a real person (an invisible spam check) and that the request really came from your site.
- It builds a tidy email and sends it to your inbox.
- (Optional) it sends the visitor an automatic "thanks, we got your message" reply.
You can also call it from your own app to send things like "payment confirmed" or "welcome" emails. One mailstack can serve several websites/brands at once, each with its own sender address and look.
Your website form ──▶ mailstack (runs on Cloudflare) ──▶ your inbox
│ ✓ real person? (Cloudflare Turnstile)
│ ✓ from your site? (allow-list)
└─ sends a branded email via Amazon SES
The Worker deployment runs on Cloudflare. The Docker deployment runs on your own server.
The Worker path needs the following accounts; provider usage limits and charges apply.
| You need | What it's for | Cost |
|---|---|---|
| A terminal + Node.js (v24+) | to set up & deploy (copy-paste commands) | free |
| A free Cloudflare account | runs mailstack + the spam check | free |
| An Amazon (AWS) account | actually sends the emails (Amazon SES) | ~free at low volume |
| ~30–45 minutes, once | first-time setup | — |
| (optional) your own domain | a nicer URL + sending "from yourname.com" | — |
Not a developer? You'll still open a terminal and paste a handful of commands — that's unavoidable for a self-hosted tool — but every step below is spelled out and explained.
- Install Node.js (the "LTS" version) from nodejs.org.
- Get this project: on GitHub click Code → Download ZIP (or run
git clone <repo-url>), unzip it, and open a terminal in that folder. - Install the project's bits:
npm install
- Sign up at dash.cloudflare.com/sign-up (free).
- Connect your computer to it:
This opens your browser — click Allow. (
npx wrangler login
wrangleris Cloudflare's command-line tool; it was installed bynpm install.)
SES (Simple Email Service) is Amazon's email service. We use it so your emails reach inboxes instead of the spam folder, for a tiny cost.
- Create a free account at aws.amazon.com.
- In the AWS console, open Amazon SES and choose a region near you (e.g. Europe (Ireland) — eu-west-1). Note which one — you'll need it in step 5.
- Verify the address your emails will come FROM (e.g.
hello@yourdomain.com, or even a Gmail address to start): SES → Identities → Create identity → enter the email → SES sends you a confirmation link → click it. (Advanced: verify a whole domain to send from any address on it.) - New SES accounts start in "sandbox" mode — they can only send to addresses you've also verified. For a contact form that emails your own inbox, just verify your inbox too and you're good to go. To email anyone (e.g. customers), open Account dashboard → Request production access (a short form, usually approved within a day).
- Create a sending key: AWS console → IAM → Users → Create user → attach the a policy allowing ses:SendEmail for your verified sender identities → open the user → Security credentials → Create access key. Copy the Access key ID and Secret access key somewhere safe (you'll paste them in step 7).
- Cloudflare dashboard → Turnstile → Add widget.
- Add your website's domain(s), and add
localhostso you can test locally. - Create it, then copy the Site key (public — goes on your web page) and the Secret key (private — goes in step 7).
This caps how often someone can submit, to stop abuse.
npx wrangler d1 create mailstack
npx wrangler d1 create mailstack-previewEach command prints an id — copy both. Then create your settings file:
cp .env.example .envOpen .env and paste the two ids (leave the domain empty for now):
MAILSTACK_D1_ID=<the first id>
MAILSTACK_D1_PREVIEW_ID=<the second id>
MAILSTACK_ROUTE=
After configuring brands in step 6, step 7 generates the SQL migration. Apply it locally for development, or remotely before deployment.
Copy config/mailstack.example.json to a private location outside this repository, then
export MAILSTACK_CONFIG pointing to that file. Use Node 24 or newer (tested on Node 26).
Replace the acme entry under brands with the entry below (keep the surrounding
configuration and other brands):
{
"acme": {
"id": "acme",
"name": "My Website",
"from": "My Website <hello@acme.example>",
"to": ["inbox@acme.example"],
"allowedOrigins": ["https://acme.example"],
"subjectPrefix": "New message from",
"autoReply": true,
"theme": { "accent": "#0064BC", "siteUrl": "https://acme.example" }
}
}You can rename acme to anything (e.g. mysite). Remember that id — you'll use it when you
connect your form in step 9.
npm run setup # bundles MAILSTACK_CONFIG and prepares Wrangler config
npx wrangler d1 migrations apply DB --local # development database
# Before deployment instead: npx wrangler d1 migrations apply DB --remote
npx wrangler secret put SES_ACCESS_KEY_ID # paste the AWS Access key ID (step 3)
npx wrangler secret put SES_SECRET_ACCESS_KEY # paste the AWS Secret access key
npx wrangler secret put TURNSTILE_SECRET # paste the Turnstile Secret key (step 4)
npx wrangler secret put API_KEY # a long random password (for app-to-app sends)
npx wrangler secret put ALTCHA_HMAC_KEY # needed for ALTCHA brands
npx wrangler secret put CONTENT_PUBLIC_KEYS # JSON JWK set printed by npm run keys
npx wrangler secret put INDEX_KEYS # JSON index-key array printed by npm run keysGenerate logging keys with npm run keys -- init secrets/panel-private.json after
creating a private secrets directory (see keys). Paste only
the JSON values of the public/index assignments into the corresponding Worker secrets;
keep the private file separate. Local Worker development needs both JSON values in
.dev.vars as well.
Need a random API_KEY? Run openssl rand -hex 32 and paste the result.
Use a clean checkout at origin/main, or a tag reachable from it. The deploy guard
fetches origin before setup and passes HEAD to Wrangler as BUILD_COMMIT.
export MAILSTACK_HEALTH_URL=https://mail.example.com/health
npm run deployCloudflare gives you a URL like https://mailstack.<your-name>.workers.dev. Check it works:
curl https://mailstack.<your-name>.workers.dev/health
# → {"ok":true,"service":"mailstack","version":"1.0.0","commit":"<HEAD sha>"}The command fails if the deployed commit differs from HEAD. With no exported
MAILSTACK_HEALTH_URL, it explicitly reports that verification was skipped.
That's your live email service.
Add the spam widget and point the form at mailstack. A minimal example:
<form id="contact">
<input name="name" required placeholder="Your name">
<input name="email" type="email" required placeholder="Your email">
<textarea name="message" required placeholder="Message"></textarea>
<!-- Cloudflare Turnstile spam check (use your Site key) -->
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY"></div>
<button>Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
document.getElementById("contact").addEventListener("submit", async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
data.brand = "acme"; // your brand id from step 6
data["cf-turnstile-response"] = e.target.querySelector("[name=cf-turnstile-response]").value;
const res = await fetch("https://mailstack.<your-name>.workers.dev/v1/send", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(data),
});
alert((await res.json()).success ? "Sent — thank you!" : "Something went wrong.");
});
</script>Submissions now arrive in your inbox. 🎉
Brands may set captcha: "altcha" instead of using Turnstile. Set the Worker's
ALTCHA_HMAC_KEY secret and configure the widget's challenge URL as
https://mail.example.com/altcha/challenge?brand=demo. Send its base64 solution in
the altcha field of POST /v1/send.
Each challenge has a signed expiry of 10 minutes. A solution without a valid expiry,
an expired solution, or an already recorded solution returns 403 verification failed.
The Worker atomically records usage in D1 before sending; missing storage or storage
errors also return 403. Fetch a new challenge for each submission or retry, including
after send failed. Records expire at the signed expiry; concurrent submissions of
one solution allow only one submission to proceed. Trusted sends skip guards.
The D1 rate limiter allows 8 attempts per brand/IP/hour and degrades open when storage
is missing or fails; public requests still require captcha. ALTCHA single-use recording
remains fail-closed: missing storage or storage errors return 403.
For a public form with a product selection, a brand may opt into requireNonce:
{ "captcha": "altcha", "requireNonce": true, "products": ["Demo Product"] }requireNonce defaults to false when omitted. products is an optional array of
allowed product names, each 1-100 characters. Existing brands keep the flat challenge
response. For a brand with requireNonce: true, request
GET /altcha/challenge?brand=demo&product=Demo%20Product from an allowed origin.
An absent or unlisted product returns 400. The response is:
{ "altcha": { "algorithm": "SHA-256", "challenge": "...", "maxnumber": 100000, "salt": "...", "signature": "..." }, "nonce": "..." }Solve the nested altcha challenge and submit flat JSON to POST /v1/send:
{ "brand": "demo", "email": "viewer@example.com", "src": "demo-video", "product": "Demo Product", "nonce": "...", "altcha": "<base64 solution>" }The nonce uses ALTCHA_HMAC_KEY, is valid for 10 minutes, and carries the brand and
product. product in POST is optional; when present, it must match the nonce exactly.
The product must still be in the brand's configured list when submitting. Mailstack
sets type from that product, including for the confirmation email when auto-reply
is enabled. Only public requests for brands with requireNonce: true treat nonce
and product as control fields and omit them from email content. For other brands
and all trusted requests, both fields remain ordinary template content.
Mailstack atomically consumes the nonce before checking ALTCHA, including when ALTCHA
is invalid. Fetch a new challenge and nonce for every attempt, including after a failed
delivery. Nonce, ALTCHA and origin rejection returns 403 verification failed for
these brands; missing or unavailable nonce storage returns 503. Rate limits still
return 429, and trusted sends skip nonce checks.
For server-to-server emails (no spam check needed), call the same endpoint with your API_KEY
and pick a template:
curl -X POST https://mailstack.<your-name>.workers.dev/v1/send \
-H "content-type: application/json" \
-H "authorization: Bearer YOUR_API_KEY" \
-d '{
"brand": "acme",
"template": "payment",
"to": "buyer@example.com",
"name": "Jane Smith",
"amount": "49.99", "currency": "USD", "item": "Pro Plan", "orderId": "ORD-1001"
}'Built-in templates: payment, refund, welcome, received, notice, license (and contact for forms).
license accepts name, productName, tier, domain, licenseKey, activationUrl
and instructions; licenseKey and activationUrl are declared secret fields.
Cloudflare, SES, SMTP hosting and your server have separate usage limits and pricing. Check your providers before deployment; mailstack itself is MIT-licensed.
After editing the configuration, validate it with npm run mailstack -- config check "$MAILSTACK_CONFIG".
Restart the Docker service with docker compose up --build -d --force-recreate, or use
the guarded Worker deploy from a clean release checkout. Keep runtime images and backups current.
- Email not arriving? You're probably still in SES "sandbox" — verify the recipient address (or request production access). Also check your spam folder.
- "verification failed"? The Turnstile widget wasn't solved, or your Site/Secret keys don't match.
- "origin not allowed"? Add your site's address to
allowedOriginsinmailstack.json, redeploy. - "send failed"? Your
fromaddress isn't verified in SES, or the SES region doesn't match where you verified it (the region isSES_REGIONinwrangler.template.toml).
- Multi-brand — many tenants in one Worker; send requests select
brandfrom the body or query; preflight resolves?brand=orOrigin. - Public recipient — public mail goes to
brand.to; the full API key may setto(scoped Pro clients needrecipientOverride: true). - One full API key, free — additional and restricted keys are a Mailstack Pro feature; see API keys: free and Pro.
- Two modes — public (Turnstile + origin + honeypot + rate limit) and trusted (Bearer token).
- Template types —
contact,welcome,received,payment,notice,license; one branded layout. - Footer branding — change
MAILSTACK_HOME_URLinsrc/templates/layout.tsto customize the mailstack link in HTML and plain-text emails. - Auto-reply — optional confirmation to the submitter, sent non-blocking via
ctx.waitUntil. - Sellf webhook source — maps e-commerce events to branded emails (
/v1/hooks/sellf). - D1 storage — atomic rate limits and ALTCHA usage records. Secret scanning in pre-commit + CI.
Nothing deployment-specific is committed. npm run setup (also run before dev/deploy)
generates these files. Test/typecheck hooks use the committed JSON example:
| Generated (gitignored) | From | Holds |
|---|---|---|
wrangler.toml |
wrangler.template.toml + .env |
D1 ids + custom domain (MAILSTACK_* vars) |
src/core/config/bundled.ts |
external MAILSTACK_CONFIG JSON |
Validated brands and logging policy + SHA-256 |
.wrangler/migrations/0001_initial.sql |
src/core/store/migration.ts |
Shared schema for Wrangler D1 migrations |
src/assets/logo.ts |
src/assets/logo.example.ts |
Brand logo PNG (base64) |
Configure via .env and the external MAILSTACK_CONFIG file; secrets via npx wrangler secret put. Full
deployment notes and architecture: see AGENTS.md.
export MAILSTACK_CONFIG="$PWD/mailstack.json"
npm run mailstack -- config check "$MAILSTACK_CONFIG"Scoped clients are created with the Mailstack Pro CLI (node scripts/client.ts create in
mailstack-pro); see API keys: free and Pro.
The importer executes a trusted local TypeScript registry and writes JSON only to stdout.
Move its output outside the repository and set MAILSTACK_CONFIG before npm run setup.
version, brands (the previous Brand fields), and logging are required. clients,
legacyApiKey, events, and alerts are optional sections. Unknown keys in declared schemas
are errors, reported as JSON paths. Logging policies inherit global → brand → template;
retention ceilings and the resend/includeSecrets combinations are validated before startup.
source needs a matching client callback for each effective brand/template. The loaders
read classification from the template registry (secretFields is optional).
Enabling includeSecrets also requires a boot audit sink. Both current runtime loaders
refuse it; keep it false. The optional alerts section is reserved and is not interpreted;
ntfy uses runtime variables and policy alertOn. sender.type selects ses or smtp
on Node; Worker sends through SES.
Node reads the file through loadNodeConfig(). Worker setup requires the config outside the repository, validates and bundles that
file and sets BUILD_CONFIG_SHA in Wrangler vars. Its build step verifies that the source
still matches the bundle; rerun setup after editing the source. npm test and
npm run typecheck generate the committed example configuration locally, without reading
private brands. Run npm run setup again with your external config before a real build.
The JSON example and render snapshots contain only acme/demo data.
This is a complete mailstack.json for the local SMTP example. Copy it to the private
config file; replace the example sender/origins for deployment. For SES choose
"sender": {"type": "ses"} and supply SES credentials. The optional SES event
allowlist below is illustrative; replace both values before subscribing a real topic.
{
"version": 1,
"sender": { "type": "smtp" },
"brands": {
"acme": {
"id": "acme", "name": "Acme", "from": "Acme <hello@acme.example>",
"to": ["inbox@acme.example"], "allowedOrigins": ["https://acme.example"],
"subjectPrefix": "New message", "autoReply": false,
"theme": { "accent": "#0064BC", "siteUrl": "https://acme.example" }
}
},
"legacyApiKey": { "enabled": true, "attachments": true },
"logging": {
"defaults": {
"level": "meta", "contentDays": 30, "metaDays": 90, "requireLog": true,
"resend": "off", "alertOn": ["send_failed", "unknown"],
"show": [], "includeSecrets": false
},
"ceilings": { "contentDays": 90, "metaDays": 400, "auditDays": 400 },
"patterns": [{ "name": "url-parameters", "urlParams": ["token", "key", "code"] }],
"brands": {
"acme": { "templates": {
"payment": { "level": "masked", "show": ["amount", "currency", "orderId"] },
"license": { "level": "masked", "requireLog": true, "show": ["productName", "tier"] }
} }
}
},
"events": { "ses": {
"topicArns": ["arn:aws:sns:eu-west-1:123456789012:mailstack-events"],
"accountIds": ["123456789012"], "allowSignatureV1": false
} }
}The legacy key has attachment permission here solely for the local attachment example.
The free core authenticates exactly one full key: the secret in API_KEY (or the
variable named by legacyApiKey.env). It has scopes send and hook:sellf, every brand
and template, and may override to. Free legacyApiKey options are enabled, env,
attachments and signatureSecretEnv (required X-Sellf-Signature on /v1/hooks/sellf,
see Sellf webhook). scopes, brands, templates and
recipientOverride may only restate the full values; any restriction fails validation with
additional or restricted API keys require Mailstack Pro.
Everything beyond that single key is Mailstack Pro (valid license):
additional clients with their own tokens, brand/template/scope restrictions,
recipientOverride: false, rate, resendCallback, and the panel client. Pro registers
a key provider through the extension keys hook; the core asks it only about tokens other
than the full key. Without a license Pro rejects those tokens with 401 while the full key
keeps sending. A configuration containing clients stops startup when no loaded extension
provides keys ($.clients: additional or restricted API keys require Mailstack Pro).
The Worker loads no extensions: without MAILSTACK_PRO_PATH, npm run setup rejects clients
and only the full key works there. With MAILSTACK_PRO_PATH=/abs/path/mailstack-pro in .env,
setup validates clients and the mailstack-pro extension options through Pro, generates the
gitignored src/core/pro.generated.ts and migration 0003_pro_license_cache.sql (license cache in
D1). Set the license with wrangler secret put MAILSTACK_LICENSE; options are product, host,
seller, jwksUrl and revocationUrl (no cachePath, webhooks or aws). A stale cache is
refreshed after the response; scoped keys keep working for seven days without the issuer.
npm run deploy then also requires a clean Pro checkout at its origin/main (or a reachable tag)
and reports it as proCommit in /health.
The input must export JSON-compatible BRANDS; the importer executes trusted local
TypeScript in a child Node process with the same permissions and environment. This
is not a sandbox. Standard output and error from the module (including
console.log, process.stdout.write and deferred diagnostics) go to CLI stderr;
stdout contains only the validated configuration JSON on success. Import, export
serialization or validation failures return a nonzero exit code with empty stdout.
The registry transfer is limited to 1 MiB; modules must finish their pending work
before the command returns. From
the repository root, with your old registry saved as legacy/brands.ts:
node scripts/mailstack.ts config import-brands ./legacy/brands.ts > mailstack.json
node scripts/mailstack.ts config check ./mailstack.json
export MAILSTACK_CONFIG="$PWD/mailstack.json"The importer preserves the registry and adds the example logging defaults. Review
clients, captcha settings and per-template policies, then move the file to your private
configuration location and update MAILSTACK_CONFIG. Node loads it on restart; Worker
needs npm run setup and a build/deploy. Do not restore src/brands.ts or edit generated
bundles. Keep credentials in runtime secrets, outside this JSON.
-
GET /health→{ ok: true, service: "mailstack", version, commit }(commit: "dev"locally). This is liveness, not readiness; itsstore: "ok"field proves reads, not writes or delivery, and read-only storage can still causerequireLog: truesends to return503. -
POST /v1/send— public (browser form, needs Turnstile) or trusted (Bearer token). Control keys:brand(body or query),cf-turnstile-response,botcheck,subject/to/replyTo/template(trusted only),autoreply(public contact), plus the reserved fields documented below. Empty or omitted bodybrandfalls back to?brand=<id>; different nonempty values return400 brand mismatch. Public requests may use ALTCHA instead of Turnstile; opted-in brands also require a nonce. -
POST /v1/hooks/sellf— Sellf webhook source (Bearer required, optional client signature). See AGENTS.md. -
POST /v1/events/ses— SNS subscription and SES delivery events. Returns{ ok: true, result: "processed" | "duplicate" | "ignored" | "confirmed" }.
When upgrading an existing configuration, add refund alongside payment,
notice and welcome in the Sellf hook client's templates list. Otherwise
refund.issued returns 403 with code scope. A "*" selection already includes it.
The first purchase.completed payment email includes every supplied licenses[]
entry, each with its full key in a separate code box and in plain text. Names come
from product and bundleComponents by product ID; an unknown ID is displayed
explicitly. Bump products do not implicitly receive licenses. Missing or empty
licenses means an ordinary payment email. Invalid entries, repeated product IDs,
more than 32 entries, or more than 48 KiB of serialized license data return
422 invalid_event_data before recording or sending. The hook body remains capped
at 64 KiB. Keys are never silently shortened, including when their total exceeds
5000 characters. License dates are not derived from tokens.
The owner may configure licenseActivationUrls on each brand as a product-ID to
HTTP(S)-URL map. Omission is equivalent to an empty map. Use exact, owner-managed
product settings pages, with no license tokens in their URLs:
"licenseActivationUrls": {
"bundle": "https://source.test.example/activate/bundle",
"component": "https://source.test.example/activate/component"
}The example acme brand demonstrates both routes; demo has an empty map. Replace
these synthetic routes in your external config with your own product settings
pages. The email tells the buyer to paste each key into the matching product's
license settings and activate each component separately. Without a configured
page, it gives the same complete instructions and the brand inbox for help,
without an activation button. JWKS, CRL, custom domains and token contents never
supply activation links. Configured activation URLs and individual keys use the
payment template's optional secret fields in standard archives. Existing payment
resend: "replay" policies remain valid for ordinary payments. When a purchase
contains a key or activation URL, core stores no replay payload or replay MAC and
disables replay for that message. Use off or an explicitly supported source
callback for those purchases.
Purchase order IDs, correlation refs and automatic deduplication prefer PI, falling back to CS. This changes the key for purchases carrying both IDs. At an upgrade, coordinate pending producer retries over the 24-hour deduplication window: an old CS-keyed purchase and a new PI-keyed retry can be distinct sends. No historical jobs are guessed or migrated. Refund identifiers are unchanged by this rule.
For local review, generate synthetic first-email fixtures with
node test/generate-purchase-license-fixtures.ts <output-directory> and serve that
directory locally. The index provides configured/unconfigured emails and full
expected keys for copy checks at 390 and 1440 px. The isolated test fixture serves
https://source.test.example/activate/bundle and /activate/component, where a
buyer can paste the complete synthetic key and confirm the matching product.
These pages are explicitly demonstrations, with no provider activation. Browser
review and real provider integration are separate checks.
POST /v1/hooks/<id> selects sources.<id> from mailstack.json. Omitting
sources installs the built-in Sellf preset; an explicit object replaces the source
registry ({} disables all sources). The example configuration includes Sellf.
Copy docs/sources/sellf.json under sources.sellf to
customize it, for example:
{"sources":{"sellf":{"signature":{"scheme":"t-v1","header":"X-Sellf-Signature","toleranceSeconds":300},"envelope":{"event":"event","timestamp":"timestamp","data":"data"},"events":{"waitlist.signup":{"action":"send","template":"notice","to":"data.customer.email","fields":{"heading":{"format":"Waitlist"},"paragraphs":{"format":"Thanks for joining {data.product.name}."}}}}}}}Each send map declares a template, recipient path, fields, optional ref and
idempotencyKey selections. Startup rejects unknown templates, invalid paths and
maps missing required template fields. ignore acknowledges without sending;
reject returns 422 unsupported_event; unlisted events are ignored.
Bearer plus hook:<id> scope and brand/template permissions are always required.
Signatures support t-v1, sha256-prefix and bearer-only; signing is additional
when the key names a runtime secret (legacyApiKey.signatureSecretEnv for the full key's
hook:sellf, or a Pro client's reference). Configure no signing reference for
bearer-only. Source definitions contain secret references only through keys.
Fields use plain dotted paths (including numeric indices), {"join":["path"]},
{"first":["preferred.path","fallback.path"]} (first nonblank scalar),
{"format":"Text {data.product.name}"} (path placeholders only), or
{"path":"data.order.amount","as":"money-minor"}. Other closed transforms are
upper, date-pl (runtime timezone) and map with typed lookup keys:
{"path":"data.refund.isFullRefund","as":"map","map":{"boolean:true":"true","boolean:false":"false"}}.
Map keys must use boolean:true, boolean:false, number:<String(value)> or
string:<exact value> (including string: for an empty string). Thus boolean
true, number 1 and strings "true"/"1" are distinct; untyped keys must be
migrated before startup: replace an old "true" key with "boolean:true" for a
boolean input or "string:true" for text; replace "1" with "number:1" for a
number or "string:1" for text. To accept both types, declare both keys explicitly.
There is no automatic type inference or migration; update custom preset copies too.
Missing, null, structured, nonfinite and unmatched values map to empty.
Numbers use JavaScript's canonical string representation; -0 and 0 share number:0.
Minor units use division by 100 and toFixed(2), preserving the former Sellf
adapter's rounding, including near the safe integer limit. ref and
idempotencyKey accept a path or first. No expressions or executable code are
accepted. General limits: 64 KiB body, depth 20, 40 mapped fields, 5000 characters
per value. transform: "purchase-licenses" is the single built-in Sellf transform:
it calls the existing license formatter on envelope data after validation. Its
output uses the existing license limits rather than general field/count limits.
Other custom behavior belongs in an operator-installed runtime extension.
The hook requires an object envelope with a nonblank string event, a valid ISO
8601 timestamp including a timezone, and a non-null, non-array object data.
Malformed JSON returns 400 invalid JSON; a malformed envelope returns
400 with code: "invalid_envelope". Authentication, configured HMAC, scope and
64 KiB body limits apply before delivery. Extra custom fields are allowed.
Mailstack checks sender authentication, the recipient address and nonblank mapped
requiredFields declared by the selected template. It does not validate the
sender's business rules: refund status, cumulative totals, full/partial consistency,
nonnegative amounts, three-letter currencies or matching currencies. Sellf must
validate those before sending. Scalar field values become strings; objects and
missing values become empty. Amount transforms require safe integer minor units
and produce two decimal places with a dot (4999 -> 49.99).
Missing required output returns 422 invalid_event_data, with template keys such
as amount (recipient errors use data.customer.email), never submitted values.
Validation follows mapping and precedes storage, deduplication and provider calls.
| Event | Outcome and template requirements beyond a valid customer email |
|---|---|
purchase.completed |
payment: nonblank amount, currency, item and orderId. PI preferred, CS fallback. Optional licenses retain the existing 32-entry/48 KiB validation and configured activation URLs. |
refund.issued |
refund: amount, currency, productName, transactionId (PI preferred, CS fallback), paymentId, refundId, isFullRefund, totalRefunded and refundedAt. Only boolean isFullRefund maps to a scope: true means pełny, false means częściowy. Missing/null/text (including "true") maps to empty and returns 422 invalid_event_data with field isFullRefund before recording or sending; no scope is guessed. Status is not inspected; the template describes a completed refund. Send only completed refunds. |
waitlist.signup |
Signup notice with configured heading and paragraphs; product name is optional interpolation. |
lead.captured |
Welcome email with access intro; no required template fields. |
access.expired |
Expiry notice with configured heading and paragraphs; product name is optional interpolation. |
subscription.created |
422 unsupported_event |
subscription.updated |
422 unsupported_event |
subscription.canceled |
422 unsupported_event |
subscription.trial_ending |
422 unsupported_event |
subscription.renewal_upcoming |
422 unsupported_event |
invoice.paid |
422 unsupported_event; source invoice.amountPaid is in major units. subscription contains only stripeSubscriptionId, without status or paid period. |
invoice.payment_failed |
422 unsupported_event; source invoice amounts are in major units. |
license.revoked |
422 unsupported_event; source recipient is license.email, with license.id, order, revokedAt and nullable/optional expiry. No token is included. |
| Any other event | 200 {success:true,ignored:"<event>"} after envelope validation; no email or message id. |
For the Sellf webhook endpoint, select only events supported by Mailstack in
Sellf's sender settings (see the table above). For purchase and refund emails,
select only purchase.completed and refund.issued; enable other supported events
only when needed. Leave the known unsupported events unchecked: Mailstack returns
422 unsupported_event, which Sellf treats as a retryable error (observed retries,
up to five attempts). The hook continues to return 422 for these events.
Known unsupported events return {success:false,code:"unsupported_event",message:...}
with the event name for both complete and incomplete data objects. This is a
permanent configuration error: disable those events in the sender; retry alone
will not help. This does not declare implemented renewal or revocation support.
The source Stripe dispute path emits only license.revoked for licenses actually
revoked. With no license rows there is no notification. Its payload has no dispute
reason/status, so it cannot establish a complete chargeback notification contract.
No subscription periods or revocation tokens are invented by the adapter.
The hook awaits renderAndSend and returns its trusted delivery response for both
success and failure. With a message row, first success is
200 {success:true,id:"msg_..."}; duplicates return the same id plus
idempotent:true, without another send. With logging level none, there is no id;
a retained reservation still returns idempotent:true on duplicate. Public /send
responses keep their existing shape.
After losing an ACK, retry the identical event: a completed logged attempt returns
its original id and idempotent:true. A concurrent logged attempt returns
409 in_progress; wait and retry the same event. 409 outcome_unknown requires
provider reconciliation, and 409 idempotency_conflict requires resolving the
changed request; do not send again under a new key. 502 send_failed after explicit
provider refusal permits retry of the same event after the provider issue is fixed.
An exception/timeout can instead leave an unknown outcome. 503 log_unavailable
means required intent recording failed without sending: restore logging and retry
the same event. Fix 400/401/403/413/422 input/configuration errors before retrying.
SMTP success means relay acceptance, not final delivery. These guarantees require
available storage and follow the shared logging/idempotency policy below.
Configure events.ses with topicArns (SNS ARN list), accountIds (12-digit account list),
and optional allowSignatureV1 (default false). SNS topics should use SignatureVersion 2.
The endpoint accepts bodies up to 256 KB. Invalid envelopes return 400, unverified messages
403, oversized bodies 413, and unavailable storage or evidence keys 503 so SNS can retry.
Set the JSON bindings CONTENT_PUBLIC_KEYS and INDEX_KEYS using the output of the keys CLI.
Events match existing messages through ms_id tags or account/provider identifiers and
recipients through blind indexes across active index keys. Event insertion and status
projection run atomically with SQLite transactions or D1 batches. Evidence contains the
complete SNS envelope sealed with purpose evidence; complaint events set the message's
complained flag. Unknown/deleted messages are acknowledged without creating rows.
Extensions can read operational status through ctx.sesStatus() (contract
SesStatusV1, version: 1). It is available only in authenticated internal Node
extension routes whose panel client selects both brands and templates as "*";
viewer, operator and admin assertions are accepted without step-up. It is absent
from event/deletion hooks and scoped panel routes. For example:
app.route('GET', '/ses-status', async (_request, ctx) => {
if (!ctx.sesStatus) return new Response(null, { status: 403 });
return Response.json(await ctx.sesStatus(), { headers: { 'cache-control': 'no-store' } });
});The snapshot contains configured, region (runtime SES_REGION), configured
topicArns/accountIds, and subscriptions: [{topicArn, confirmedAt}].
confirmedAt is epoch milliseconds of the latest successful local confirmation,
not an AWS subscription lookup; null means none observed since this migration.
It also contains received, rejected: {signature, topic, account},
lastReceivedAt and lastReceivedType (null until first observation).
Counters count structurally valid SNS envelopes, including retries and ignored or
unmatched notifications, independently of message logging. Rejection categories
are exclusive: topic first, then account, then signature/verification failure.
Malformed/oversized bodies are excluded. The last type is a recognized SES event
name for verified, valid SES payloads; otherwise it is the SNS envelope type.
Times use local receipt time, never the supplied event timestamp, and do not move
backwards. No addresses, message ids, tokens, content or raw envelopes are included.
Observations are aggregate, retained with the database across restarts and message
retention, and shared by SQLite and D1; apply the generated
0002_ses_status.sql D1 migration to existing installations. Old events are not
backfilled. Observation writes are best-effort and do not change endpoint results;
storage outages can leave gaps. Reads reject on storage failure, rather than
reporting zero. Confirmation is recorded only after the confirmation request and
audit succeed. configured: false reports empty configuration selections but
preserves historical counters. This adds no public endpoint or DomainEvent type.
To connect SES and SNS:
- In the same AWS region as SES, create an SNS topic with signature version 2 and
allow SES to publish events to it. Put its exact ARN and account id in
events.ses. - Create an SES configuration set and an enabled SNS event destination for
SEND,REJECT,BOUNCE,COMPLAINT,DELIVERY,DELIVERY_DELAY,RENDERING_FAILURE. Leave open/click tracking disabled. - Set
SES_CONFIGURATION_SETto that set's name. On Worker, run setup and redeploy with the updated config and keys; on Node, restart with the updated environment. - Subscribe the public HTTPS URL
https://mail.example.com/v1/events/sesto the topic. Use the standard SNS envelope, without raw message delivery. The handler confirms an accepted subscription automatically; check the subscription is confirmed. - Send a logged test message and inspect delivery events in the store. Only messages
with a message row receive the configuration set and
ms_id/ms_brandtags.
No AWS resources are created by mailstack. Certificate/signature handling is tested locally on Node and workerd; a real SNS notification is a separate deployment check. SMTP records relay acceptance locally and does not consume SES delivery events. SMTP DSN polling and generic provider webhooks are not implemented.
Origin allow-list (CORS reflects the exact origin, never *) · server-side Turnstile (fails
closed) · honeypot · fixed recipient / anti-relay · D1 rate limit (8/hour per brand+IP) ·
constant-time bearer compare · all field values HTML-escaped · 64 KiB (10 MiB for attachment-enabled send clients) / ~40 fields / 5000
UTF-16 code units per content value · no secrets in code. Content values are capped
after string-array joining (with blank-line separators). If the boundary falls
inside a surrogate pair, the whole final character is omitted, leaving 4999 units.
The request body byte limits remain 64 KiB / 10 MiB.
TruffleHog runs as a pre-commit hook (.husky/pre-commit, skips gracefully if not installed)
and in CI (.github/workflows/secret-scan.yml, always enforced). Install locally:
brew install trufflehog.
cp .dev.vars.example .dev.vars # fill runtime secrets, including logging key JSON values
npm run dev # npx wrangler devThe send pipeline uses src/core/crypto to seal recipients, subjects, refs, archive projections,
provider responses and event evidence. The CLI requires Node.js 24+.
mkdir -p secrets
chmod 700 secrets
npm run --silent keys -- init secrets/panel-content-keys.json
# Copy the two printed assignments into .env; the private JWK stays in the 0600 file.
# Load the existing assignments from the private .env before rotating:
set -a
. ./.env
set +a
npm run --silent keys -- rotate-index
# Replace INDEX_KEYS in .env with the printed assignment, including older keys.CONTENT_PUBLIC_KEYS and the private file use JWK sets ({"keys":[...]}). INDEX_KEYS
is a JSON array of {kid,key}, where key is a base64url-encoded 32-byte key. In both
arrays the first entry is active; prepend new keys and retain older entries for reads.
init refuses to overwrite its private-key file. For content-key rotation, generate a
new pair at a new filename, prepend its public JWK, and retain both private files.
Keep old keys through the metadata retention ceiling and any retained backups.
rotate-index prints a replacement assignment; it does not update .env itself.
Back up the private file separately from message data. Do not commit it or the environment values.
seal(data, context, publicKeys) and open(value, context, privateKeys) use a context
{brand,messageId,purpose} and the ms1 envelope. Both accept an optional fourth
argument maxBytes (positive integer, default 2 MiB), measured in uncompressed UTF-8
JSON bytes. seal checks the size before compression; open counts decompressed
chunks and cancels the reader at the first chunk exceeding the limit. Exactly the
limit is accepted. blindIndex(address,key) returns
{kid,index}; blindIndexes(address,keys) returns one result for every active index key.
canonicalJson sorts object keys recursively in UTF-16 order, preserves array order,
and uses JSON primitive serialization; it rejects non-JSON values and cycles.
createMac(data,keys) returns {kid,mac} with a full base64url HMAC for request or
resend payloads; verifyMac(data,tag,keys) selects the matching key and returns a boolean.
HMAC inputs are UTF-8: mailstack/v1/idx\n followed by the normalized address for
blind indexes, or mailstack/v1/mac\n followed by canonical JSON for MACs (\n = LF).
Indexes retain the first 22 base64url characters; MACs retain the full digest.
Values computed without these context prefixes must be recomputed.
See CONTRIBUTING.md. Licensed under MIT — see LICENSE.
All outgoing mail, including public submissions and auto-replies, uses the logging
pipeline before contacting the configured provider. The effective logging policy
comes from global, brand, and template settings. requireLog: true returns 503 with code: "log_unavailable"
without sending if the intent cannot be recorded. With requireLog: false, delivery
continues without a log when storage or keys are unavailable. level: "none" writes no
message row and needs no encryption keys. With an idempotency key, it stores only a
client-scoped hash in guards, expiring after 24 hours. Missing or unavailable storage
allows delivery without deduplication, even with requireLog: true at level none.
Policies resolve from logging.defaults through logging.brands.<id>.defaults to
logging.brands.<id>.templates.<template>. Requests cannot override them.
| Level | Stored |
|---|---|
none |
No message row, recipients, content or delivery events; short-lived guards only |
meta |
Message metadata, sealed recipients/Reply-To, subject, ref and provider response; events and sealed evidence |
masked |
meta plus a sealed archive projection keeping template defaults or content fields explicitly listed in show; other fields become [ukryte] |
full |
meta plus the archive rendering; declared secret fields and configured patterns remain masked |
Templates declare maskedFields as the default visible content keys when show
is omitted at every policy layer. An explicit show at the most specific configured
layer replaces the entire list (including show: [] to hide all content fields).
To extend a default list, copy its keys and add your own; to narrow it, list only the
keys you want. Existing configurations with explicit show keep their behavior.
The example configuration omits show so staging compositions inherit these defaults:
| Template | Default visible fields |
|---|---|
payment |
amount, currency, item, description, orderId, date |
refund |
amount, currency, productName, transactionId, paymentId, refundId, isFullRefund, totalRefunded, refundedAt |
license |
productName, tier |
notice, welcome |
None; arbitrary message text may contain personal data or tokens |
Recipient names/addresses, license keys, activation links, other URLs and tokens
are never included in these defaults. Declared secret fields remain masked even
when listed in show. A hidden or unrecognized refund scope omits the scope line;
only boolean true/false and strings "true"/"false" select full/partial refunds.
secretFields is declared by templates in code; the license template declares
licenseKey and activationUrl. Secret values in archive projections show only their
last four characters when longer than four; shorter secrets are fully hidden.
The payment template declares each purchase key and configured activation URL
separately in optionalSecretFields; these have the same archive projection rules
and disable replay only when present in the message. patterns supports named
regex or urlParams rules. Archive projections omit JSON-LD; outgoing email keeps the original rendering. subjectRoles
identifies submitter address fields for data-subject indexing (contact.email).
Retention values are positive integer days; contentDays <= metaDays, and both must
fit the global ceilings. requireLog controls whether a missing intent prevents delivery.
resend: "replay" validates only with masked/full and templates without
unconditional secret fields; source needs a matching send client with resendCallback. Defaults remain
resend: "off"; execution requires an extension declaring the resend capability. includeSecrets requires full and a boot audit sink; current runtimes require false.
Trusted POST /v1/send requests accept:
| Input | Contract |
|---|---|
ref |
Optional string of 1-128 characters; otherwise 400 invalid_ref. Stored encrypted with a correlation index. |
Idempotency-Key header |
Optional [A-Za-z0-9._:-]{1,128}; otherwise 400 invalid_idempotency_key. |
resendOf |
Requires an enabled extension and an approved source job, its owning client and matching Idempotency-Key; otherwise 400 not_supported without the extension or 409 resend_mismatch. |
attachments |
Array of { filename, contentType, contentBase64 }; requires attachments: true on a trusted client. |
These keys are excluded from template content. Public requests ignore them and the
idempotency header. Trusted responses include id: "msg_..." whenever the send has a
message row; public response shapes are unchanged. Sellf hook responses use the
same trusted delivery contract, including id and idempotent when applicable. Provider failures retain message: "send failed", with additive
code: "send_failed" and HTTP 502.
Idempotency keys are scoped to the authenticated client (legacy for the legacy API
key; public sends use client id public without deduplication). All logging levels
share one atomic reservation in guards, lasting 24 hours from the first attempt.
Changing a logging policy or restarting the runtime preserves that reservation.
Existing unexpired none guards and meta/masked/full message records are recognized
without a backfill or access to plaintext keys. If an old unlogged reservation coexists
with a refused message, it remains reserved until expiry. Recorded reservations use
guards.count = 2; unlogged and legacy reservations use 1, so clearing a refused
message's binding cannot release an older unlogged reservation.
Changing policy never removes message evidence.
At the 24-hour boundary, a new attempt may claim the key without waiting for retention;
only the expired key binding is cleared from old messages, leaving their evidence intact.
With the current policy at none, a repeated key without a message record returns
200 {success:true,idempotent:true} without an id or another send, including while
the original attempt is pending or its outcome is uncertain. Without a request MAC,
a different body cannot be distinguished.
At meta, masked or full, a reserved key without a message record is read again
up to three times, 10 ms apart, then returns 409 in_progress if still absent.
This also applies after switching from none to a logged level: the original
reservation remains in force until expiry, without another send or a success without ID.
An explicit provider refusal releases an unlogged reservation for retry; preparation
errors leave the key available. Missing or unavailable storage at none still allows
sending without deduplication, regardless of requireLog.
When the original attempt has a message record, its request MAC and state determine
the response below, even if the current policy is none. A missing or unverifiable
MAC returns 409 idempotency_conflict without sending. Reservation hashes do not depend
on index keys. Keep previous index keys during rotation to verify recorded request MACs;
removing a required key returns a conflict until the 24-hour reservation expires.
An explicit refusal permits a matching request to retry under the current policy.
| Original state | Repeated request |
|---|---|
sent or a later delivery state |
200 {success:true,id,idempotent:true} without another send |
sending |
409, code: "in_progress" |
send_failed (explicit provider refusal) |
Clear the old key and execute a new attempt |
unknown (exception, timeout, or uncertain result) |
409, code: "outcome_unknown"; no automatic retry |
| Different request with the same key | 409, code: "idempotency_conflict" |
An exception or timeout marks unresolved recipients unknown along with the message.
Retention applies the same update to sending attempts older than 15 minutes, including
after restart. Known recipient outcomes, partial SMTP relay acceptance/refusal and the
message complaint flag remain intact. Late send finalization also preserves established
provider delivery outcomes and their evidence. An explicit late refusal resolves both
sending and unknown recipients without changing known recipient outcomes.
Reconciliation never sends email automatically.
An unknown result needs provider reconciliation before considering another send.
The operator panel is supplied by an extension. The SES adapter makes one request per attempt. No idempotency key
means no deduplication. Generated contact receipt timestamps do not change request
identity; recipient, subject, content, Reply-To, ref and explicit markup do.
Sellf uses the generic source engine and requires a Bearer client with hook:sellf
scope. signatureSecretEnv names the optional runtime signing secret;
sellfSignatureSecretEnv remains an alias. When configured, the runtime secret and
X-Sellf-Signature: t=<unix>,v1=<hex> are required (HMAC-SHA256 over ${t}.${rawBody},
300-second tolerance); invalid credentials return 401 unauthorized.
Source keys are source:<id>:<event>: followed by
JSON.stringify([brandId, selectedIdentityOrBodyDigest]), then scoped to the client
by the pipeline. The body fallback is the first 32 hex characters of SHA-256.
The Sellf preset selects PI then CS for purchases, refund ID for refunds, and
order CS/PI then payment CS/PI for other events. Purchase and refund ref select
PI then CS; other events use the same selection as their key. Different refund IDs
produce separate deliveries. Missing refund IDs fail the refund template requirement.
Duplicate, pending, conflict and uncertain outcomes follow the table above.
Comparison covers mapped delivery intent; level none compares only the key.
Keys expire after 24 hours. The new source: namespace does not look up older
sellf: keys. Retrying any event sent by the former adapter within that 24-hour
window may send it again; reconcile outstanding deliveries when changing versions.
Set optional SES_CONFIGURATION_SET in .env and run npm run setup to include it in
Worker vars. For local npx wrangler dev, it may also be set in .dev.vars. Only sends
with a message row include ConfigurationSetName and tags ms_id (message id),
ms_brand (brand id). SES MessageId and the encrypted response body are recorded;
response headers are excluded. No open/click tracking configuration is sent.
Use Node 24+ and a local SQLite database to read the log offline. These commands use local files and do not contact the API or email provider:
node scripts/mailstack.ts log list --db ./data/mailstack.sqlite
node scripts/mailstack.ts log list --db ./data/mailstack.sqlite --brand acme --status sent --since 2026-10-01T00:00:00Z --limit 20
node scripts/mailstack.ts log find --db ./data/mailstack.sqlite --email buyer@example.com --index-keys ./secrets/index-keys.json
node scripts/mailstack.ts log find --db ./data/mailstack.sqlite --email buyer@example.com --index-keys env
node scripts/mailstack.ts log show msg_EXAMPLE --db ./data/mailstack.sqlite --key ./secrets/content-private.jsonlist and find print a table of message id, creation time (UTC), brand, template,
status and latest delivery event. Add --json for JSON output. Results are newest
first, with message id breaking timestamp ties; the default limit is 100 and
list --limit accepts 1-100. --since accepts a valid calendar ISO timestamp with
seconds and a timezone (Z or ±HH:MM, offset hours 00-23 and minutes 00-59).
Fractional seconds are optional. Impossible dates, out-of-range clock components
(including 24:00 and leap seconds) and missing timezones are rejected before opening
the database. Equivalent UTC and offset timestamps select the same rows.
Lists contain no recipient addresses or address hints. The none logging level
creates no message row, so those sends cannot appear in results.
The index-key file contains the same JSON array as INDEX_KEYS, for example
[{"kid":"index-1","key":"<base64url-encoded 32-byte key>"}].
--index-keys env reads INDEX_KEYS from the current environment. Search checks
every supplied key generation and matches recipient, Reply-To and submitter
roles. It trims the address and lowercases its domain; the local part remains
case-sensitive. Keep old index keys available while their messages are retained.
show prints JSON containing metadata and the event timeline, including bounce,
complaint and delivery events. For masked and full, it also opens the archived
projection and stored provider response with the private JWK set created by
npm run keys -- init ./secrets/content-private.json ({"keys":[...]}). The
archive is labelled Archived projection (level masked/full). For meta, it
prints a notice that content was not recorded and returns only metadata and the
timeline. Missing policy metadata, unavailable or expired content, and incorrect
keys produce a readable error and a nonzero exit code. An unsuccessful decrypt
prints no partial result.
Database reads use a read-only connection. Before opening any archived content,
show briefly opens a separate connection to append cli.read to the existing
audit chain; this is its only database write. The database must therefore permit
that audit append for content reads. If the append fails, content is not opened.
Keys are never printed; decrypted values go only to stdout. The CLI does not
write decrypted output to files or logs. It does not create a missing database
or run migrations. For a separate machine, use a consistent SQLite snapshot (see
Retention and snapshots) and copy keys separately.
The Worker runs retention hourly at minute 17 (UTC). Expired content, resend data
and guards are removed; message metadata and its children are deleted in atomic
batches of at most 500 messages. Logging ceilings also apply to existing rows.
Content readers must call isContentReadable(row, now) before returning content,
including when scheduled maintenance has not run yet. Sending rows older than
15 minutes become unknown with a local event; this does not resend mail.
appendAudit(store, entry) accepts internal metadata only: actors, actions,
message identifiers, counts and configuration identifiers; callers must exclude
personal data from targets and details. Details use canonical JSON. The first
prev_hash is 64 zeroes; absent target/details contribute empty strings to the
newline-separated SHA-256 input. Appends assign consecutive sequence numbers
inside an atomic write and retry a conflicting head at most three times.
Pruning removes a contiguous expired prefix only after recording
audit.pruned {upTo, hashAtUpTo}. verifyChain(rows, anchor?) checks ordered rows
and an optional independently stored (seq, hash) anchor. Supply the last known
head to detect a shortened tail; rows alone cannot establish a missing tail.
A pruned prefix can be checked from its marker or a matching boundary anchor.
Node exposes backupSqlite(db, dir, now, keep=7) for consistent daily snapshots
named mailstack-YYYYMMDD.db using UTC dates. It retains the seven newest matching
files and leaves other files untouched. The Node runtime schedules these snapshots
daily and at startup when the last snapshot is absent or at least 24 hours old. It retains seven snapshots; with the runtime
running daily, removed data ages out of backups within seven days. Keep keys outside
the database and backup directory. Worker databases use
D1 Time Travel.
On Node, append these variables to the private .env and recreate the service. On
Worker, use npx wrangler secret put NTFY_URL and npx wrangler secret put NTFY_TOKEN;
supply optional PANEL_URL the same way only when a panel exists.
Set optional runtime variables NTFY_URL (topic endpoint), NTFY_TOKEN
(Bearer secret), and PANEL_URL (panel base URL). Without NTFY_URL,
alerts are disabled. Configure logging.defaults.alertOn or brand/template
overrides with bounce_permanent, complaint, reject, send_failed,
unknown, or render_failure.
Alerts use the stored recipient hint and a bounded summary of diagnostic status
codes and bounce classification; arbitrary provider prose is omitted. They contain
no subject or message content. Message-level events without a matched recipient use
unassigned instead of a recipient hint. Templates declaring secret fields use high priority;
others use default priority. PANEL_URL adds a link to /m/<messageId>.
One attempt per message/type is claimed in guards for the effective metaDays
(or seven days when absent). A failed notification is logged without its details
and does not change event or send results. Requests time out after five seconds
and do not follow redirects.
Alerts are best-effort: an ntfy failure at event time does not retry the alert; the message remains visible in the log.
Node extensions opt in with capabilities: ["resend"]. The runtime passes
ctx.resend only to authenticated internal routes of that extension, with the verified
panel principal bound to it. viewer cannot use this port; operator and admin
need a step-up within five minutes, an unexpired assertion, and matching brand/template
selections. Event hooks do not receive this capability. Extension database queries
are limited to their ext_<name>_ tables.
Types live in src/core/resend-contract.ts:
source(messageId)returns the permitted strategy, brand/template, sealed reference and sealed payload (with expiry), or a structured error. Audit must succeed before returning sealed data. A source strategy also returns the owning client id and callback descriptor (url,secretEnv), never the secret value.request({messageId, reason, idempotencyKey, replay?})accepts a 10–500 character reason and the normal idempotency key format. Forreplay, pass the exact decrypted payload fromsource().replay; core validates it and awaits the shared delivery pipeline. Forsource, it returns202withresendIdand the sealed source descriptor. Callback signing, execution, UI and license decisions belong to the extension. The extension must declare the capability only when enabled.
The payload contains to, template, data, and optional replyTo,
subjectOverride, markup. The core checks its canonical MAC and recipient index set,
current policy, expiry, template classification and recipient outcomes. Files are not
stored in this payload; messages with attachments cannot use the stored-payload
strategy. Use a source strategy to regenerate their attachments.
An approved source callback sends through POST /v1/send with the original brand,
template, recipient set, resendOf and the approved Idempotency-Key. Only the
original owning client may fulfill it. The endpoint returns no source history.
Every child has resend_of and resend_root and must have a recorded intent,
including when the current policy has requireLog: false.
Jobs and quota reservations last 24 hours. Root quota is a rolling 3/24h with a
10-minute cooldown; actor (20/day) and instance (50/day) quotas use UTC calendar days.
Quotas are reserved atomically at approval. Explicit provider refusal releases the
reservation and permits the same key again; uncertain outcomes retain it. Repeated
keys return the same resendId; sending returns in_progress, unknown returns
outcome_unknown. A completion interrupted after provider acceptance is reconciled
from the recorded child without another provider call. Retention removes expired jobs.
Trusted send clients must set attachments: true in their configuration (default false,
including the legacy key). Public requests ignore the attachments control key.
{"brand":"acme","template":"notice","attachments":[{"filename":"note.txt","contentType":"text/plain","contentBase64":"SGVsbG8="}]}Allowed MIME types and extensions: application/pdf (.pdf), image/png (.png),
image/jpeg (.jpg, .jpeg), text/plain (.txt), text/csv (.csv),
text/calendar (.ics), application/xml and text/xml (.xml). Filenames must
match [\w .()-]{1,100} and have a matching extension. Base64 must be canonical,
with padding and no whitespace. PDF, PNG and JPEG require matching magic bytes.
Maximum: 5 files and 7 MiB decoded in total. Request bodies remain limited to
64 KiB; authenticated send clients with attachment permission have a 10 MiB limit.
Both declared and streamed body sizes are checked before parsing.
Missing client permission returns 403 with code: "scope"; unsupported MIME types
return 415 attachment_type; invalid shape, filename, base64, magic bytes, count or
decoded total returns 422 attachment_invalid. Oversized bodies return 413 with
message: "payload too large".
The Node API Compose budget is 512 MiB, separate from the SMTP sink. It is sized for sequential requests up to 10 MiB JSON and 7 MiB decoded attachments. Keep at most one large send in flight per instance, including the provider send; serialize these sends in the caller or proxy. The listener does not enforce this concurrency bound. More concurrent large requests, extensions, maintenance and larger archives require their own measured budget; 512 MiB does not cover arbitrary concurrency. Leave host memory for the sink, panel, filesystem cache and other processes.
node scripts/attachment-profile.mjs runs a bounded, sequential regression in a
separate Node API process with real loopback HTTP, SQLite logging and SMTP MIME.
It checks eight prior small sends, 1 MiB, 7 MiB, five files totalling 7 MiB,
decoded limit + 1, JSON bodies at 10 MiB / + 1, eight later small sends,
health, decoded MIME SHA-256 and decrypted archive exclusions. --fresh measures
one 7 MiB send without warmup. The RSS ceiling is 384 MiB, leaving 128 MiB of the
Compose budget outside this profile. RSS is not a cgroup measurement.
For Linux container validation, node scripts/attachment-cgroup.mjs creates one
isolated TEST Compose project, uses an internal-only network and loopback ports,
and removes its containers, volumes and local image afterward. It refuses to run
alongside another Compose project and requires cached node:24-alpine and
axllent/mailpit:v1.29.7 images. It builds the API locally, measures fresh and warm
runs, records each service's cgroup limit/peak/current/events and process RSS/HWM,
checks OOMKilled, verifies MIME checksums and inbox preservation across a Mailpit
restart. Save stdout as measurement evidence. Mailpit has its own 512 MiB budget
and persistent volume. Run this validation on the target runtime before treating
the container budget as confirmed; the host profiler alone cannot establish that.
SES uses Content.Simple.Attachments; SMTP receives decoded buffers. Files and their
names are excluded from archives and replays. Only {count,totalBytes,types} is stored
in attachments_meta. Request MACs include each file's SHA-256, name and type; changing
a file with the same idempotency key returns 409 idempotency_conflict at levels
meta, masked and full. Level none deduplicates by key alone.
Node can load optional modules at startup; Worker does not load or execute them.
Omitting extensions is equivalent to "extensions": []. At most one extension may
implement keys(config), returning { authenticate(token, env), client(id) } for tokens
other than the full key (Mailstack Pro uses it for scoped clients).
{
"extensions": [
{ "module": "mailstack-addon", "options": {} }
]
}module is an installed package name (optionally a package subpath) or an absolute
file path, such as /opt/mailstack/addon.mjs. Relative paths are rejected. A module
exports an Extension as its default export, or a default factory receiving
options and returning an Extension (a promise is supported). Import or
initialization errors stop startup and identify $.extensions.<index>.module
without printing module values or exception details.
The contracts live in src/core/extensions.ts. Extension names match
[a-z][a-z0-9_]{0,39} and must be unique. All hooks are optional:
migrate(db)runs before listening. Use Kysely schema/query builders and tables beginning withext_<name>_; referenced tables must use the same prefix. Raw SQL, schema-qualified tables and dropping indexes are not supported. Index names also use this prefix. Migrations should be repeatable, for example withifNotExists().onEvent(event, ctx)receives send outcomes (message.sent,message.send_failed,message.unknown), projected SES outcomes (message.delivered,message.bounced,message.complained, ormessage.send_failedfor rejection/rendering failure), andform.submittedafter a successful public contact submission. Duplicate SES notifications and idempotent send responses do not emit another outcome. Explicit send retries are new attempts. Events contain onlyid,type,at(epoch milliseconds),brand,template,status, and optionalref/messageId(the local log id). Address, subject and message content are absent. Keep correlation refs opaque.routes(app)registers exact paths withapp.route('GET', '/ping', (request, ctx) => Response.json({ ok: true })). They are mounted only on Node's internal port 8081, at/internal/ext/<name>/ping. Requests require a configuredpanelclient bearer token,X-Panel-Actor, and a signedX-Panel-Assertionas described in the internal API contract. Do not publish this port externally. Extensions must append an audit entry before content reads or state mutations.
ctx provides a Kysely db handle, appendAudit(entry), now() (epoch
milliseconds), and config containing brand ids and logging policies only.
Route handlers also receive the verified ctx.panel principal and must apply its
scopes, selections, role and step-up requirements.
It does not include runtime environment variables, sending keys or client tokens.
Module-specific options are passed only to the factory. Modules run as trusted
in-process Node code, not in a sandbox.
Event hooks run in the background with a five-second wait limit. Exceptions and timeouts produce a generic log entry and never change the email/API result. The limit stops waiting; it cannot cancel a module's ongoing work. Modules should bound their own I/O and avoid blocking the Node event loop. Delivery is best-effort; there is no persistent event queue in the core.
The existing Node listener on port 8081 exposes the sealed log and privacy API.
Panel calls require the configured panel client plus a signed, short-lived actor
assertion; role and step-up checks run in the API. See the internal API contract
for routes, role matrix, PANEL_ASSERTION_KEY, audit behavior and extension integration.
The manual-test sandbox uses synthetic brands and addresses only. It requires Docker
Compose, Node 24+, OpenSSL, and a sibling mailstack-pro checkout with dependencies
installed. It never reads the normal .env, private brand configuration, or production
credentials. Run from this checkout:
scripts/test-env.sh up
scripts/test-env.sh seed
scripts/test-env.sh status
scripts/test-env.sh downup builds one project, mailstack-test. seed is idempotent and creates template
samples at levels none/meta/masked/full, a second tenant, a nonce-enabled brand with
products, synthetic clients, signed SES fixtures, and separate expiring records
(metadata after one hour; content/replay after fifteen minutes). Restarting up
preserves data; reset destroys and recreates the sandbox. After source changes use
reset: the build context is a snapshot, not a live source mount.
| Service | Address |
|---|---|
| Public API | http://localhost:18880 |
| Pro panel (self-signed TLS) | https://localhost:18890 |
| Mailpit inbox | http://localhost:18825 |
| Request playground | http://localhost:18882 |
| HTTPS receiver / request history | https://localhost:18843/records |
All mail goes to Mailpit, including .example recipients. API, panel and Mailpit
have no external network; a fixture gateway publishes their fixed ports on loopback.
The internal API port 8081 is not published. The panel has separate state and private
content keys; API mounts contain public content keys and index keys only. The fixture
gateway holds its own signing keys, never panel content keys. The containers use the
host's non-root UID so private bind-mounted files can remain mode 0600. Mailpit also
runs with that UID; the init container assigns its separate mailpit-data volume.
Its database is /data/mailpit.db, outside tmpfs, and survives stop/start, restart
and container recreation within a run. down and reset remove this volume too.
Mailpit has a separate 512 MiB budget: four times the previous 128 MiB allocation,
with headroom for MIME/base64 parsing of a 7 MiB attachment and earlier messages.
This is a test budget, not a measured peak or a guarantee: the full 7 MiB run must
measure sink/API memory separately and verify attachment checksums after prior sends.
status prints service addresses and writes account instructions, test tokens, source
SHAs and invitations to /private/tmp/mailstack-test-local/status.json (0600).
invite all renews viewer/operator/admin invitation links, which expire after fifteen
minutes. Enroll with a Chrome CDP virtual resident authenticator, then authenticate
again for step-up. There are no passwords or owner accounts. Keep each role's virtual
credentials separately. Test profiles must be temporary, headless, and independent
of the owner's browser. Do not log tokens or cookies in evidence.
scripts/test-env.sh invite all
scripts/test-env.sh license expired # valid, missing, bad-signature, wrong-domain, revoked
scripts/test-env.sh license valid
scripts/test-env.sh mode '{"hook":"500","ntfy":"timeout"}'
scripts/test-env.sh mode '{"hook":"200","ntfy":"200","license":"200","callback":"200"}'
scripts/test-env.sh exec api cat /data/manual-seed.json
scripts/test-env.sh exec api node /workspace/mailstack-pro/dist/manual-seed.mjs event MESSAGE_ID Bounce EVENT_ID
scripts/test-env.sh exec api node /workspace/mailstack-pro/dist/manual-seed.mjs expire
scripts/test-env.sh exec api node /workspace/mailstack-pro/dist/manual-seed.mjs retention
scripts/test-env.sh logsThe HTTPS receiver records exact bodies, signature headers, idempotency keys and
signature validity. It supports 200/500/timeout/redirect modes per hook, ntfy, license
or source callback. For an endpoint created in the panel, verify its signature using
the one-time secret returned at creation; the receiver's built-in signatureValid
uses the seeded endpoint secret. Only selected signature, idempotency, content and
notification headers are recorded; Authorization and cookies are not stored or
exported. Existing history is filtered on export too. /records remains an array;
/records/diagnostics reports recovered count, discarded line numbers (one-based),
byte offsets (zero-based), lengths, and NUL ranges. NUL bytes are removed only in
the read projection; unparsable entries are reported, while later valid entries
remain readable. An affected original is saved byte-for-byte as a mode-0600
records.jsonl.original-<sha256> beside the unchanged history. Keep these originals
private, especially for history created by older versions. Response headers
X-Fixture-Discarded-Records and X-Fixture-Nul-Bytes flag recovery, and the same
diagnostics go to container stderr without record contents. Append uses a fresh
append descriptor and fsync; an unterminated tail is preserved before a separating
newline is added. No historical cause of NUL bytes has been established.
Use its CA at
/private/tmp/mailstack-test-local/fixture/tls.crt for host curl requests.
The Pro test composition maps only license.test.example, hooks.test.example and
source.test.example through injected transports to this receiver. Production SSRF
validation is unchanged. Other destinations use the normal resolver and cannot be
sent through the fixture adapter. A local transport test is not evidence of public
DNS rebinding protection or production certificate trust. The panel uses its real
CLI, auth, storage and views; the test build injects only licensing and alert transport.
event uses the real SNS verifier and a fixture-signed envelope with a locally supplied
certificate, then the real event handler and extension emitter. It does not bypass
signature verification or alter the public /v1/events/ses endpoint. These fixtures
prove local processing and evidence handling, not AWS certificate trust. Expiry
helpers change synthetic fixture timestamps; the host clock is never changed.
In the mixed SNS-fixture + SMTP sandbox, SQL and the timeline/proof may say
delivered after a fixture Delivery event while the summary still says
Relay accepted (delivery not confirmed) because its provider is SMTP. This
difference is intentional: neither the local fixture event nor SMTP relay
acceptance proves final delivery to a real recipient.
The helper drains the dynamic waitUntil queue, including nested registrations,
before closing SQLite and printing its event result. Rejected tasks produce a nonzero
exit and no success result; cleanup still runs after the remaining tasks settle.
body.result: "processed" means the SES event was recorded; "duplicate" means no
new domain event was emitted. background: "drained" confirms completion of registered
tasks only. Extension hooks retain their runtime error handling and filters; it does
not assert a queue row or an HTTP webhook. webhookDelivery: "not_confirmed" is explicit.
A deterministic local reproducer (no Docker, browser, or provider) exercises the real
SNS verifier, SES handler, extension emitter and SQLite queue writes with a delayed
onEvent, Delivery without a notifier, Bounce, Complaint, duplicates, and Bounce before
Delivery. It also checks another nested task level, rejection and cleanup:
npm test -- test/manual-lifecycle.test.tsFor a sandbox retest, first configure an enabled webhook for acme / the selected
template and all three event types, targeting https://hooks.test.example/events. Send a fresh message
without ref and retain its response id as MESSAGE_ID; use its actual recipient
(the default below is buyer@acme.example). Inject Delivery, then repeat the exact
command with the same EVENT_ID to obtain duplicate without another queue row:
scripts/test-env.sh exec api node /workspace/mailstack-pro/dist/manual-seed.mjs event MESSAGE_ID Delivery EVENT_ID buyer@acme.examplePoll both the durable Pro queue and receiver with a deadline. This read-only host
recipe requires the running sandbox; replace MESSAGE_ID. It fails if either side
is missing after 60 seconds. The receiver body must contain the same messageId as
the send response, plus the domain event id found in the queue:
python3 - MESSAGE_ID message.delivered <<'PYTHON'
import json, ssl, subprocess, sys, time, urllib.request
message_id, event_type = sys.argv[1:]
context = ssl.create_default_context(cafile='/private/tmp/mailstack-test-local/fixture/tls.crt')
query = """
import Database from 'better-sqlite3';
const db = new Database('/data/mailstack.db', { readonly: true });
const rows = db.prepare(`SELECT event_id, state FROM ext_pro_webhook_deliveries
WHERE json_extract(body, '$.messageId')=? AND json_extract(body, '$.type')=?`)
.all(...process.argv.slice(1));
console.log(JSON.stringify(rows)); db.close();
"""
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
rows = json.loads(subprocess.check_output([
'scripts/test-env.sh', 'exec', 'api', 'node', '--input-type=module', '-e', query,
message_id, event_type], timeout=min(5, remaining)))
with urllib.request.urlopen('https://localhost:18843/records', context=context,
timeout=min(5, max(.001, deadline - time.monotonic()))) as response:
records = json.load(response)
event_ids = {row['event_id'] for row in rows}
matches = []
for record in records:
if record.get('path') != '/events':
continue
try:
body = json.loads(record['body'])
except (ValueError, KeyError):
continue
if (isinstance(body, dict) and body.get('messageId') == message_id
and body.get('type') == event_type and body.get('id') in event_ids):
matches.append(record)
if rows and matches:
print(json.dumps({'queue': rows, 'receiverAttempts': len(matches), 'messageId': message_id}))
break
time.sleep(min(.25, max(0, deadline - time.monotonic())))
else:
raise SystemExit('Deadline: queue and correlated receiver event not both confirmed')
PYTHONPreserve and independently verify the matching raw receiver body, signature and
idempotency key as described above; a matching body alone does not verify its signature.
Repeat with Bounce / message.bounced and Complaint / message.complained, using
distinct source event IDs. For ordering, use a fresh message, inject Bounce before
Delivery, and poll both event types: the late Delivery event keeps the projected bounce
status. For duplicates compare queue event IDs and receiver decisions, since delivery
retries may legitimately add HTTP attempts. Use two fresh messages without ref to
verify independent correlation. Restart/retry and inbox preservation are separate
manual acceptance checks; the focused reproducer does not claim them.
Before every fault or reset, preserve all earlier mail outside the installation directory, including raw EML and SHA256, with this paginated capture/check procedure (use a new evidence directory for each capture):
python3 - capture /private/tmp/mailstack-inbox-before-fault <<'PY'
import hashlib, json, pathlib, sys, urllib.request
mode, directory = sys.argv[1:]
root = pathlib.Path(directory)
base = 'http://127.0.0.1:18825'
def get(path):
return urllib.request.urlopen(base + path, timeout=15).read()
def inventory():
result, start = {}, 0
while True:
page = json.loads(get(f'/api/v1/messages?start={start}&limit=50'))
for message in page['messages']:
mid = message['ID']
raw = get(f'/api/v1/message/{mid}/raw')
result[mid] = hashlib.sha256(raw).hexdigest()
if mode == 'capture':
path = root / (mid + '.eml')
with path.open('xb') as stream:
stream.write(raw)
path.chmod(0o600)
start += len(page['messages'])
if start >= page['total']:
return result
if not page['messages']:
raise RuntimeError('Incomplete inbox page')
if mode == 'capture':
root.mkdir(mode=0o700)
manifest = inventory()
path = root / 'sha256.json'
path.write_text(json.dumps(manifest, sort_keys=True))
path.chmod(0o600)
elif mode == 'check':
expected = json.loads((root / 'sha256.json').read_text())
actual = inventory()
assert expected and all(actual.get(k) == v for k, v in expected.items()), 'Inbox changed'
else:
raise ValueError('Use capture or check')
print('Inbox checksums OK')
PYFor a restart check, capture a nonempty inbox first, run
docker compose -p mailstack-test -f /private/tmp/mailstack-test-local/compose.yml restart mailpit,
wait for Mailpit readiness, and repeat the Python block with check instead of
capture and the same evidence path. All prior IDs/raw checksums must match; new
messages may be present. For the large-attachment trial also download the attachment
and compare its decoded SHA256 with the input file. A destructive reset intentionally
starts an empty inbox; use the saved EML as evidence, not as a claim of persistence.
Keep the original test run's earlier captures before starting another fault.
down removes containers, volumes, locally built images and the entire temporary
installation including credentials. The downloaded Mailpit image and evidence saved
outside the installation directory remain. One Compose project and one Chrome at a
time; up checks other projects and memory pressure before building.
scripts/test-worker.sh deploy
scripts/test-worker.sh status
# When the coordinator has finished using it:
scripts/test-worker.sh destroyThis script reads only the agent Vaultwarden item CF Deploy - General API token;
it never uses a token from repository .env files. It deploys only mailstack-test
on workers.dev, with the existing mailstack-preview D1 and migration 0001.
No custom domain or production route is configured. SES keys are randomly generated
invalid credentials: accepted requests end in 502 send_failed, with logging, and
cannot send mail. ALTCHA and the nonce challenge work independently of SES.
Before and after each operation it compares the production Worker's deployment JSON;
an unavailable initial fingerprint prevents any mutation. Evidence and the test URL /
token are in /private/tmp/mailstack-test-worker (0600 files). The Worker is left
available for coordinator tests. destroy removes only that Worker and local test
credentials; the existing preview database and its synthetic rows are retained.
The script does not delete or recreate D1 databases. A missing permission is reported
without preventing local tests. Never run the normal production deploy command for
this sandbox. Wrangler command reference.
priv/run-manual-tests.sh --dry-run
priv/run-manual-tests.sh # coordinator only; starts four Codex runs
priv/run-manual-tests.sh --fresh # explicit new cycle after source/brief changesThe private plan, scenarios and charters are in priv/plans; six tester briefs are
in priv/briefs/manual. The runner executes REG-A, REG-B, EXP-1 and EXP-2 sequentially,
with a fresh local sandbox between runs, a shared browser-slot reservation, resource
checks, capacity retries and report-based resumption. State is priv/manual.state,
logs are priv/run-logs/manual, and the final table is
priv/reports/manual-summary.md. A completed report may still contain product bugs;
completion is not release approval. SMOKE and RETEST are separate coordinator briefs.
Never commit generated tokens, reports containing credentials, or temporary state.
When connecting to SMTP by IP, set SMTP_TLS_SERVERNAME to the certificate hostname. This preserves TLS certificate verification with a direct address, including when SMTP_URL supplies the connection. The value must be a hostname.