-
Notifications
You must be signed in to change notification settings - Fork 6
Add email subscribe form to blog posts via Resend #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| name: notify-new-post | ||
|
|
||
| on: | ||
| push: | ||
| branches: ["main"] | ||
| paths: ["src/pages/blog/**/*.mdx"] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| notify: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 2 | ||
|
|
||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: 1.3.4 | ||
|
|
||
| - name: Find newly added blog posts | ||
| id: detect | ||
| env: | ||
| BEFORE_SHA: ${{ github.event.before }} | ||
| AFTER_SHA: ${{ github.sha }} | ||
| run: | | ||
| git diff --name-only --diff-filter=A "$BEFORE_SHA" "$AFTER_SHA" -- 'src/pages/blog/**/*.mdx' > new_posts.txt | ||
| if [ ! -s new_posts.txt ]; then | ||
| echo "skip=true" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| cat new_posts.txt | ||
|
|
||
| - name: Send broadcast(s) | ||
| if: steps.detect.outputs.skip != 'true' | ||
| env: | ||
| RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} | ||
| RESEND_SEGMENT_ID: ${{ secrets.RESEND_SEGMENT_ID }} | ||
| run: bun scripts/notify-subscribers.ts | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| #!/usr/bin/env bun | ||
| // Sends a Resend broadcast for each newly added blog post listed in new_posts.txt | ||
| // (paths relative to repo root, one per line). Invoked by the GitHub Action | ||
| // .github/workflows/notify-new-post.yml on push to main. | ||
|
|
||
| import { readFileSync } from "node:fs"; | ||
| import { basename } from "node:path"; | ||
|
|
||
| const SITE = "https://moq.dev"; | ||
| const FROM = "Media over QUIC <blog@moq.dev>"; | ||
|
|
||
| const apiKey = requireEnv("RESEND_API_KEY"); | ||
| const segmentId = requireEnv("RESEND_SEGMENT_ID"); | ||
|
|
||
| const newPostsList = readFileSync("new_posts.txt", "utf8").trim(); | ||
| if (!newPostsList) { | ||
| console.log("No new posts. Exiting."); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| const paths = newPostsList.split("\n").filter(Boolean); | ||
| console.log(`Found ${paths.length} new post(s): ${paths.join(", ")}`); | ||
|
|
||
| for (const path of paths) { | ||
| const rawSlug = basename(path, ".mdx"); | ||
| const slug = encodeURIComponent(rawSlug); | ||
| const fm = parseFrontmatter(readFileSync(path, "utf8")); | ||
| const title = fm.title ?? rawSlug; | ||
| const description = fm.description ?? ""; | ||
| const url = `${SITE}/blog/${slug}`; | ||
|
|
||
| console.log(`Creating broadcast for "${title}" → ${url}`); | ||
|
|
||
| const create = await fetch("https://api.resend.com/broadcasts", { | ||
| signal: AbortSignal.timeout(15000), | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${apiKey}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| segment_id: segmentId, | ||
| from: FROM, | ||
| subject: title, | ||
| html: renderHtml({ title, description, url }), | ||
| }), | ||
| }); | ||
|
|
||
| if (!create.ok) { | ||
| const err = await create.text(); | ||
| throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); | ||
| } | ||
|
|
||
| const { id } = (await create.json()) as { id: string }; | ||
|
|
||
| const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { | ||
| signal: AbortSignal.timeout(15000), | ||
| method: "POST", | ||
| headers: { Authorization: `Bearer ${apiKey}` }, | ||
| }); | ||
|
kixelated marked this conversation as resolved.
|
||
|
|
||
| if (!send.ok) { | ||
| const err = await send.text(); | ||
| throw new Error(`Resend broadcast send failed (${send.status}): ${err}`); | ||
| } | ||
|
|
||
| console.log(`✓ Sent broadcast ${id} for "${title}"`); | ||
| } | ||
|
|
||
| function requireEnv(name: string): string { | ||
| const v = process.env[name]; | ||
| if (!v) throw new Error(`Missing env var: ${name}`); | ||
| return v; | ||
| } | ||
|
|
||
| function parseFrontmatter(source: string): Record<string, string> { | ||
| const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/); | ||
| if (!match) return {}; | ||
| const out: Record<string, string> = {}; | ||
| for (const line of match[1].split(/\r?\n/)) { | ||
| const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); | ||
| if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| function renderHtml({ title, description, url }: { title: string; description: string; url: string }): string { | ||
| const safeTitle = escapeHtml(title); | ||
| const safeDescription = escapeHtml(description); | ||
| const safeUrl = escapeHtml(url); | ||
| return `<!doctype html> | ||
| <html><body style="font-family: -apple-system, system-ui, sans-serif; line-height: 1.5; color: #1f2937;"> | ||
| <h1 style="margin: 0 0 16px;">${safeTitle}</h1> | ||
| ${safeDescription ? `<p style="font-size: 16px; color: #4b5563;">${safeDescription}</p>` : ""} | ||
| <p style="margin: 24px 0;"> | ||
| <a href="${safeUrl}" style="display: inline-block; background: #2563eb; color: #fff; text-decoration: none; padding: 10px 20px; border-radius: 6px;">Read it on moq.dev →</a> | ||
| </p> | ||
| <p style="font-size: 13px; color: #6b7280;">Or open it directly: <a href="${safeUrl}">${safeUrl}</a></p> | ||
| </body></html>`; | ||
| } | ||
|
|
||
| function escapeHtml(s: string): string { | ||
| return s.replace(/[&<>"']/g, (c) => { | ||
| switch (c) { | ||
| case "&": | ||
| return "&"; | ||
| case "<": | ||
| return "<"; | ||
| case ">": | ||
| return ">"; | ||
| case '"': | ||
| return """; | ||
| default: | ||
| return "'"; | ||
| } | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| --- | ||
| import Subscribe from "@/components/subscribe.tsx"; | ||
| --- | ||
|
|
||
| <aside class="not-prose mt-12 border-t border-slate-700 pt-8"> | ||
| <h3 class="mb-4 text-xl font-semibold text-slate-100">Subscribe for new stuff</h3> | ||
| <Subscribe client:load /> | ||
| <div class="mt-4 flex items-center gap-2 text-sm text-slate-400"> | ||
| <span>or</span> | ||
| <a | ||
| href="/rss.xml" | ||
| class="inline-flex items-center gap-1.5 text-slate-300 hover:text-white" | ||
| title="RSS Feed" | ||
| > | ||
| <svg width="18" height="18" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RSS Feed"> | ||
| <circle cx="6" cy="26" r="2" fill="currentColor" /> | ||
| <path d="M6 16 A10 10 0 0 1 16 26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> | ||
| <path d="M6 8 A18 18 0 0 1 24 26" fill="none" stroke="#00C02D" stroke-width="2" stroke-linecap="round" /> | ||
| </svg> | ||
| <span class="font-medium">RSS Feed</span> | ||
| </a> | ||
| </div> | ||
| </aside> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { createSignal } from "solid-js"; | ||
|
|
||
| type State = "idle" | "submitting" | "success" | "error"; | ||
|
|
||
| export default function Subscribe() { | ||
| const [email, setEmail] = createSignal(""); | ||
| const [state, setState] = createSignal<State>("idle"); | ||
| const [error, setError] = createSignal(""); | ||
|
|
||
| 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"); | ||
| } | ||
| }; | ||
|
Comment on lines
+10
to
+33
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Use the component error-display pattern from 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 Also applies to: 59-59 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <> | ||
| {state() === "success" ? ( | ||
| <p class="text-sm text-green-400">Thanks! You'll get an email when a new post goes up.</p> | ||
| ) : ( | ||
| <form onSubmit={handleSubmit} class="flex flex-col gap-2 sm:flex-row"> | ||
| <input | ||
| type="email" | ||
| required | ||
| placeholder="you@example.com" | ||
| value={email()} | ||
| onInput={(e) => setEmail(e.currentTarget.value)} | ||
| disabled={state() === "submitting"} | ||
| class="flex-1 rounded border border-slate-700 bg-slate-800 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-blue-500 focus:outline-none" | ||
| /> | ||
| <button | ||
| type="submit" | ||
| disabled={state() === "submitting"} | ||
| class="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-500 disabled:bg-slate-700" | ||
| > | ||
| {state() === "submitting" ? "Subscribing…" : "Subscribe"} | ||
| </button> | ||
| </form> | ||
| )} | ||
| {state() === "error" && <p class="mt-2 text-sm text-red-400">{error()}</p>} | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| // Cloudflare Worker entry. Static asset requests fall through to the ASSETS | ||
| // binding (Workers-with-Static-Assets). Only /api/* is handled here. | ||
|
|
||
| interface Env { | ||
| ASSETS: { fetch: (request: Request) => Promise<Response> }; | ||
| RESEND_API_KEY: string; | ||
| RESEND_SEGMENT_ID: string; | ||
| } | ||
|
|
||
| const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; | ||
|
|
||
| export default { | ||
| async fetch(request: Request, env: Env): Promise<Response> { | ||
| const url = new URL(request.url); | ||
|
|
||
| if (url.pathname === "/api/subscribe") { | ||
| if (request.method !== "POST") { | ||
| return new Response("Method Not Allowed", { status: 405 }); | ||
| } | ||
| return handleSubscribe(request, env); | ||
| } | ||
|
|
||
| return env.ASSETS.fetch(request); | ||
| }, | ||
| }; | ||
|
|
||
| async function handleSubscribe(request: Request, env: Env): Promise<Response> { | ||
| let email: unknown; | ||
| try { | ||
| const body = (await request.json()) as { email?: unknown }; | ||
| email = body.email; | ||
| } catch { | ||
| return json({ error: "invalid body" }, 400); | ||
| } | ||
|
|
||
| if (typeof email !== "string" || !EMAIL_RE.test(email)) { | ||
| return json({ error: "invalid email" }, 400); | ||
| } | ||
|
|
||
| let res: Response; | ||
| try { | ||
| res = await fetch("https://api.resend.com/contacts", { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${env.RESEND_API_KEY}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| email, | ||
| unsubscribed: false, | ||
| segments: [env.RESEND_SEGMENT_ID], | ||
| }), | ||
| }); | ||
| } catch (err) { | ||
| console.error(`Resend POST /contacts → fetch threw: ${err}`); | ||
| return json({ error: "subscribe failed" }, 502); | ||
| } | ||
|
|
||
| // Treat any non-5xx as success so we don't leak whether an address is | ||
| // already on the list (Resend returns 4xx for duplicates). Log 4xx for | ||
| // debugging since a misconfigured segment ID would silently break sends. | ||
| // Only log status + request id, not the body (which may contain the email). | ||
| if (!res.ok) { | ||
| console.error(`Resend POST /contacts → ${res.status} (request-id: ${res.headers.get("x-request-id") ?? "n/a"})`); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if (res.status >= 500) { | ||
| return json({ error: "subscribe failed" }, 502); | ||
| } | ||
|
|
||
| return json({ ok: true }, 200); | ||
| } | ||
|
|
||
| function json(body: unknown, status: number): Response { | ||
| return new Response(JSON.stringify(body), { | ||
| status, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: moq-dev/moq.dev
Length of output: 164
🏁 Script executed:
Repository: moq-dev/moq.dev
Length of output: 1623
🌐 Web query:
actions/checkout v4 tag commit sha💡 Result:
In GitHub Actions, the
actions/checkout@v4tag is a mutable reference that points to the latest commit within thev4release branch of theactions/checkoutrepository [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 officialactions/checkoutrepository 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 ofv4.0.0corresponds to commit SHA3df4ab1[6]. Using a full SHA in your workflow configuration looks like this: - uses: actions/checkout@3df4ab1 Note that while tags likev4are 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.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