diff --git a/.github/workflows/newsletter-send.yml b/.github/workflows/newsletter-send.yml new file mode 100644 index 0000000..ef459a7 --- /dev/null +++ b/.github/workflows/newsletter-send.yml @@ -0,0 +1,36 @@ +name: "Newsletter: SEND to subscribers (pool apophenia)" + +on: + workflow_dispatch: + inputs: + issue: + description: "Issue dir under newsletter/ (e.g. issue-2026.10.07). Sends to ALL subscribers." + required: true + type: string + +permissions: + contents: read + +# Never run two real sends at once +concurrency: + group: newsletter-send + cancel-in-progress: false + +jobs: + send: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Send to pool "apophenia" + run: node scripts/send-newsletter.mjs + env: + ISSUE: ${{ inputs.issue }} + POOL: apophenia + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + NEWSLETTER_SEND_KEY: ${{ secrets.NEWSLETTER_SEND_KEY }} + NEWSLETTER_FROM: ${{ secrets.NEWSLETTER_FROM }} diff --git a/.github/workflows/newsletter-test.yml b/.github/workflows/newsletter-test.yml new file mode 100644 index 0000000..041bf21 --- /dev/null +++ b/.github/workflows/newsletter-test.yml @@ -0,0 +1,32 @@ +name: "Newsletter: test send (pool test)" + +on: + workflow_dispatch: + inputs: + issue: + description: "Issue dir under newsletter/ (e.g. issue-2026.10.07)" + required: true + type: string + +permissions: + contents: read + +jobs: + send: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Send to pool "test" + run: node scripts/send-newsletter.mjs + env: + ISSUE: ${{ inputs.issue }} + POOL: test + SUBJECT_PREFIX: "[TEST] " + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + NEWSLETTER_SEND_KEY: ${{ secrets.NEWSLETTER_SEND_KEY }} + NEWSLETTER_FROM: ${{ secrets.NEWSLETTER_FROM }} diff --git a/scripts/send-newsletter.mjs b/scripts/send-newsletter.mjs new file mode 100644 index 0000000..85ca8a9 --- /dev/null +++ b/scripts/send-newsletter.mjs @@ -0,0 +1,98 @@ +// Sends newsletter//index.html to every active subscriber of POOL via Resend batch API. +// Subject = the HTML . {{{EMAIL}}} in the HTML becomes the recipient's (URL-encoded) address. +// Logs never contain subscriber emails: this repo (and its Actions logs) is public. +import fs from "node:fs/promises"; +import path from "node:path"; + +const { ISSUE = "", POOL, RESEND_API_KEY, NEWSLETTER_SEND_KEY, NEWSLETTER_FROM, SUBJECT_PREFIX = "", DRY_RUN } = process.env; +const RUN_ID = process.env.GITHUB_RUN_ID || Date.now(); +const LIST_URL = "https://newsletter.planetrenox.com/api/list"; +const UNSUB_URL = "https://newsletter.planetrenox.com/api/unsub"; +const BATCH_SIZE = 100; // Resend max emails per batch request +const GAP_MS = 200; // 5 req/s, half of Resend's 10 req/s team limit (shared by all keys) +const MAX_TRIES = 5; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const scrub = (s) => String(s).replace(/[^\s@<>"']+@[^\s@<>"']+/g, "<email>"); +const die = (msg) => { + console.error(`✖ ${scrub(msg)}`); + process.exit(1); +}; + +if (!/^\w[\w.-]*$/.test(ISSUE)) die(`Invalid issue dir "${ISSUE}"`); +for (const [k, v] of Object.entries({ POOL, RESEND_API_KEY, NEWSLETTER_SEND_KEY, NEWSLETTER_FROM })) if (!v) die(`${k} is not set`); + +const file = path.join("newsletter", ISSUE, "index.html"); +const html = await fs.readFile(file, "utf8").catch(() => die(`${file} not found`)); +const title = html.match(/<title>([^<]+)<\/title>/i)?.[1].trim() || die(`${file} has no <title> (used as subject)`); +const subject = SUBJECT_PREFIX + title; +const from = `Apophenia News <${NEWSLETTER_FROM}>`; + +const res = await fetch(`${LIST_URL}?pool=${encodeURIComponent(POOL)}`, { headers: { "X-Send-Key": NEWSLETTER_SEND_KEY } }); +const { emails = [], error } = await res.json().catch(() => ({})); +if (!res.ok) die(`List fetch failed (${res.status}): ${error || "bad response"}`); +if (!emails.length) die(`Pool "${POOL}" has no active subscribers`); + +const toMail = (to) => { + const e = encodeURIComponent(to); + return { + from, + to, + subject, + html: html.replaceAll("{{{EMAIL}}}", e), + // One-click unsubscribe (RFC 8058): mail clients POST here, GET shows a confirmation page + headers: { + "List-Unsubscribe": `<${UNSUB_URL}?pool=${encodeURIComponent(POOL)}&email=${e}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" + } + }; +}; + +const batches = []; +for (let i = 0; i < emails.length; i += BATCH_SIZE) batches.push(emails.slice(i, i + BATCH_SIZE).map(toMail)); +console.log(`Issue ${ISSUE} | "${subject}" | from ${from} | pool "${POOL}": ${emails.length} recipients in ${batches.length} batch(es)`); +if (DRY_RUN) { + console.log("DRY_RUN set, nothing sent."); + process.exit(0); +} + +// Idempotency key is stable across "Re-run failed jobs" (same run id), so a re-run +// skips batches Resend already accepted (within 24h) and resumes where it failed. +const sendBatch = async (batch, i) => { + for (let attempt = 1; ; attempt++) { + const r = await fetch("https://api.resend.com/emails/batch", { + method: "POST", + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + "Content-Type": "application/json", + "Idempotency-Key": `newsletter/${ISSUE}/${POOL}/${RUN_ID}/${i}` + }, + body: JSON.stringify(batch) + }); + const body = await r.json().catch(() => ({})); + if (r.ok) { + // Out of requests in this window: wait for the reset before the next batch + if (r.headers.get("ratelimit-remaining") === "0") await sleep((+r.headers.get("ratelimit-reset") || 1) * 1000); + return body.data || []; + } + // Only per-second rate limits and server errors are worth retrying; quota 429s are not + const retryable = (r.status === 429 && body.name === "rate_limit_exceeded") || r.status >= 500 || body.name === "concurrent_idempotent_requests"; + if (!retryable || attempt >= MAX_TRIES) + die(`Batch ${i + 1}/${batches.length} failed (${r.status} ${body.name || ""}): ${body.message || "no message"}. ${i * BATCH_SIZE} of ${emails.length} sent before this; "Re-run failed jobs" resumes without duplicates.`); + const wait = (+r.headers.get("retry-after") || 2 ** attempt) * 1000; + console.warn(`… batch ${i + 1} got ${r.status} ${body.name || ""}, retrying in ${wait / 1000}s`); + await sleep(wait); + } +}; + +let sent = 0; +for (const [i, batch] of batches.entries()) { + if (i) await sleep(GAP_MS); + const ids = await sendBatch(batch, i); + sent += batch.length; + console.log(`✔ Batch ${i + 1}/${batches.length}: ${batch.length} emails accepted (${ids.length} ids)`); +} + +const summary = `Sent **${subject}** (\`${ISSUE}\`) to **${sent}** subscribers of pool \`${POOL}\` in ${batches.length} batch(es).`; +console.log(summary.replace(/[*`]/g, "")); +if (process.env.GITHUB_STEP_SUMMARY) await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);