From 3ed1276cc2a96bb8fc7c558c65927af6e1bfa0d6 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Sat, 24 Jan 2026 21:55:19 -0500 Subject: [PATCH 1/8] feat: switch newsletter from Loops to Brevo --- api/newsletter.ts | 61 +++++++++++++++++++++++++++++++++ cspell.json | 1 + package.json | 3 +- pnpm-lock.yaml | 3 ++ src/components/Newsletter.astro | 6 +--- 5 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 api/newsletter.ts diff --git a/api/newsletter.ts b/api/newsletter.ts new file mode 100644 index 0000000..6068b8a --- /dev/null +++ b/api/newsletter.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +const envSchema = z + .object({ + BREVO_API_KEY: z.string().min(1), + BREVO_LIST_ID: z.coerce.number().int().positive().optional(), + }) + .transform((data) => ({ + brevoApiKey: data.BREVO_API_KEY, + brevoListId: data.BREVO_LIST_ID, + })); + +const env = envSchema.parse(process.env); + +export default async function handler(req: any, res: any) { + if (req.method !== "POST") { + return res.status(405).end(); + } + + const raw: string = await new Promise((resolve) => { + let data = ""; + req.on("data", (chunk: any) => (data += chunk)); + req.on("end", () => resolve(data)); + }); + const params = new URLSearchParams(raw); + const email = params.get("email")?.trim() || ""; + + if (!email) { + return res.status(400).json({ error: "Missing email" }); + } + + const payload: Record = { email }; + if (env.brevoListId) { + payload.listIds = [env.brevoListId]; + } + + try { + const response = await fetch( + "https://api.brevo.com/v3/contacts?updateEnabled=true", + { + body: JSON.stringify(payload), + headers: { + "api-key": env.brevoApiKey, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + return res.status(response.status).json({ error: response.statusText }); + } + + const data = await response.json(); + return res.status(201).json(data || { message: "created" }); + } catch (error: unknown) { + return res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/cspell.json b/cspell.json index fcc1c85..a05e510 100644 --- a/cspell.json +++ b/cspell.json @@ -16,6 +16,7 @@ "words": [ "astropub", "bootcamps", + "brevo", "d'œuvres", "devries", "dimitri", diff --git a/package.json b/package.json index f279ffc..323549f 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "sharp": "^0.34.5", "temporal-polyfill": "^0.3.0", "uqr": "^0.1.2", - "wifi-share-link": "^0.1.2" + "wifi-share-link": "^0.1.2", + "zod": "^4.3.6" }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97c2cc..ec60e1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: wifi-share-link: specifier: ^0.1.2 version: 0.1.2 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@eslint-community/eslint-plugin-eslint-comments': specifier: ^4.6.0 diff --git a/src/components/Newsletter.astro b/src/components/Newsletter.astro index 85da574..627fd89 100644 --- a/src/components/Newsletter.astro +++ b/src/components/Newsletter.astro @@ -32,11 +32,7 @@ import Heading from "./Heading.astro"; Sign up to receive announcements and important SquiggleConf info. -
+ Date: Tue, 27 Jan 2026 11:10:21 -0500 Subject: [PATCH 2/8] Switch to actual Astro API --- .astro/types.d.ts | 3 +- .env.template | 2 ++ .gitignore | 1 + README.md | 5 +++ api/newsletter.ts | 61 ------------------------------------- astro.config.ts | 8 ++++- cspell.json | 1 + src/pages/api/newsletter.ts | 52 +++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 9 files changed, 71 insertions(+), 64 deletions(-) create mode 100644 .env.template delete mode 100644 api/newsletter.ts create mode 100644 src/pages/api/newsletter.ts diff --git a/.astro/types.d.ts b/.astro/types.d.ts index 03d7cc4..24a12e7 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1,2 +1,3 @@ /// -/// \ No newline at end of file +/// +/// \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..a60cee3 --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +BREVO_API_KEY= +BREVO_LIST_ID=3 diff --git a/.gitignore b/.gitignore index 8d175bb..a77ba83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .astro +.env .vercel /dist /node_modules \ No newline at end of file diff --git a/README.md b/README.md index 3e198ff..c9f587c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ pnpm i pnpm dev ``` +### Environment Variables + +To get the newsletter API running, copy `.env.template` to an `.env` file and fill in the Brevo API key value from our password manager. +This is not necessary unless you want to work on the newsletter API. + ## Contributors diff --git a/api/newsletter.ts b/api/newsletter.ts deleted file mode 100644 index 6068b8a..0000000 --- a/api/newsletter.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { z } from "zod"; - -const envSchema = z - .object({ - BREVO_API_KEY: z.string().min(1), - BREVO_LIST_ID: z.coerce.number().int().positive().optional(), - }) - .transform((data) => ({ - brevoApiKey: data.BREVO_API_KEY, - brevoListId: data.BREVO_LIST_ID, - })); - -const env = envSchema.parse(process.env); - -export default async function handler(req: any, res: any) { - if (req.method !== "POST") { - return res.status(405).end(); - } - - const raw: string = await new Promise((resolve) => { - let data = ""; - req.on("data", (chunk: any) => (data += chunk)); - req.on("end", () => resolve(data)); - }); - const params = new URLSearchParams(raw); - const email = params.get("email")?.trim() || ""; - - if (!email) { - return res.status(400).json({ error: "Missing email" }); - } - - const payload: Record = { email }; - if (env.brevoListId) { - payload.listIds = [env.brevoListId]; - } - - try { - const response = await fetch( - "https://api.brevo.com/v3/contacts?updateEnabled=true", - { - body: JSON.stringify(payload), - headers: { - "api-key": env.brevoApiKey, - "Content-Type": "application/json", - }, - method: "POST", - }, - ); - - if (!response.ok) { - return res.status(response.status).json({ error: response.statusText }); - } - - const data = await response.json(); - return res.status(201).json(data || { message: "created" }); - } catch (error: unknown) { - return res.status(500).json({ - error: error instanceof Error ? error.message : "Unknown error", - }); - } -} diff --git a/astro.config.ts b/astro.config.ts index 01d66d3..578680b 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -1,11 +1,17 @@ import vercel from "@astrojs/vercel"; import { konamiEmojiBlast } from "@konami-emoji-blast/astro"; -import { defineConfig } from "astro/config"; +import { defineConfig, envField } from "astro/config"; export default defineConfig({ adapter: vercel({ webAnalytics: { enabled: true }, }), + env: { + schema: { + BREVO_API_KEY: envField.string({ access: "secret", context: "server" }), + BREVO_LIST_ID: envField.number({ access: "public", context: "server" }), + }, + }, image: { layout: "constrained", responsiveStyles: true, diff --git a/cspell.json b/cspell.json index a05e510..60345ba 100644 --- a/cspell.json +++ b/cspell.json @@ -48,6 +48,7 @@ "Simons", "squiggleconf", "Tauri", + "treeify", "vitullo", "vohr", "Wasmer", diff --git a/src/pages/api/newsletter.ts b/src/pages/api/newsletter.ts new file mode 100644 index 0000000..56da774 --- /dev/null +++ b/src/pages/api/newsletter.ts @@ -0,0 +1,52 @@ +import { APIRoute } from "astro"; +import { BREVO_API_KEY, BREVO_LIST_ID } from "astro:env/server"; +import { z } from "zod"; + +const bodySchema = z.object({ + email: z.email(), +}); + +export const POST: APIRoute = async ({ request }) => { + const body = bodySchema.safeParse(await request.json()); + if (body.error) { + return new Response("Invalid body", { + status: 400, + statusText: body.error.message, + }); + } + + const { email } = body.data; + + try { + const response = await fetch("https://api.brevo.com/v3/contacts", { + body: JSON.stringify({ + email, + listIds: [BREVO_LIST_ID], + }), + headers: { + "api-key": BREVO_API_KEY, + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (response.ok || isBrevoDuplicateIdentifier(await response.json())) { + return new Response("Thanks for subscribing!", { status: 200 }); + } + + console.error(response); + return new Response("Error", { status: 400 }); + } catch (error) { + console.error(error); + return new Response("Error", { status: 400 }); + } +}; + +function isBrevoDuplicateIdentifier(json: unknown) { + return ( + typeof json === "object" && + !!json && + "code" in json && + json.code === "duplicate_parameter" + ); +} diff --git a/tsconfig.json b/tsconfig.json index b54dffa..d82be47 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "strict": true, "target": "ESNext" }, - "include": ["api", "src"] + "include": ["src"] } From 85b2ed72dde2fc145d4dc5e7e322cc563564194a Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 27 Jan 2026 11:13:24 -0500 Subject: [PATCH 3/8] rm treeify --- cspell.json | 1 - 1 file changed, 1 deletion(-) diff --git a/cspell.json b/cspell.json index 60345ba..a05e510 100644 --- a/cspell.json +++ b/cspell.json @@ -48,7 +48,6 @@ "Simons", "squiggleconf", "Tauri", - "treeify", "vitullo", "vohr", "Wasmer", From fb6e0dee31e1f0c1e1a3fea2926ddd80fec9929d Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 27 Jan 2026 11:17:02 -0500 Subject: [PATCH 4/8] sync before CI tasks --- .github/workflows/ci.yaml | 3 +++ astro.config.ts | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8f28c35..53c3295 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,6 +5,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/prepare + - run: cp .env.example .env - run: pnpm build lint: name: Lint @@ -12,6 +13,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/prepare + - run: pnpm astro sync - run: pnpm lint lint_knip: name: Lint Knip @@ -47,6 +49,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/prepare + - run: pnpm astro sync - run: pnpm tsc name: CI diff --git a/astro.config.ts b/astro.config.ts index 578680b..87d794c 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -8,8 +8,16 @@ export default defineConfig({ }), env: { schema: { - BREVO_API_KEY: envField.string({ access: "secret", context: "server" }), - BREVO_LIST_ID: envField.number({ access: "public", context: "server" }), + BREVO_API_KEY: envField.string({ + access: "secret", + context: "server", + optional: true, + }), + BREVO_LIST_ID: envField.number({ + access: "public", + context: "server", + optional: true, + }), }, }, image: { From 18cfb16e7130252498392dfc2833e1c98b2dfa3f Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 27 Jan 2026 11:22:09 -0500 Subject: [PATCH 5/8] remove optional --- astro.config.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/astro.config.ts b/astro.config.ts index 87d794c..578680b 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -8,16 +8,8 @@ export default defineConfig({ }), env: { schema: { - BREVO_API_KEY: envField.string({ - access: "secret", - context: "server", - optional: true, - }), - BREVO_LIST_ID: envField.number({ - access: "public", - context: "server", - optional: true, - }), + BREVO_API_KEY: envField.string({ access: "secret", context: "server" }), + BREVO_LIST_ID: envField.number({ access: "public", context: "server" }), }, }, image: { From a35064dcd29428b0b04a1be1844eca0b7130d5b6 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 27 Jan 2026 11:23:18 -0500 Subject: [PATCH 6/8] lint false report --- eslint.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.ts b/eslint.config.ts index 59a3de2..aa019bb 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -63,6 +63,7 @@ export default defineConfig( }, }, rules: { + "n/no-missing-import": "off", "n/no-unpublished-import": "off", // Stylistic concerns that don't interfere with Prettier From 2408c86d35dd5a71d9683695f5eafe03bf43a62c Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 27 Jan 2026 11:23:38 -0500 Subject: [PATCH 7/8] .template, not .example --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 53c3295..fc196bd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,7 +5,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/prepare - - run: cp .env.example .env + - run: cp .env.template .env - run: pnpm build lint: name: Lint From 0bd27a367519e5788455180e0fb272aaff58578e Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 27 Jan 2026 11:32:33 -0500 Subject: [PATCH 8/8] OK it seems to work now --- src/components/Newsletter.astro | 3 +-- src/pages/api/newsletter.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/Newsletter.astro b/src/components/Newsletter.astro index 627fd89..927bfed 100644 --- a/src/components/Newsletter.astro +++ b/src/components/Newsletter.astro @@ -99,10 +99,9 @@ import Heading from "./Heading.astro"; "Content-Type": "application/x-www-form-urlencoded", }, }) - .then((response) => [response.ok, response.json(), response] as const) + .then((response) => [response.ok, response.text(), response] as const) .then(([ok, dataPromise, response]) => { dataPromise.then((data) => { - console.log({ data }); if (ok) { setMessage("Thanks for signing up!", "happy"); form.reset(); diff --git a/src/pages/api/newsletter.ts b/src/pages/api/newsletter.ts index 56da774..539203a 100644 --- a/src/pages/api/newsletter.ts +++ b/src/pages/api/newsletter.ts @@ -7,7 +7,8 @@ const bodySchema = z.object({ }); export const POST: APIRoute = async ({ request }) => { - const body = bodySchema.safeParse(await request.json()); + const formData = Object.fromEntries(await request.formData()); + const body = bodySchema.safeParse(formData); if (body.error) { return new Response("Invalid body", { status: 400, @@ -30,12 +31,11 @@ export const POST: APIRoute = async ({ request }) => { method: "POST", }); - if (response.ok || isBrevoDuplicateIdentifier(await response.json())) { - return new Response("Thanks for subscribing!", { status: 200 }); - } + console.log(response); - console.error(response); - return new Response("Error", { status: 400 }); + return response.ok || isBrevoDuplicateIdentifier(await response.json()) + ? new Response("Thanks for signing up!", { status: 200 }) + : new Response("Error", { status: 400 }); } catch (error) { console.error(error); return new Response("Error", { status: 400 });