mirror of
https://github.com/apophenia-news/apophenia.news.git
synced 2026-10-08 21:35:23 +00:00
Add newsletter send workflows (test pool + real pool)
scripts/send-newsletter.mjs sends newsletter/<issue>/index.html via the Resend batch API (100/batch, paced under the 10 req/s team limit, retries rate-limit 429s/5xx, idempotency keys so re-runs resume without dupes) to the active subscribers of a pool fetched with the scoped send key. Adds one-click List-Unsubscribe headers. Never logs subscriber emails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Sends newsletter/<ISSUE>/index.html to every active subscriber of POOL via Resend batch API.
|
||||
// Subject = the HTML <title>. {{{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`);
|
||||
Reference in New Issue
Block a user