Architecture
One Cloudflare Worker by default. Email Routing (or mail.tm) receives, D1 stores, Discord and/or Telegram and/or Slack deliver.
src/worker.ts The main entrypoint. fetch() serves Discord
interactions, the Telegram webhook (optional, see
below), Slack slash commands, and the status page.
email() takes inbound mail from Email Routing.
scheduled() runs the mail.tm poll and the daily
cleanup. Picks domain vs mail.tm mode from whether
DISPOSABLE_DOMAIN is set.
src/telegram-worker.ts An optional second entrypoint, only relevant if
you're running Discord and Telegram together and
want them bundle-isolated from each other. See
docs/telegram-adapter.md. Owns only the Telegram
webhook -- no Email Routing, no cron, so there's
exactly one place (src/worker.ts) that could ever
double-run the mail.tm poller or daily cleanup.
src/core/ Address CRUD, rate limiting, dispatch, MIME
parsing, plus command/config logic shared by every
chat-platform adapter (core/commands.ts,
core/config.ts, core/html-to-text.ts). Doesn't
import from adapters/ or storage/.
src/core/storage.ts SqlExecutor: the run/first/all interface core runs
SQL against, so core has no D1 types in it.
src/storage/d1.ts The SqlExecutor implementation for D1.
src/adapters/ Delivery adapters. discord/, telegram/, and slack/
all ship built in.
src/receivers/mailtm/ mail.tm's API client, the poller, and its cleanup
(which deletes the remote mailbox before dropping
the row). Used for any mail.tm-backed address
regardless of which Worker created it.
src/counter-page.ts The status page's HTML, inlined, no external
assets.
Two modes, one Worker
DISPOSABLE_DOMAIN decides how addresses get created on whichever Worker you're looking at:
- Set. Addresses are generated on your domain. Cloudflare Email Routing's catch-all rule invokes
email(). - Empty.
createMailtmAddressprovisions a real mailbox on mail.tm per/new.
The poll cron and daily cleanup run unconditionally on src/worker.ts regardless of this setting, since the database can hold a mix of both kinds of address at once, for example if Telegram is split onto its own Worker independently running in mail.tm mode while the main Worker is in domain mode. Both pollOnce and runMailtmCleanup already no-op cheaply for rows that aren't mail.tm-backed, so there's no benefit to gating either call on this Worker's own mode.
Everything else, commands, storage, delivery, cleanup, the status page, is identical between modes.
Tests
npm test
Node's built-in runner against a real in-memory SQLite database. No dependencies, no mocks: D1 speaks the same dialect, and these run through the same SqlExecutor interface src/storage/d1.ts implements.
Covers command semantics, mail rendering, counters, expiry reminders, and that schema.sql still agrees with the migration chain. Several cases exist because a failure there once broke mail delivery: a missing counters table, and anything thrown by a reminder taking out the cleanup that runs alongside it.
Extending it
Delivery adapter. Implement MailAdapter in src/core/types.ts: a name, a deliver(owner, mail) for forwarded email, and a notify(owner, message) for plain messages from the bot itself (expiry reminders). Both return { success, error? } and never throw. Register it in buildAdapters() in src/worker.ts -- this part always lives there, since that's the only Worker that ever processes inbound mail.
If the platform also needs inbound commands (/new, /list, etc., not just outbound delivery), that's a separate piece: parse whatever shape that platform's messages arrive in, reuse the shared logic in src/core/commands.ts and src/core/config.ts rather than reimplementing expiry/note parsing, and call the same core/db.ts functions Discord, Telegram, and Slack all use. Whether that handler needs its own Worker (like src/telegram-worker.ts) or can just add a route to src/worker.ts's fetch() depends on whether you want it bundle-isolated from the other adapters -- see src/adapters/telegram/webhook.ts or src/adapters/slack/webhook.ts for that pattern; Slack's is the simpler of the two, since it has no split-Worker option to account for.
Storage backend. Implement SqlExecutor in src/core/storage.ts. Call handleInboundEmail({ to, from, raw }, db, dispatcher) from core/email.ts for each piece of mail. raw takes a Buffer, ReadableStream, or string, whatever postal-mime accepts.