Add Cloudflare Email Worker: Discord notify + mail forward - #1
Merged
Conversation
Implement an Email Worker that, per incoming message, forwards it to
{username}@FORWARD_EMAIL_DOMAIN and posts a Discord embed to the webhook
mapped to the recipient username.
- src/index.ts: email() handler (non-awaited forward, lazily cached
DISCORD_WEBHOOK_MAP parse, skip notify for unmapped users, errors logged
and swallowed so forwarding is never blocked).
- src/email.ts: username extraction, postal-mime body parse, HTML->text.
- src/discord.ts: embed formatting with Discord limit trimming.
- .env.yaml as single config source with gen:dev-vars (local .dev.vars) and
set:secrets (wrangler secret bulk) scripts; secret files git-ignored.
- Tooling: bun, @cloudflare/vite-plugin, vitest + @cloudflare/vitest-pool-workers.
- 21 tests covering helpers and handler flow.
Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Quality cleanups from a review pass (no behavior change intended): - discord.ts: drop BODY_LIMIT double-truncation; truncate the body once against the real EMBED_DESCRIPTION_LIMIT. - index.ts: key the webhook-map cache on the raw secret so it re-parses on change; removes the test-only resetWebhookMapCache export while still parsing once per instance in production. - email.ts: fold the six HTML entity-decode passes into a single regex pass; flatten the nested from-address ternary. - test/fixtures.ts: shared buildMime/mimeStream helpers, reused by the email and handler tests instead of duplicated MIME strings. - gen-dev-vars.ts: serialize secrets generically so new keys propagate. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Replace the hand-written .gitignore with GitHub's official Node template and append the Cloudflare Worker specifics (wrangler, .dev.vars, .env.yaml, generated worker types), including a negation to keep .env.yaml.example tracked since the template's `.env.*` rule would otherwise ignore it. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Supply-chain hardening: refuse npm versions published less than 3 days (259200s) ago, so a compromised fresh release has time to be caught and yanked before it can be installed. Existing pinned lockfile versions are unaffected; the cooldown applies to future adds/updates. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
- Update the only outdated dependencies to latest majors: typescript 5.9 -> 7 and @types/node 22 -> 26. All other deps were already at latest. Typecheck, the 21 tests, and the build stay green. The 3-day minimumReleaseAge cooldown from bunfig.toml applies to these installs. - Add mise.toml pinning bun 1.3.11 and node 24 so contributors get a consistent toolchain; ignore mise local override files. - Document the mise + cooldown workflow in the README. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
- Rename the `cf-typegen` script to `gen:cf-types` so all generators share the `gen:` prefix. - Add a `gen` script that runs every generator (gen:cf-types + gen:dev-vars). - Document both in the README. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Drop the Node template's entries for tools/frameworks this project doesn't use (Grunt, Bower, Snowpack, Next/Nuxt/Gatsby/vuepress/vitepress/SvelteKit/ Docusaurus, Serverless, FuseBox, DynamoDB, Firebase, pnpm/yarn-v3, npm/parcel caches, node-gyp, diagnostic reports, pids). Keep only what's relevant to bun/vite/vitest/wrangler/TypeScript plus the Cloudflare Worker specifics. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Production secret upload isn't needed for now. Drop the set:secrets script and its scripts/set-secrets.ts; keep gen:dev-vars (local) and the shared load-config.ts. README now points to `wrangler secret put` / the dashboard for setting production secrets. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
- Move tests out of test/ to src/*.test.ts (email/discord/index), colocated with the code they cover; move the MIME helpers to src/test-fixtures.ts. - Express each spec through the test name and drop explanatory comments. - Use it.each table-driven cases where the inputs/outputs are plain data (extractUsername, htmlToText, parseWebhookMap, embed limits); keep mock-driven handler cases as individual tests. - Update tsconfig include to drop the removed test/ dir. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Shorten the header/field comments to one-liners; the full explanation already lives in the README. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Fold the 6-step handler walkthrough into a short paragraph, drop the duplicated .env.yaml sample (it lives in .env.yaml.example), and tighten Configuration and Development into compact command blocks. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
- Drop source comments that merely restate behavior already pinned by tests (extractUsername/parseEmail/htmlToText/buildDiscordPayload/notifyDiscord/ parseWebhookMap and the handler's skip/error-swallow steps); keep the non-obvious rationale (webhook-map cache keying, forward concurrency). - Move the MIME/mock helpers and fixtures to src/test/fixtures.ts (including makeMessage/makeEnv) so the colocated src/*.test.ts files stay short. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Move the primary entry points to the top of each file and push supporting helpers below them, relying on function hoisting: the email() handler now leads src/index.ts (parseWebhookMap / webhook-map cache follow), and notifyDiscord leads src/discord.ts (buildDiscordPayload / truncate / limit constants follow). Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Restructure each test file into three describe groups — positive (happy path), semi-positive (alternative/boundary but valid), negative (invalid/error) — and distribute the existing cases accordingly. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Group tests by function first, then by case type: each function gets a describe, containing positive / semi-positive / negative sub-describes with the relevant cases. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
rin2yh
pushed a commit
that referenced
this pull request
Jul 14, 2026
Reconcile with the rebased PR #1 base (test helpers moved to src/test/fixtures.ts, comments trimmed, README condensed). Resolutions: - discord.ts keeps the concrete DiscordWebhookPayload return type on top of PR #1's trimmed style. - oxlint unknown-override path updated to src/test/fixtures.ts. - README merged: kept PR #1's condensed prose, kept the CI/lint/format additions. - Reformatted with oxfmt so `oxfmt --check` stays clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GKWrRcDKcg13Xqv5qrr1V
buildDiscordPayload now returns DiscordWebhookPayload / DiscordEmbed (exported) instead of unknown, so the test asserts against the real production type rather than a locally duplicated Embed interface that could drift from the payload. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Move the module-level constants above the type definitions so limits and lookup tables are visible up front: the Discord embed limits in src/discord.ts and the HTML entity table in src/email.ts now lead their files. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
positive = specified behaviour (including fallbacks and limit trimming); semi-positive = validation that rejects out-of-contract input; negative = abnormal external failures. Move the spec-driven fallback/trimming cases into positive, keep only the invalid-config validation in semi-positive, and leave the webhook error-response case as negative. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
The field-based it.each branched on the row (embedOf(email)[field]), which doesn't fit a data table cleanly. Replace it with two plain tests for the title and description limits. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Real mail always has a sender (From is required and the envelope MAIL FROM is always present), so a header-less message asserting "(unknown sender)" was unrealistic. Test the case that actually occurs — a message with a sender but no Subject — asserting the "(no subject)" fallback while the sender is extracted. The defensive sender fallback stays in code for type/crash safety. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
A code-audit subagent judged these guards unreachable given the runtime: - extractUsername: message.to is always a non-empty bare envelope address, so the empty-string guard, the "Name <addr>" angle-bracket parsing, and the no-"@" fallback can never fire. Reduce to slicing the local part. - truncate: all call sites use limits (256/4096/1024) far larger than the marker length and only run when value exceeds the limit, so the Math.max(0, ) clamp never fires. Guards it judged JUSTIFIED (postal-mime optional types, secret validation, external HTTP body read, empty-body/no-subject fallbacks) are kept. Drop the now-unreachable extractUsername test rows. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Rather than silently slicing a malformed value, make the "input is an email address" invariant an explicit contract: throw if there is no "@". The case can't occur for a real message.to, so this is a loud tripwire, not graceful handling. Add a test documenting the contract. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Replace the per-field assertions and the separate limit tests with one it.each that deep-equals the whole embed against an expected value. The over-long rows encode the exact truncated string, so they cover both the marker and the length limit in a single equality. Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
Make parseWebhookMap, htmlToText, buildDiscordPayload and the Discord payload types private again, and test them through the public boundary instead: - HTML-to-text via parseEmail (an HTML-only message), - the Discord embed via notifyDiscord (asserting the posted request body), - webhook-map validation via the handler (invalid/non-object config forwards without notifying). Only genuinely public surface stays exported (extractUsername, parseEmail, ParsedEmail, notifyDiscord, Env, the worker). Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
- email.ts: match HTML entities with a generic `&(#?\w+);` regex + map lookup instead of restating the map's keys in the alternation (single source). - test/fixtures.ts: build makeMessage's MIME from the passed `to` so the raw To header and message.to agree; add shared spyFetchOk/spyFetchError helpers. - index/discord tests: use the shared fetch-spy helpers. Skipped: exporting the embed limits/marker for the test (keeps the no- test-only-export policy), dropping the webhook-map cache (the "parse once per instance" requirement), and merging WorkerSecrets into Env (distinct concerns). Claude-Session: https://claude.ai/code/session_012cEF1ARSdtXP8iWwDPn6Ly
rin2yh
pushed a commit
that referenced
this pull request
Jul 14, 2026
Take PR #1's refactored source and tests as canonical (typed Discord payload, private internals, reorganized tests, spyFetch helpers). Re-apply this branch's CI policy on top: - src/index.ts: per-line disable for the JSON.parse boundary `unknown`. - src/test/fixtures.ts: type the message mock instead of `as unknown as`. - src/discord.test.ts: per-line disables for the two genuine test-only `unknown` uses (parsed JSON return, table expected value). Reformatted with oxfmt; oxlint/typecheck/tests all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GKWrRcDKcg13Xqv5qrr1V
rin2yh
marked this pull request as ready for review
July 14, 2026 09:31
Contributor
Author
otakakot
reviewed
Jul 14, 2026
otakakot
approved these changes
Jul 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要
メールをユーザー名対応の Discord Webhook に通知しつつ、別のメールサーバーへ転送する Cloudflare Email Worker。
{username}@your-domain(例user1@gophercon.jp)宛のメールを処理{username}@FORWARD_EMAIL_DOMAINへ転送{username}対応の Webhook へ embed で通知(未登録は転送のみ・エラーなし)postal-mimeの1つのみ設計判断
postal-mimemessage.raw(生MIME)しか手段がなく、multipart / エンコーディング / 文字コード / HTML→text を自前実装するのは困難mise.tomlで bun / node をピンし、環境差をなくすbunfig.tomlのminimumReleaseAge(3日)レビュー観点
内部品質
src/index.ts/ Discord 整形src/discord.ts/ メール処理src/email.ts外部品質
DISCORD_WEBHOOK_MAPが不正なJSON の場合補足