Skip to content

PunchIn-App/punchin-email

Repository files navigation

PunchIn Email Worker — Two-Way Alias Relay for trackmytime.today

License: BUSL-1.1 CI Version

Role-address email that replies as itself — mail to an alias forwards to your inbox, and your reply goes back out from the alias, not from you.

A Cloudflare Email Worker that powers the @trackmytime.today role aliases and a two-way reply relay. Mail to abuse@, cla@, contact@, cve@, feedback@, licensing@, privacy@ (plus any +subaddress) is delivered to your inbox from the alias itself (with the sender shown in the display name). When you simply hit Reply, the response goes back out from the original alias to the original sender — no manual "From" selection, and your personal address is never exposed in either direction.

This is the infrastructure behind the contact addresses in the main PunchIn project's governance docs: CLA → cla@, security → cve@ (with cve+<number>@ sub-addressing), conduct → abuse@.

About

What A single-purpose Cloudflare Email Worker: inbound role-alias forwarding plus a stateless two-way reply relay, with a small Access-gated admin UI for runtime config.
For The @trackmytime.today contact addresses behind the PunchIn project's governance docs. Self-hostable for any domain on Cloudflare Email Routing.
Stack Cloudflare Email Workers + Workers KV + Email Sending binding. No framework, no build step, no database.
State Thread mappings live in KV with a 30-day TTL; admin-editable settings live in KV under settings:v1. Nothing is persisted beyond that.
Status Stable — v1.6.2. See docs/CHANGELOG.md for history.
License BUSL-1.1. Security policy: SECURITY.md.

The design priority is privacy and least surprise: your personal inbox is never exposed to correspondents, every conversation stays under its public alias, and unexpected mail is rejected with a clear reason rather than silently dropped. See Robustness & Safety Guards for the specific protections.

Why

Public projects need role addresses, but you don't want to expose a personal inbox or fiddle with "Send as" identities for every reply. This worker keeps the alias as the public face of every conversation while you read and reply from one ordinary inbox.

How It Works

                  ┌─────────────────────────── inbound ───────────────────────────┐
  partner@corp.com ──► cla@trackmytime.today ──► [Worker] ──► sends to your Gmail
                                                    │       FROM "partner… via PunchIn" <cla@>
                                                    │       Reply-To: relay+<id>@trackmytime.today
                                          stores {originalSender,
                                          aliasEmail} in KV under <id>

                  ┌──────────────────────────── relay ────────────────────────────┐
  you hit Reply ──► relay+<id>@trackmytime.today ──► [Worker] ──► sends FROM cla@
                                                    │            TO partner@corp.com
                                          looks up <id> in KV,
                                          rewrites From/To headers
  1. Inbound (handleInbound): an email to an allowed alias generates a random 16-char thread id, stores { originalSender, aliasEmail, timestamp } in the EMAIL_THREADS KV namespace (30-day TTL), and sends the message to FORWARD_TO via the EMAIL_SENDING binding — rewritten so it comes From the alias ("<sender> via PunchIn" <alias@RELAY_DOMAIN>) with Reply-To: relay+<id>@<RELAY_DOMAIN>. It deliberately does not forward(): forward() drops the added Reply-To, which would route your reply straight to the sender and expose your inbox address. Both From and Reply-To are relay-controlled, so your reply always comes back through the relay.
  2. Relay (handleRelay): your reply lands on relay+<id>@…. The worker verifies the sender (From == FORWARD_TO, plus a defence-in-depth check on the SPF/DKIM/DMARC verdict Cloudflare stamped on the reply), looks up the thread, rewrites the From/To headers (preserving Subject, Message-ID, In-Reply-To, References so threading survives), and sends it out via the EMAIL_SENDING binding. A successful relay also refreshes the thread's KV TTL so an actively used conversation doesn't age out mid-thread.

The worker's email() entrypoint routes by recipient: anything starting with relay+ goes to the relay handler, everything else to the inbound handler.

Robustness & Safety Guards

