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@.
| 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.
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.
┌─────────────────────────── 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
- Inbound (
handleInbound): an email to an allowed alias generates a random 16-char thread id, stores{ originalSender, aliasEmail, timestamp }in theEMAIL_THREADSKV namespace (30-day TTL), and sends the message toFORWARD_TOvia theEMAIL_SENDINGbinding — rewritten so it comesFromthe alias ("<sender> via PunchIn" <alias@RELAY_DOMAIN>) withReply-To: relay+<id>@<RELAY_DOMAIN>. It deliberately does notforward():forward()drops the addedReply-To, which would route your reply straight to the sender and expose your inbox address. BothFromandReply-Toare relay-controlled, so your reply always comes back through the relay. - Relay (
handleRelay): your reply lands onrelay+<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 theFrom/Toheaders (preservingSubject,Message-ID,In-Reply-To,Referencesso threading survives), and sends it out via theEMAIL_SENDINGbinding. 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.
| 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. |
- 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
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 promptedBindings:
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.
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:
- Email → Email Routing → enable it and let Cloudflare add the
MX/TXT(SPF) records fortrackmytime.today. - Routing rules → Catch-all address → action Send to a Worker →
punchin-email. (Alternatively, create individualSend to a Workerrules forabuse,cla,contact,cve,feedback,licensing,privacy, andrelay— but catch-all is less error-prone.) - 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 theEMAIL_SENDINGbinding), not byforward()-ing, soFORWARD_TOdoes 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 therelay+<id>@replies — to the worker with the+tagintact, 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+tagvariants 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.
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.
- Cloudflare dashboard → your
punchin-emailworker → Settings → Domains & Routes → Enable Cloudflare Access (or Zero Trust → Access → Applications → Add → Self-hosted targetingpunchin-email.<subdomain>.workers.dev). - 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.
- Policy: allow only yourself — e.g. an
Allowpolicy with Include → Login Methods → Cloudflare (optionally narrowed to your account-member email). - From the application's config, copy the Application Audience (AUD) tag and
your team domain (
<team>.cloudflareaccess.com) intoACCESS_AUDandACCESS_TEAM_DOMAINinwrangler.toml, then deploy. Turn on “Validate Access JWTs” so unauthenticated requests are blocked at the edge too. - 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.
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)npm run deploy # wrangler deployCutting a tagged release (tag → gh release create vX.Y.Z → deploy) is
documented in docs/RELEASING.md.
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.
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.
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.