Skip to content

Add email subscribe form to blog posts via Resend#98

Merged
kixelated merged 2 commits into
mainfrom
claude/sharp-chaum-8fc790
May 23, 2026
Merged

Add email subscribe form to blog posts via Resend#98
kixelated merged 2 commits into
mainfrom
claude/sharp-chaum-8fc790

Conversation

@kixelated
Copy link
Copy Markdown
Collaborator

Summary

  • Adds a subscribe form at the bottom of every blog post (gated on frontmatter.date, so it only shows on posts — not /, /demo, or /blog).
  • New Worker entry at worker/index.ts exposes POST /api/subscribe, which adds the email to a Resend Segment. Static asset requests fall through to env.ASSETS.fetch so the rest of the site is unchanged.
  • New GitHub Action notify-new-post.yml triggers on push to main when an MDX file is added under src/pages/blog/, then sends a Resend broadcast to the same Segment via scripts/notify-subscribers.ts.
  • Switches the staging route from new.moq.dev to moq.wtf and 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

  • First dynamic surface on the site. Until now it was a 100% static Astro deploy. The Worker is ~50 lines and handles only /api/subscribe; everything else delegates to the assets binding.
  • Secrets already configured on both moq-dev and moq-dev-staging Workers (RESEND_API_KEY, RESEND_SEGMENT_ID) and as repo Actions secrets. No additional Cloudflare config needed at merge time.
  • moq.dev sending 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.
  • The deployed RESEND_API_KEY was shared in chat and needs rotation post-merge (re-run wrangler secret put and gh secret set with the new value).
  • Segment is non-rule-based (Resend's "manual" type), which is why the Worker can assign contacts directly via the segments: [id] array in POST /contacts.

Test plan

  • bun run check (biome + tsc) passes
  • bun astro build --mode staging builds successfully (30 pages)
  • Subscribe block renders on blog posts, not on /, /demo, /blog, /publish (verified via grep on built HTML)
  • Deployed to moq.wtf; verified POST /api/subscribe with bad email → 400, GET405, blog HTML → 200 text/html
  • End-to-end signup → contact lands in Resend Segment (manual test by reviewer with a real email)
  • Add a throwaway blog post on a test branch to confirm the broadcast workflow fires (defer until sending domain is verified)

🤖 Generated with Claude Code

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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

Review Change Stack

Warning

Review limit reached

@kixelated, we couldn't start this review because you've used your available PR reviews for now.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c40b3181-86a9-4a99-9d7c-9864e7bf0b14

📥 Commits

Reviewing files that changed from the base of the PR and between 2ae160a and 341786a.

📒 Files selected for processing (3)
  • .github/workflows/notify-new-post.yml
  • scripts/notify-subscribers.ts
  • worker/index.ts

Walkthrough

This pull request implements a complete email subscription system. It adds a Cloudflare Worker at POST /api/subscribe that validates emails and stores them in Resend's contacts API with a configurable segment. A new SolidJS component captures subscriber emails with form submission, error handling, and success states, integrated into the article layout via an Astro wrapper. Finally, a GitHub Actions workflow detects new blog posts pushed to main, triggers a Bun script that parses post frontmatter, and sends broadcast notifications to all subscribers via Resend's broadcast API.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding an email subscription feature to blog posts using Resend.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering the feature, implementation details, rationale, and test plan.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/sharp-chaum-8fc790
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/sharp-chaum-8fc790

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 84a338e and 2ae160a.

📒 Files selected for processing (7)
  • .github/workflows/notify-new-post.yml
  • scripts/notify-subscribers.ts
  • src/components/subscribe.astro
  • src/components/subscribe.tsx
  • src/layouts/global.astro
  • worker/index.ts
  • wrangler.jsonc

Comment thread .github/workflows/notify-new-post.yml Outdated
Comment on lines +17 to +23
- uses: actions/checkout@v4
with:
fetch-depth: 2

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 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 || true

Repository: 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" || true

Repository: 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:


🌐 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:


🌐 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:


🌐 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:


Harden workflow: pin actions and disable checkout credential persistence

  • .github/workflows/notify-new-post.yml uses actions/checkout@v4; pin it to actions/checkout@2d7d9f7ff5b310f983d059b68785b3c74d8b8edd and add persist-credentials: false under with (currently missing).
  • .github/workflows/notify-new-post.yml uses oven-sh/setup-bun@v2; pin this to an immutable commit SHA instead of the moving v2 tag.
🧰 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.

Comment thread scripts/notify-subscribers.ts Outdated
Comment thread scripts/notify-subscribers.ts
Comment on lines +10 to +33
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");
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment thread worker/index.ts Outdated
Comment thread worker/index.ts
- 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>
@kixelated kixelated merged commit 4ddabf8 into main May 23, 2026
1 check passed
@kixelated kixelated deleted the claude/sharp-chaum-8fc790 branch May 23, 2026 23:12
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.

1 participant