Imported from tilianb/hidemyemail (
AGENTS.md). Install upstream withnpx skills add tilianb/hidemyemail. Copyright stays with the author.
AGENTS.md
Guidance for AI coding agents (and new contributors) working in this repo.
Keep this file current. If your change adds/renames a top-level directory, npm script, migration convention, CI workflow, or security invariant listed here, update this file in the same PR. Treat a stale AGENTS.md as a bug.
Memory and Learning
Before starting any task:
Read Learnings.md in full. Apply all entries under "What Has Worked"
and "Patterns and Preferences." Avoid all patterns listed under
"What Has Failed."
After completing any task:
Update Learnings.md with new observations using this format:
[Date] — [Task type]
- Observation: [what you noticed]
- Action: [what to do or avoid going forward]
- Confidence: [high / medium / low]
Be specific. "Avoid relative imports in /utils — the build step resolves them incorrectly" is useful. "Be careful with imports" is not.
Do not add:
- Observations already captured in the file
- General best practices (only project-specific ones)
- Redundant restatements of existing entries
- Machine-specific paths, device names, host OS quirks, or instructions tied to one agent's environment; describe required tool versions and portable environment discovery instead
What this project is
Self-hosted, serverless email alias service ("hide my email" style): Cloudflare Worker (Hono) + D1 (SQLite) + React dashboard, with AWS SES/S3/SNS for receiving and sending mail. Single Worker serves both the API and the built dashboard assets.
Repo layout
| Path | What lives there |
|---|---|
worker/ |
Cloudflare Worker — all backend logic |
worker/src/api/ |
Hono app (app.ts wires routes + session guard) and routes/ |
worker/src/email/ |
Mail pipeline: router.ts dispatch → inbound.ts (forward), reply.ts (reverse-alias replies), action.ts (signed mailto actions) |
worker/src/db/ |
D1 query helpers, re-exported via db/queries.ts (import * as q) |
worker/src/lib/ |
Crypto, auth primitives, settings, MIME, SES SigV4 client |
worker/migrations/ |
D1 migrations, numbered 00NN_name.sql, append-only |
worker/test/ |
Vitest + @cloudflare/vitest-pool-workers (cloudflare:test env) |
dashboard/ |
React 19 + Vite SPA (TypeScript), no UI framework — hand-rolled CSS in src/index.css |
extension/ |
Chromium Manifest V3 popup — vanilla TypeScript, local-only credentials, optional per-origin access |
docker/ |
Self-host runtime: Node server wrapping the Worker via Miniflare |
docs/ |
Setup/deploy/config docs + ROADMAP.md (tracked backlog) |
ios/ |
Native SwiftUI app (XcodeGen project.yml) |
android/ |
Native Android app — Kotlin + Jetpack Compose (Gradle, package dev.hidemyemail.app) |
website/ |
Astro Starlight docs site, generated from docs/ + README/CHANGELOG/ROADMAP by scripts/sync-docs.mjs; published to GitHub Pages |
Mobile (iOS + Android)
- The SwiftUI app (
ios/) is the product; the owner wants its look kept. Build:cd ios && xcodegen generatethen xcodebuild. Team ID is inproject.yml. Passkey login requires a paid Apple team (Associated Domains); on a personal team, stripCODE_SIGN_ENTITLEMENTSlocally to sideload — never commit that strip. CI:.github/workflows/ios.yml(build + tests on a simulator). - The Android app (
android/) is a hand-written Kotlin/Jetpack Compose mirror of the iOS screens and theme tokens (the 2026-06-10 Skip transpilation spike was abandoned in favor of this native port). Both apps talk to the Worker in bearer-token mode (X-Auth-Mode: token); keepApiClient.ktandAPIClient.swiftin feature parity. Build:cd android && ./gradlew :app:assembleDebug(needsJAVA_HOME+ANDROID_HOME). CI:.github/workflows/android.yml(build + lint). - Android builds require JDK 21 and Android SDK 35. Discover the installed
toolchains in the current environment and set
JAVA_HOME,ANDROID_HOME, andPATHaccordingly; do not assume a package manager, fixed filesystem location, or host-specific setup script.
Build & test
# Worker — tests run against a real workerd runtime
cd worker && npm ci && npm test && npx tsc --noEmit
# Dashboard — tsc is part of the build
cd dashboard && npm ci && npm run build
# Chromium extension — tests + reproducible dist/ and ZIP
cd extension && npm ci && npm test && npm run build && npm run zip
Always run all three before committing; CI (.github/workflows) runs them too.
There is no lint step beyond tsc. Local dev: npx wrangler dev in worker/
plus npm run dev in dashboard/ (Vite proxies to the Worker).
First-deploy secret bootstrap: cd worker && npm run setup (interactive;
-- --print emits KEY=VALUE lines for the Docker .env). It shares its
PBKDF2 derivation with scripts/hash-password.mjs via scripts/pbkdf2.mjs.
Docs site: cd website && npm install && npm run dev (build: npm run build).
npm run sync (auto-run before dev/build) regenerates src/content/docs/ from
the repo markdown — never hand-edit that directory; edit docs//README instead.
CI: .github/workflows/docs.yml builds and deploys to GitHub Pages on push to
main.
Conventions
- Branches: work lands on
devvia feature branches;mainis release. PRs todevunless told otherwise. Never merge a PR before CI is green. - Commits: conventional commits (
feat(worker): …,fix(email): …). Bodies explain why. No AI co-author trailers. - Releases: release PRs merge
devintomain; patch releases must not contain new features. Before tagging, updateCHANGELOG.md, Worker and dashboard package versions and lockfiles, AndroidversionName/ monotonically increasingversionCode, and the iOS baseline versions inproject.yml. Write GitHub release notes for users, not a raw commit list: start with a short summary, group changes under descriptive headings, explain user-visible behavior and security impact in plain language, include an Upgrade Notes section covering migrations and config changes (or explicitly say none), and end with the full comparison link. Use the detailed v1.1.0 release notes as the quality baseline. Do not leave the auto-generated notes as the published release description. After tagging, verify the GitHub release notes, APK, Chromium extension ZIP, TestFlight upload, and GHCR / Docker Hub images before declaring the release complete. - Migrations: new numbered file in
worker/migrations/; never edit an applied one. Keep columns nullable / defaulted so existing rows keep their behavior. Code does NOT tolerate missing tables (no try/catch migration fallbacks — that pattern was deliberately removed). - Settings: runtime-tunable knobs go in
SETTING_DEFAULTS(worker/src/config.ts), get validation inworker/src/api/routes/admin/settings.ts, a UI row indashboard/src/pages/Admin.tsx, and a row indocs/CONFIGURATION.md. All four or it's not done. - Docs: user-visible behavior and changes to APIs, configuration, security,
deployment, or setup must update their source docs (
README.md,docs/, andCHANGELOG.mdwhen release-facing) in the same PR. The website syncs those sources during dev/build; never hand-editwebsite/src/content/docs/. - Tests: every behavior change gets a test in
worker/test/. Pattern: build the Hono app withcreateApp(), callapp.request(...)with a signed cookie, or callhandleInbound/handleReplydirectly with a__sesSendsentinel env to capture outbound mail.
Security invariants (do not weaken)
- Destination emails are AES-encrypted at rest (
lib/crypto.ts) and looked up by HMAC hash (email_hash) — never store or log plaintext addresses. - API keys for the addy.io-compatible
/api/v1surface are shown once and stored as SHA-256 only (lib/api-keys.ts); creating or revoking one is fresh-auth gated like passkey enrolment./api/v1authenticates exclusively by Bearer key — it must never read session cookies, and its CORS policy (any origin, credentials OFF) depends on that. - Sensitive account operations (MFA changes, data export, account deletion)
require fresh auth via
hasFreshAuth(worker/src/api/auth-helpers.ts), not just a session: the__Host-fresh-authcookie for web clients, or theX-Fresh-Authheader (issued only in token-mode login responses) for native bearer clients. - Dashboard
X-Expected-User-IDis an optional tab-account consistency hint, never authentication. Compare it with the verified session in the session guard so shared-cookie changes cannot retarget a stale tab's operation. - WebAuthn derives its RP ID and expected origin only from canonical
APP_ORIGIN, never request headers. Passkey challenges and native app-auth codes are one-time artifacts; preserve atomic consumption before admission. - Account recovery rotates
auth_versionand revokes every credential class: sessions, fresh-auth credentials, MFA, passkeys, and API keys. Native bearer credentials are bound to a canonical server origin in both mobile clients. - Reverse-alias replies are gated by SES SPF/DMARC verdicts AND a
first-contact check (
hasPriorInbound) — reverse addresses are guessable. Thecontactstable is the source of truth for that gate; preserve contact rows independently of event retention. - Inbound forwards respect SES spam/virus verdicts (
spam_verdict_action,virus_verdict_action). Forwarded mail is DKIM-signed by the alias domain, so forwarding junk burns the operator's sender reputation. - SNS webhooks verify signatures and TopicArn before acting.
- SNS/SES processing uses durable delivery claims and quota reservations to fence retries and concurrent sends; do not replace them with read-then-write checks or acknowledge retryable failures as completed.
- Docker trusts
X-HideMyEmail-Client-IPonly from an exact socket peer listed inTRUSTED_PROXY_IPS; the proxy must overwrite, never append, that header. - State-changing public endpoints must be POST (e.g. unsubscribe: GET only renders a confirm form — mail scanners prefetch GET links).
- Unauthenticated auth-adjacent endpoints (
/login,/register,/restore, recovery) are IP-rate-limited via therate_limitstable.
Gotchas
app.tsroute order matters: public routers are mounted BEFORE the session-guard middleware; the guard's exempt-path list is belt-and-braces. New public endpoints need both (mount before guard + add to the list)./api/v1follows the same pattern with its own Bearer-key middleware and its own CORS policy (see the dispatch at the top ofcreateApp).- Alias-creation rules (quota, local-part validation/generation, default-
destination resolution) are shared between the dashboard route
(
routes/aliases.ts) and the addy.io API (routes/v1.ts) viadb/aliases.ts+lib/alias-format.ts. Change the rules there so the two surfaces cannot drift. - Two forwarding paths in
inbound.ts: raw-MIME header rewrite (default) and full mimetext rebuild (inline-actions / over-quota). Header changes must be applied to BOTH. - D1 in tests is real SQLite — migrations from
worker/migrations/are applied by the vitest pool config automatically. worker/wrangler.jsoncsetskeep_vars: true; deploys must not clobber dashboard-managed vars (npm run deployalready passes--keep-vars).- Docker self-host runs the same Worker under Miniflare
(
docker/server.mjs) — Worker features used must exist there too (e.g. the cronscheduled()handler is invoked by asetIntervalshim).