Guard Where Why
Alias allowlist (ALLOWED_ALIASES) inbound A catch-all route sends every address to the worker. Only the configured base local-parts are forwarded; everything else is rejected, so the worker can't be abused as an open forwarder for the whole domain.
Relay sender verification relay Only mail whose From matches FORWARD_TO is relayed. Without this, anyone who learned a thread id could send mail from your alias to the original sender. As defence-in-depth on that header-trusting gate, the worker also consults the SPF/DKIM/DMARC verdict Cloudflare stamps on the reply and rejects one that demonstrably did not authenticate as the FORWARD_TO domain (fail-open — an absent/unrecognized verdict still relays).
Auto-submitted detection relay Vacation responders / bounces (Auto-Submitted, Precedence: bulk/list, X-Autoreply, …) are not relayed, preventing mail loops.
Sender-header allowlisting relay rewrite The rewrite keeps only an allowlist of headers the recipient needs to render/thread the reply (Subject, Date, Message-ID, In-Reply-To, References, MIME-Version, Content-*) and prepends a fresh From/To; everything else — trace/auth headers that can leak the inbox address (Received, Authentication-Results, ARC-*, X-Google-*, Return-Path, Reply-To, Sender, the now-invalid DKIM-Signature, …), including any future X- header — is dropped by default. Cloudflare re-signs the outbound message for the domain.
Graceful expiry relay Replies to threads older than 30 days are rejected with a clear reason rather than silently dropped.

Tech Stack & Project Structure

  • Cloudflare Email Workers (email() handler) — inbound routing + relay
  • Workers KV (EMAIL_THREADS) — thread mappings, 30-day TTL
  • Cloudflare Email Sending (EMAIL_SENDING) — outbound relay
  • Cloudflare Access — authenticates the admin UI (fetch() handler); the worker verifies the Access JWT itself
  • Vitest — unit + handler tests
  • Wrangler — local dev, dry-run, deploy
src/
  index.js    email() + fetch() entrypoints + handleInbound / handleRelay
  lib.js      pure, runtime-agnostic helpers (unit-tested directly)
  settings.js KV-backed runtime settings layered over env defaults
  access.js   Cloudflare Access JWT verification for the admin UI (fails closed)
  admin.js    admin UI page + GET/PUT /api/settings
test/
  lib.test.js        helper unit tests
  handlers.test.js   handler tests with mocked message / KV / send bindings
  settings.test.js   getSettings / updateSettings over mocked KV
  access.test.js     Access claim + JWT signature verification
  admin.test.js      admin router (GET/PUT /api/settings) tests
  helpers.js         test doubles (KV, message, env)
  mocks/             stub for the `cloudflare:email` module
docs/
  CHANGELOG.md       Keep a Changelog history
  RELEASING.md       versioning rules + release procedure
wrangler.toml        worker config + bindings
CLAUDE.md            architecture guide

Configuration

Non-secret wrangler.toml vars:

Var Meaning
RELAY_DOMAIN Domain used in the generated relay+<id>@… Reply-To.
ALLOWED_ALIASES Comma-separated base local-parts that may forward (e.g. abuse,cla,contact,cve,feedback,licensing,privacy).
CONTACT_URL Optional. URL shown in the bounce when mail hits an unrecognized address. Defaults to https://<RELAY_DOMAIN> if unset.
ACCESS_AUD Admin UI auth — the Cloudflare Access application Audience (AUD) tag. Blank ⇒ admin UI disabled (fails closed).
ACCESS_TEAM_DOMAIN Admin UI auth — your Access team domain, e.g. yourteam.cloudflareaccess.com.

ALLOWED_ALIASES, CONTACT_URL, and FORWARD_TO are defaults — the admin UI can override them at runtime (stored in KV). The committed/secret values are the bootstrap used until you save something.

Secret (set with wrangler secret put, never committed):

Secret Meaning
FORWARD_TO Your real destination inbox. Inbound mail is forwarded here, and it is the only address allowed to drive the relay. Kept out of version control because it is personal data.
wrangler secret put FORWARD_TO   # paste your inbox address when prompted

Bindings:

  • EMAIL_THREADS — KV namespace storing thread mappings.
  • EMAIL_SENDING — Email Sending binding (unrestricted, so the relay can send to arbitrary original senders).

Never put FORWARD_TO (or any personal address / secret) in [vars] — that block is committed to the repo.

Email Routing Setup

This worker expects both the role aliases and the relay+* replies to be delivered to it. The simplest configuration is a catch-all → this worker route, because the worker already enforces the alias allowlist itself:

  1. EmailEmail Routing → enable it and let Cloudflare add the MX/TXT (SPF) records for trackmytime.today.
  2. Routing rulesCatch-all address → action Send to a Workerpunchin-email. (Alternatively, create individual Send to a Worker rules for abuse, cla, contact, cve, feedback, licensing, privacy, and relay — but catch-all is less error-prone.)
  3. Email Sending → ensure the domain's DKIM for Email Sending is configured so that both the inbound mail delivered to your inbox and the relayed replies are signed for trackmytime.today. The worker delivers inbound mail by sending it (via the EMAIL_SENDING binding), not by forward()-ing, so FORWARD_TO does not need to be a verified Email Routing destination — it just needs to be a real mailbox you control (it is also the only address allowed to drive the relay).

Subaddressing (the Cloudflare setting) is optional with catch-all. Catch-all delivers the full recipient — including cla+foo@, cve+123@, and the relay+<id>@ replies — to the worker with the +tag intact, and the worker strips the subaddress itself (baseLocalPart). You'd only need to enable Cloudflare's Subaddressing toggle if you switched from catch-all to individual per-address rules and wanted +tag variants to match them.

If you use individual rules instead of catch-all, the relay+* replies need a rule too. Without it, your replies bounce. Catch-all avoids that footgun.

Admin UI

The worker also serves a tiny admin page (and a GET/PUT /api/settings API) from its fetch() handler, so you can change the forwarding address, accepted aliases, and contact URL at runtime without a redeploy. Those values are stored in KV (settings:v1) layered over the deploy defaults; RELAY_DOMAIN stays fixed.

This route is public on *.workers.dev, so it must be gated. Auth is done with Cloudflare Access using your Cloudflare account as the identity — you sign in with the same Cloudflare login that owns the worker, no third-party identity provider required. The worker independently verifies the Access JWT and fails closed — until ACCESS_AUD + ACCESS_TEAM_DOMAIN are set, every request gets a 503.

Setup

  1. Cloudflare dashboard → your punchin-email worker → Settings → Domains & Routes → Enable Cloudflare Access (or Zero Trust → Access → Applications → Add → Self-hosted targeting punchin-email.<subdomain>.workers.dev).
  2. Identity provider: in Zero Trust → Settings → Authentication, use the built-in Cloudflare login method (the “Login with Cloudflare” provider). Leave One-time PIN off so the only way in is a Cloudflare account, and restrict it to members of your account.
  3. Policy: allow only yourself — e.g. an Allow policy with Include → Login Methods → Cloudflare (optionally narrowed to your account-member email).
  4. From the application's config, copy the Application Audience (AUD) tag and your team domain (<team>.cloudflareaccess.com) into ACCESS_AUD and ACCESS_TEAM_DOMAIN in wrangler.toml, then deploy. Turn on “Validate Access JWTs” so unauthenticated requests are blocked at the edge too.
  5. Visit https://punchin-email.<subdomain>.workers.dev/, sign in with your Cloudflare account, and you'll get the settings form.

Defence in depth: Access blocks unauthenticated requests at the edge and the worker verifies the JWT (signature + AUD + issuer + expiry) itself, so the admin API is safe even if the route is ever reached directly.

Development

npm install
npm test          # run the suite once
npm run test:watch
npm run check     # wrangler deploy --dry-run (bundles, no upload)
npm run dev       # wrangler dev (local worker)

Deploy

npm run deploy    # wrangler deploy

Cutting a tagged release (tag → gh release create vX.Y.Z → deploy) is documented in docs/RELEASING.md.

Contributing

Contributions are welcome. See .github/CONTRIBUTING.md for the workflow, versioning, documentation, and testing requirements. Contributions require agreeing to the Contributor License Agreement — you sign by including the sign-off line from the PR template in your pull request description. This project follows a Code of Conduct.

Security

Please do not file public issues for vulnerabilities. Email cve@trackmytime.today (or cve+<number>@… if a CVE is assigned) — see SECURITY.md for the full policy.

License

Business Source License 1.1 — any individual may use, modify, and self-host the worker to relay role-address email for a domain they personally control, at no charge. Organizational use (a company, agency, or other legal entity deploying it as part of its internal tooling, mail infrastructure, or operations) requires a commercial license from PunchIn-App (licensing@trackmytime.today). On the Change Date (2030-06-02) the license converts to the GNU AGPL v3.0.

About

Cloudflare Email Worker that gives a domain two-way role aliases (cla@, cve@, …) — mail forwards to your inbox and replies go back out from the alias. Includes an Access-gated admin UI.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Sponsor this project

Contributors