mail-kit
One email layer for every app. Never throws — it returns whether the mail was sent.
Why it exists
Email is where products quietly grow a second, private implementation: a different sender, a swallowed error, a test message that reaches a real person. 23 apps had started.
How it works
Resend, over raw fetch, with no other dependencies. The call returns a result that says whether the mail went, rather than throwing and hoping a catch exists. Sandbox and placeholder addresses are refused, and the shared sender is one convention.
Where it fits
Hire, notifications, and anything that has to reach a person outside the product. It is the boring half of 'reach someone'. threadkit is the half that stays inside.
API and examples
Documentation from source commit d799d01a5233 · 2026-11-06. Source documentation can include work beyond the registry release.
- Install
- Use
- One address per app: `hello@<host>`
- Mail to an app
- AI triage
- Env contract (the whole of it)
- Fleet conventions
- What this is not
Read the package documentation
The fleet's one email layer. One provider (Resend), one env contract, one call shape, zero runtime dependencies.
Exists because the fleet grew ~12 bespoke email implementations across three provider styles, and a dead SMTP credential silenced two apps' outbound mail for months without anyone noticing. Uniformity is the fix.
Install
pnpm add @bitbaum/mail-kit
Use
import { sendMail, isMailConfigured, mailHealth, conventionalFrom } from "@bitbaum/mail-kit";
const result = await sendMail({
to: "person@example.ch",
subject: "Your invoice",
html: "<p>…</p>",
attachments: [{ filename: "invoice.pdf", content: pdfBuffer, contentType: "application/pdf" }],
});
if (!result.sent) {
// result.error, result.status?, result.retryable — sendMail NEVER throws.
logger.error("invoice email failed", result);
}
sendMailnever throws. A thrown email error inside a server action
reaches nobody and destroys the form; a { sent: false } you must look at is deliverable. Callers where delivery matters check .sent.
isMailConfigured()— gate flows that depend on an email arriving
(password reset, invoices). A placeholder key and a production sandbox sender (@resend.dev) count as unconfigured: both look configured and deliver to nobody.
mailHealth()— key-validity + domain-status probe for/api/health.
A passing probe does not prove delivery; only a real send does.
options.idempotencyKey— pass from crons/queues so a retried job
cannot double-send (Resend dedupes for 24h).
One address per app: hello@<host>
Every app has one address, on its own host, that a customer can guess: hello@<app>.orangecat.ch on the box, and hello@murphys-bar.ch once a site has its own domain. appAddress(url) builds it; appAddressFromEnv() reads MAIL_ADDRESS or derives it from APP_URL / NEXT_PUBLIC_SITE_URL / AUTH_URL.
sendMail puts that address in Reply-To on every message (unless the call sets its own, or already sends from it). Mail still leaves through the shared sender until each host can sign its own — but every answer reaches the app.
Mail to an app
The fleet's mail gateway receives for every app host, checks SPF/DKIM/DMARC, and POSTs an InboundMail to /api/mail/inbound, signed with MAIL_INBOUND_SECRET (x-mail-signature: t=…,v1=hmac). The app side is one line:
// app/api/mail/inbound/route.ts
import { inboundHandler } from "@bitbaum/mail-kit";
export const POST = inboundHandler({ onMail: (mail) => saveAndTriage(mail) });
Unsigned, tampered, stale (> 5 min) or oversized deliveries are refused before your code runs; a thrown onMail answers 500 so the gateway retries.
AI triage
triageMail(mail, complete, context) asks a model (any complete() — the ai-forms / ai-kit shape) for kind, urgency, a one-line summary, the sender's language and needsHuman. It never throws: no model, or nonsense back, and the answer comes from plain rules, marked by: "rules". Instructions inside an email are described, never followed.
Env contract (the whole of it)
| Var | Meaning |
|---|---|
| `RESEND_API_KEY` | required to send (a fleet key: `/opt/fleet/env`) |
| `RESEND_FROM` | sender — `Name <addr>` or bare address (optional if every call passes `from`) |
| `MAIL_ADDRESS` | the app's own address; optional, derived from the app's URL |
| `MAIL_INBOUND_SECRET` | verifies mail from the gateway (a fleet key: `/opt/fleet/env`) |
Fleet conventions
- The shared Resend account is on the free tier: 1 verified domain,
100 emails/day, 3,000/month. Hitting the daily cap surfaces as { sent: false, status: 429, retryable: true }.
- Only
loki.orangecat.chis verified, so every app sends as
<app>@fleetcrown.orangecat.ch — conventionalFrom("My App") builds it.
- A daily canary on the box sends one real email and alerts Telegram when it
fails — that, not this package, is what proves delivery keeps working.
What this is not
- Not a template engine — apps own their HTML.
- Not a queue —
retryabletells a caller whether a retry can help;
scheduling one is the caller's decision.
- Not for bulk — newsletters go through Listmonk.
Part of bitbaum — AI-native products on open infrastructure, built in Zürich. Every package here lists the apps that use it: which apps use mail-kit.
Changelog and releases
Read the repository changelog
This retrospective record was added on 2026-10-27 from the immutable sources linked below. Git commit dates are source dates, not inferred npm publication dates. A GitHub release does not itself establish npm publication. Application adoption and deployment evidence are tracked separately.
0.2.0
- Tagged source
v0.2.0 - Source commit, dated 2026-10-25.
- GitHub release, published 2026-10-25.
Recorded release notes
What's Changed
* ci(auto-merge): hand the sweep a PAT so workflow PRs stop stalling by @catomean in https://github.com/bitbaum/mail-kit/pull/2 * ci(auto-merge): correct the reason this repo passes a PAT by @catomean in https://github.com/bitbaum/mail-kit/pull/3 * ci(publish): make publishing idempotent so a second run is green, not E403 by @catomean in https://github.com/bitbaum/mail-kit/pull/4 * chore: rename FleetCrown to Loki by @catomean in https://github.com/bitbaum/mail-kit/pull/5 * fix: the sending domain is DNS, not the product name by @catomean in https://github.com/bitbaum/mail-kit/pull/6 * docs: link back to bitbaum, and to who uses this package by @catomean in https://github.com/bitbaum/mail-kit/pull/7 * feat: one address per app, mail to apps, and AI triage (v0.2.0) by @catomean in https://github.com/bitbaum/mail-kit/pull/8 * ci(publish): allow publishing a merged version by hand by @catomean in https://github.com/bitbaum/mail-kit/pull/9 * docs(readme): the one-address example names no product's door by @catomean in https://github.com/bitbaum/mail-kit/pull/10 * chore: add the fleet package template files this repo is missing (dependabot.yml, git-release.yml, release.yml) by @catomean in https://github.com/bitbaum/mail-kit/pull/11
Full Changelog: https://github.com/bitbaum/mail-kit/compare/v0.1.1...v0.2.0
Release notes
What's Changed
* ci(auto-merge): hand the sweep a PAT so workflow PRs stop stalling by @catomean in https://github.com/bitbaum/mail-kit/pull/2 * ci(auto-merge): correct the reason this repo passes a PAT by @catomean in https://github.com/bitbaum/mail-kit/pull/3 * ci(publish): make publishing idempotent so a second run is green, not E403 by @catomean in https://github.com/bitbaum/mail-kit/pull/4 * chore: rename FleetCrown to Loki by @catomean in https://github.com/bitbaum/mail-kit/pull/5 * fix: the sending domain is DNS, not the product name by @catomean in https://github.com/bitbaum/mail-kit/pull/6 * docs: link back to bitbaum, and to who uses this package by @catomean in https://github.com/bitbaum/mail-kit/pull/7 * feat: one address per app, mail to apps, and AI triage (v0.2.0) by @catomean in https://github.com/bitbaum/mail-kit/pull/8 * ci(publish): allow publishing a merged version by hand by @catomean in https://github.com/bitbaum/mail-kit/pull/9 * docs(readme): the one-address example names no product's door by @catomean in https://github.com/bitbaum/mail-kit/pull/10 * chore: add the fleet package template files this repo is missing (dependabot.yml, git-release.yml, release.yml) by @catomean in https://github.com/bitbaum/mail-kit/pull/11
Full Changelog: https://github.com/bitbaum/mail-kit/compare/v0.1.1...v0.2.0
Recent source changes
Repository activity can include unreleased work; it is not a list of deployed changes.
- docs(agents): the org rules block, generated from bitbaum/fleet (#25)
- chore(deps): clear dev-toolchain audit findings inside existing ranges (#24)
- docs(agents): the org rules block, generated from bitbaum/fleet (#23)
- fix(release): apply verified build-output staging contract (#22)
- chore(deps): dependabot leaves TypeScript 7 alone until typescript-eslint supports it (#20)
- docs(agents): the org rules block, generated from bitbaum/fleet (#19)
- docs: record verified release history and its evidence limits (#18)
- chore: add the fleet package template files this repo is missing (release-notify.yml) (#17)
Where it is declared
Linked products declare this package in their manifests. A declaration alone does not verify every feature, version or deployed integration.
Sources and verification
Install commands and declared consumers come from Fleet’s generated registry. Documentation and release notes come from the package repository. The catalogue does not certify production journey coverage.
Documentation from source commit d799d01a5233 · 2026-11-06. Source documentation can include work beyond the registry release.



