Add email subscribe form to blog posts via Resend#98
Conversation
A subscribe form renders at the bottom of every blog post (gated on frontmatter.date so it doesn't appear on /, /demo, or the blog index). The form POSTs to /api/subscribe, a Worker entry that adds the email to a Resend Segment. A GitHub Action on push to main detects newly-added blog MDX files and sends a Resend broadcast for each. Also switches the staging route from new.moq.dev to moq.wtf and adopts the Workers-with-Static-Assets pattern (main + ASSETS binding in wrangler.jsonc) so the same project can serve both static pages and the /api/* endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Your plan currently allows 1 review/hour. Refill in 51 minutes and 6 seconds. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more review capacity refills, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThis pull request implements a complete email subscription system. It adds a Cloudflare Worker at 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/notify-new-post.yml:
- Line 6: The workflow's paths glob ("paths" key) is non-recursive and doesn't
match nested posts; update it to the recursive pattern
("src/pages/blog/**/*.mdx") and make the same change to the git-diff detection
glob used elsewhere in the workflow (the git diff/changed-files glob string) so
both use "src/pages/blog/**/*.mdx" and remain consistent.
- Around line 17-23: Update the workflow to pin external actions to immutable
SHAs and disable checkout credential persistence: replace the floating
actions/checkout@v4 reference with
actions/checkout@2d7d9f7ff5b310f983d059b68785b3c74d8b8edd and add
persist-credentials: false under its with block, and replace
oven-sh/setup-bun@v2 with the specific commit SHA for that action (pin the
oven-sh/setup-bun action to an immutable commit) while keeping the existing
bun-version setting unchanged.
In `@scripts/notify-subscribers.ts`:
- Around line 25-30: The slug and URL are inserted raw into HTML templates which
can break links or allow unsafe characters; update the slug and url construction
so slug = encodeURIComponent(basename(path, ".mdx")) (use the existing
basename/SITE variables) and build url = `${SITE}/blog/${slug}` with the encoded
slug, then HTML-escape the resulting url before inserting into any HTML/template
output (and apply the same HTML-escape to any places that output the URL/text
around the template insertion referenced by slug/url and the later uses around
lines 92-95); add or reuse a small htmlEscape helper and use it when writing
attributes or text.
- Around line 33-57: The two fetch calls that create and send a Resend broadcast
(the requests assigned to variables create and send in
scripts/notify-subscribers.ts) lack per-request timeouts and can hang
indefinitely; wrap each fetch with an AbortController, pass controller.signal to
fetch, start a setTimeout to call controller.abort() after a reasonable timeout
(e.g., 10s), and ensure the timeout is cleared after the fetch completes (use
try/finally) so you abort stalled requests but don't leak timers; apply this
pattern to both the POST to "https://api.resend.com/broadcasts" (create) and the
POST to `https://api.resend.com/broadcasts/${id}/send` (send).
In `@src/components/subscribe.tsx`:
- Around line 10-33: Replace the inline error-display in handleSubmit with the
shared component-level error handling pattern used in src/components/fail.tsx:
import and render the Fail component (or use the same error state shape/API it
expects) instead of calling setError directly for fetch errors and catch blocks,
and update handleSubmit to set only the error value/state the Fail component
consumes while keeping setState("error")/("success") logic intact; specifically
update the handleSubmit function, its usage of setError and error state, and add
the Fail component import so the UI uses the centralized fail component for all
error rendering.
In `@worker/index.ts`:
- Around line 56-58: The current error logging in the resend response handling
prints the full upstream body (using await res.text()), which may contain
sensitive subscriber data; change the console.error call inside the !res.ok
branch to log only the HTTP status and any available upstream request identifier
(e.g., res.headers.get('x-request-id') or similar) and avoid including the
response body. Update the call site where res.ok is checked (the block
referencing res.ok and await res.text()) to capture the request id header,
format a concise message with status and request id, and use the existing
application logger (instead of console.error) if available to emit the sanitized
error.
- Around line 40-52: The res = await fetch(...) call in worker/index.ts can
throw on network/DNS/TLS errors and currently bypasses your status checks,
leading to an unstructured worker error; wrap the fetch call in a try/catch
around the res variable creation, catch any thrown error (e.g., network error),
log it and return the API's structured error response with an appropriate HTTP
status (e.g., 502/503) and JSON body consistent with your contract instead of
letting the exception bubble; ensure the catch references the same
env.RESEND_API_KEY/context and preserves existing handling for non-2xx responses
after the try block.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8ccf51f2-2985-4e06-a576-7683b1cbd30e
📒 Files selected for processing (7)
.github/workflows/notify-new-post.ymlscripts/notify-subscribers.tssrc/components/subscribe.astrosrc/components/subscribe.tsxsrc/layouts/global.astroworker/index.tswrangler.jsonc
| - uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 2 | ||
|
|
||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: 1.3.4 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE=".github/workflows/notify-new-post.yml"
echo "== $FILE =="
if [ -f "$FILE" ]; then
nl -ba "$FILE" | sed -n '1,120p'
else
echo "Missing: $FILE"
exit 1
fi
echo "== Extract uses lines =="
rg -n --hidden --no-ignore -S "uses:\s+([^@]+)@(.+)" .github/workflows/notify-new-post.yml || trueRepository: moq-dev/moq.dev
Length of output: 164
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE=".github/workflows/notify-new-post.yml"
echo "== $FILE =="
if [ -f "$FILE" ]; then
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE" | sed -n '1,160p'
else
echo "Missing: $FILE"
exit 1
fi
echo "== Extract uses lines (uses: owner/repo@ref) =="
rg -n --hidden --no-ignore -S --no-mmap "uses:\s*[^#\n]+" "$FILE" || true
echo "== Extract checkout persist-credentials config if present =="
rg -n --hidden --no-ignore -S "persist-credentials" "$FILE" || trueRepository: moq-dev/moq.dev
Length of output: 1623
🌐 Web query:
actions/checkout v4 tag commit sha
💡 Result:
In GitHub Actions, the actions/checkout@v4 tag is a mutable reference that points to the latest commit within the v4 release branch of the actions/checkout repository [1][2]. Because tags and branches in GitHub repositories are mutable, they can be updated by repository maintainers to point to different commit SHAs [2][3]. For security-critical workflows, it is a recommended best practice to pin actions to a specific, immutable full commit SHA rather than a tag [2][3]. This prevents supply chain attacks where an attacker might compromise a repository and repoint a tag to a malicious commit [2][3][4]. To identify the specific commit SHA for a version: 1. Visit the official actions/checkout repository on GitHub [1]. 2. Navigate to the "Tags" or "Releases" section [1]. 3. Select the desired version (e.g., v4.0.0) to view the specific commit SHA it points to [1][5]. 4. Alternatively, you can check the build logs of a workflow that uses the tag; the "Setup Job" section will display the exact SHA of the action that was downloaded [1]. For example, the initial release of v4.0.0 corresponds to commit SHA 3df4ab1 [6]. Using a full SHA in your workflow configuration looks like this: - uses: actions/checkout@3df4ab1 Note that while tags like v4 are convenient, they do not provide the same security guarantees as pinning to a full commit SHA [2][3].
Citations:
- 1: https://stackoverflow.com/questions/78903499/how-do-i-pin-an-action-to-a-specific-sha
- 2: https://www.aikido.dev/blog/checklist-github-actions
- 3: https://gbhackers.com/compromised-github-action/
- 4: https://undercodenews.com/github-actions-under-siege-imposter-commits-poisoned-tags-and-a-silent-supply-chain-nightmare-unfolding-in-real-time/
- 5: https://github.com/actions/checkout/releases/tag/v4.0.0
- 6: Release 4.0.0 actions/checkout#1447
🌐 Web query:
oven-sh/setup-bun v2 tag commit sha
💡 Result:
The oven-sh/setup-bun GitHub Action uses mutable Git tags (such as v2) to point to the latest release within that major version series [1][2]. Because these tags are updated by the repository maintainers to point to new commits as releases are published, they do not correspond to a single, permanent commit SHA [3][4][5]. For example, the v2 tag has been updated multiple times since its introduction, moving from the initial v2.0.0 release to subsequent minor and patch releases like v2.0.2, v2.1.0, and v2.2.0 [3][4][5]. To ensure security and reproducibility in your GitHub Actions workflows, it is strongly recommended to pin the action to a specific, immutable commit SHA rather than a mutable tag [6][7]. You can find the specific commit SHA for any release by viewing the release page on GitHub [3][4][5] or by inspecting the repository's commit history [8]. Example of pinning to a commit SHA: - uses: oven-sh/setup-bun@0c5077e
Citations:
- 1: https://github.com/oven-sh/setup-bun
- 2: https://github.com/marketplace/actions/setup-bun
- 3: https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
- 4: https://github.com/oven-sh/setup-bun/releases/tag/v2.1.0
- 5: https://github.com/oven-sh/setup-bun/releases/tag/v2.2.0
- 6: https://undercodenews.com/github-actions-nightmare-malicious-tag-hijack-sparks-massive-supply-chain-breach-shock/
- 7: https://gbhackers.com/compromised-github-action/
- 8: oven-sh/setup-bun@0c5077e
🌐 Web query:
https://github.com/actions/checkout/releases/tag/v4 commit SHA
💡 Result:
For https://github.com/actions/checkout/releases/tag/v4, the tag v4 points to commit SHA 2d7d9f7ff5b310f983d059b68785b3c74d8b8edd.[1][2] In GitHub’s repo UI, navigating to the release tag “v4” corresponds to the “tree/v4” view (tag) and the underlying commit shown there is 2d7d9f7ff5b310f983d059b68785b3c74d8b8edd.[2][3]
Citations:
- 1: https://github.com/actions/checkout/tree/releases/v4.0.0
- 2: https://github.com/actions/checkout/tree/v4
- 3: https://github.com/actions/checkout/tree/2d7d9f7ff5b310f983d059b68785b3c74d8b8edd
🌐 Web query:
https://github.com/oven-sh/setup-bun/releases/tag/v2 commit SHA
💡 Result:
The GitHub release URL you provided points to tag v2 (major tag), not a specific release version. For the major tag v2, GitHub does not expose a single immutable “commit SHA” from the release page alone. What we can confirm from the repo: - The release PR for v2.0 is #80 (“release: v2.0:tada:”) [1], and the compare page shows the v2 changes include commit ef00e4a: “release: v2.0:tada: (#80)” [2]. If you meant a specific minor/patch release under v2, tell me which tag (e.g., v2.0.2, v2.1.0, v2.2.0). Those pages are specific releases and will correspond to a particular commit SHA.
Citations:
🌐 Web query:
oven-sh/setup-bun tree/v2 commit SHA
💡 Result:
The GitHub Actions action version “oven-sh/setup-bun@v2” is a moving target (a tag), so the commit SHA depends on which exact tag commit the tag pointed to when you resolved it. One concrete, current example: the release tag “v2.2.0” corresponds to commit SHA 0c5077e51419868618aeaa5fe8019c62421857d6 in the oven-sh/setup-bun repository [1]. If you specifically meant “tree/v2” (a branch/path view) rather than the “@v2” action tag, that’s not directly a SHA; you’d need the exact underlying ref resolution from GitHub (branch/tag).
Citations:
🌐 Web query:
oven-sh/setup-bun releases tag v2 commit sha
💡 Result:
The request “oven-sh/setup-bun releases tag v2 commit sha” can be answered as follows: the oven-sh/setup-bun tag “v2” corresponds to the git commit SHA used for the v2.0.0 release. I was able to find concrete release entries for v2.0.2 and v2.1.0 (and the general releases list), but the search results returned do not include the v2 (i.e., v2.0.0) release’s commit SHA itself. For example, release tags shown include v2.0.2 and v2.1.0, but not the commit SHA mapping for “v2”. [1][2] If you want, tell me whether you mean: 1) tag name “v2” as used by GitHub Releases (often pointing to v2.0.0), or 2) “all v2.x release tags”, and I can re-search specifically for the commit SHA(s) behind those tag(s).
Citations:
- 1: https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
- 2: https://github.com/oven-sh/setup-bun/releases/tag/v2.1.0
Harden workflow: pin actions and disable checkout credential persistence
.github/workflows/notify-new-post.ymlusesactions/checkout@v4; pin it toactions/checkout@2d7d9f7ff5b310f983d059b68785b3c74d8b8eddand addpersist-credentials: falseunderwith(currently missing)..github/workflows/notify-new-post.ymlusesoven-sh/setup-bun@v2; pin this to an immutable commit SHA instead of the movingv2tag.
🧰 Tools
🪛 zizmor (1.25.2)
[warning] 17-19: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[error] 17-17: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
[error] 21-21: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/notify-new-post.yml around lines 17 - 23, Update the
workflow to pin external actions to immutable SHAs and disable checkout
credential persistence: replace the floating actions/checkout@v4 reference with
actions/checkout@2d7d9f7ff5b310f983d059b68785b3c74d8b8edd and add
persist-credentials: false under its with block, and replace
oven-sh/setup-bun@v2 with the specific commit SHA for that action (pin the
oven-sh/setup-bun action to an immutable commit) while keeping the existing
bun-version setting unchanged.
| const handleSubmit = async (e: SubmitEvent) => { | ||
| e.preventDefault(); | ||
| setState("submitting"); | ||
| setError(""); | ||
|
|
||
| try { | ||
| const res = await fetch("/api/subscribe", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ email: email() }), | ||
| }); | ||
|
|
||
| if (res.ok) { | ||
| setState("success"); | ||
| } else { | ||
| const body = (await res.json().catch(() => ({}))) as { error?: string }; | ||
| setError(body.error ?? "Something went wrong. Try again?"); | ||
| setState("error"); | ||
| } | ||
| } catch { | ||
| setError("Couldn't reach the server. Try again?"); | ||
| setState("error"); | ||
| } | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Use the component error-display pattern from src/components/fail.tsx.
This component handles errors inline, but this path requires the shared component-level error handling pattern for consistency in interactive UI components.
As per coding guidelines, "Use component-level error handling with the src/components/fail.tsx pattern for error display".
Also applies to: 59-59
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/subscribe.tsx` around lines 10 - 33, Replace the inline
error-display in handleSubmit with the shared component-level error handling
pattern used in src/components/fail.tsx: import and render the Fail component
(or use the same error state shape/API it expects) instead of calling setError
directly for fetch errors and catch blocks, and update handleSubmit to set only
the error value/state the Fail component consumes while keeping
setState("error")/("success") logic intact; specifically update the handleSubmit
function, its usage of setError and error state, and add the Fail component
import so the UI uses the centralized fail component for all error rendering.
- Workflow: recursive `**/*.mdx` glob in both the `paths` filter and the `git diff` pathspec, so future nested posts still trigger. - Worker: wrap the Resend fetch in try/catch and return 502 on network failures; redact the error log to status + x-request-id only (response body could echo the email). - notify-subscribers: 15s `AbortSignal.timeout` on both broadcast fetches so CI fails fast instead of hanging the full job timeout; encode the slug and HTML-escape the URL before injecting into the broadcast body. Skipped two suggestions: SHA-pinning external actions (deploy.yml and pr.yml don't pin either; consistency over isolated change) and routing form errors through fail.tsx (which is for Error-object crash banners, not inline form validation feedback). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
frontmatter.date, so it only shows on posts — not/,/demo, or/blog).worker/index.tsexposesPOST /api/subscribe, which adds the email to a Resend Segment. Static asset requests fall through toenv.ASSETS.fetchso the rest of the site is unchanged.mainwhen an MDX file is added undersrc/pages/blog/, then sends a Resend broadcast to the same Segment via scripts/notify-subscribers.ts.new.moq.devtomoq.wtfand adopts the Workers-with-Static-Assets pattern (main+assets.binding) so a single Worker serves both static pages and the/api/*endpoint.Why
The site has had no email signup, only RSS. Resend is the lightest-weight email service that keeps us off a marketing platform (no creator-platform overhead, free tier covers indie scale). The Worker is needed because the Resend API key can't be exposed in the browser; it's a thin proxy that injects the bearer token server-side. The broadcast-on-new-post job means publishing a blog post is still just "write the MDX and merge."
Reviewer notes
/api/subscribe; everything else delegates to the assets binding.moq-devandmoq-dev-stagingWorkers (RESEND_API_KEY,RESEND_SEGMENT_ID) and as repo Actions secrets. No additional Cloudflare config needed at merge time.moq.devsending domain still needs verification in Resend (DKIM/SPF/MX records) before the broadcast workflow will actually deliver mail. Merging is safe — the workflow only fires when a new MDX file is added, and any failure surfaces in CI.RESEND_API_KEYwas shared in chat and needs rotation post-merge (re-runwrangler secret putandgh secret setwith the new value).segments: [id]array inPOST /contacts.Test plan
bun run check(biome + tsc) passesbun astro build --mode stagingbuilds successfully (30 pages)/,/demo,/blog,/publish(verified via grep on built HTML)moq.wtf; verifiedPOST /api/subscribewith bad email →400,GET→405, blog HTML →200 text/html🤖 Generated with Claude Code