Decouple core from Cloudflare; add a self-hosted Node/Docker deployment path

Cinderbox was fully hard-locked to Cloudflare (D1Database and
ForwardableEmailMessage types baked directly into core/db.ts,
core/ratelimit.ts, and core/email.ts) and the README read like a Discord
bot's docs rather than what this actually is: a small mail-routing engine
with a pluggable delivery adapter, of which Discord is the first.

- Add SqlExecutor (core/storage.ts): the minimal run/first/all interface
  core runs SQL against. D1 and SQLite both speak SQLite dialect, so every
  query in core/db.ts and core/ratelimit.ts is now shared verbatim between
  both storage backends -- no duplicated business logic, only two thin
  driver implementations (storage/d1.ts, storage/sqlite.ts via
  better-sqlite3, chosen over node:sqlite because the latter is still
  flagged experimental).

- core/email.ts no longer takes Cloudflare's ForwardableEmailMessage --
  postal-mime's own RawEmail type already accepts everything either
  deployment target hands it (a Worker's ReadableStream, a Buffer from a
  self-hosted SMTP server), so that's the only shape core needs.

- Add a self-hosted entrypoint (src/node/): an SMTP server for inbound mail
  (smtp-server), a plain HTTP server for Discord interactions, and a
  cleanup schedule matching the Workers cron timing. Runs as a plain Node
  process or via the new Dockerfile, storing state in a local SQLite file
  with the schema applied automatically on first run.

- Rate limits and address quota/TTL are now env-configurable
  (MAX_ACTIVE_ADDRESSES, ADDRESS_TTL_SECONDS, RATE_LIMIT_<CMD>_*) with
  defaults matching prior hardcoded behavior. Self-hosters own the
  abuse-vs-friction tradeoff for their own deployment; setting any
  RATE_LIMIT_*_MAX to 0 disables that command's limit entirely.

- Every relative import gained an explicit .ts extension. Node's native
  ESM loader (used by the new self-hosted path) requires this for runtime
  imports -- it has no bundler resolving extensionless or directory
  specifiers the way Wrangler's esbuild step does for the Workers path.
  Harmless for the Workers build, required for the Node one.

- Rewrite the README: leads with the core service and the two pluggable
  boundaries (delivery adapters, storage/receiver backends) instead of
  treating Discord as the product. Documents both deployment paths side
  by side, including the DNS setup for self-hosting (MX -> your own IP,
  genuinely different from the Cloudflare path) and the port-25/HTTPS-
  webhook caveats that path involves.

Verified: full core logic (create/get/list/extend/revoke/rate-limit)
produces identical results against SqliteExecutor as against D1 in a
direct smoke test; the self-hosted SMTP+HTTP host boots, correctly
rejects mail for the wrong domain (550) and silently drops mail for an
unregistered address on the right domain, and delivers a real Discord DM
end-to-end through the new code path (confirmed receipt). The Cloudflare
Worker still bundles and deploys cleanly after the import-path changes,
and its live interactions endpoint was reverified post-deploy.
Psalm 25 17committed parent 3816faddd52bd5fad6b18cc5541ecb5cb39e24f59b08ddcBrowse files
22 files changed+869−185

Rendering diff…