Skip to content

Add Cloudflare Email Worker: Discord notify + mail forward - #1

Merged
rin2yh merged 25 commits into
mainfrom
claude/email-discord-webhook-worker-am5f2q
Jul 20, 2026
Merged

Add Cloudflare Email Worker: Discord notify + mail forward#1
rin2yh merged 25 commits into
mainfrom
claude/email-discord-webhook-worker-am5f2q

Conversation

@rin2yh

@rin2yh rin2yh commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

概要

メールをユーザー名対応の Discord Webhook に通知しつつ、別のメールサーバーへ転送する Cloudflare Email Worker。

  • {username}@your-domain(例 user1@gophercon.jp)宛のメールを処理
    • {username}@FORWARD_EMAIL_DOMAIN転送
    • {username} 対応の Webhook へ embed で通知(未登録は転送のみ・エラーなし)
  • TypeScript / bun / ドメイン非依存
    • 実行時依存は postal-mime の1つのみ

設計判断

  • 本文パースは postal-mime
    • Email Worker は message.raw(生MIME)しか手段がなく、multipart / エンコーディング / 文字コード / HTML→text を自前実装するのは困難
  • mise.toml で bun / node をピンし、環境差をなくす
  • サプライチェーン対策
    • bunfig.tomlminimumReleaseAge(3日)

レビュー観点

内部品質

  • 責務を分割
    • ハンドラ src/index.ts / Discord 整形 src/discord.ts / メール処理 src/email.ts

外部品質

  • 要件どおりの挙動を23テストで担保
    • 登録ユーザー=転送+通知、未登録=転送のみ・エラーなし
  • 異常系でも転送が継続することを検証
    • 通知失敗時、DISCORD_WEBHOOK_MAP が不正なJSON の場合
  • Discord の上限に合わせた本文トリミングを検証
    • title 256 / description 4096

補足

  • CIは次のPRで実装予定
    • lint関連のコメントがあれば、次のPRで対応します
  • CDはCIの次のPRを予定
    • 上に同じく、該当のPRで対応します

claude added 12 commits July 14, 2026 04:46
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
claude added 3 commits July 14, 2026 06:37
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
claude added 10 commits July 14, 2026 06:52
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
rin2yh marked this pull request as ready for review July 14, 2026 09:31
@rin2yh
rin2yh requested a review from logica0419 July 14, 2026 09:31
@rin2yh
rin2yh requested a review from otakakot July 14, 2026 09:31
@otakakot

otakakot commented Jul 14, 2026

Copy link
Copy Markdown

@rin2yh
ありがとうございますー!

単体テストあるのでこのタイミングでCI環境もちゃちゃっと作ってよさそうに思いました 👀

以下のPRで追加予定だったのですね 💦

#2

問題なさそうです!LGTMです〜

@rin2yh

rin2yh commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

@otakakot

単体テストあるのでこのタイミングでCI環境もちゃちゃっと作ってよさそうに思いました 👀

レビューありがとうございます!
実は、別のPRでCIを実装していて、stacked PRの形(ベースブランチがこのブランチ)になってます。
#2

一気にレビュー依頼すると重いかなと思い、別PRに逃してました

Comment thread package.json
@rin2yh
rin2yh merged commit 3b76828 into main Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants