mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-22 07:46:07 -05:00
Clarify creator requirements and add announcement emails (#96)
* Initial draft * docs: finalize Terms rollout dates and preserve previous version * feat: add reusable creator announcements and Resend sending script
This commit is contained in:
225
scripts/send-announcement.mts
Normal file
225
scripts/send-announcement.mts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { Resend } from "resend";
|
||||
import { renderEmail } from "../src/emails/render.ts";
|
||||
import type { Database } from "../src/types/db.ts";
|
||||
|
||||
type Announcement = { id: string; title: string; message: string; ready: boolean };
|
||||
type Recipient = { email: string; userIds: string[]; hacks: string[] };
|
||||
type Message = { from: string; replyTo: string; subject: string; html: string; text: string };
|
||||
type Mailer = Pick<Resend["emails"], "send">;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isEmail(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(value);
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`Set ${name} before running this mode.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function hash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
async function readOptional(file: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
if (isRecord(error) && error.code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAnnouncement(file: string): Promise<Announcement> {
|
||||
const value: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
if (!isRecord(value) || typeof value.id !== "string" ||
|
||||
!/^[a-z0-9][a-z0-9-]{0,79}$/.test(value.id) ||
|
||||
typeof value.title !== "string" || !value.title.trim() || /[\r\n]/.test(value.title) ||
|
||||
typeof value.message !== "string" || !value.message.trim() || typeof value.ready !== "boolean") {
|
||||
throw new Error("Announcement needs an id in lowercase kebab-case, a title, a message, and a ready boolean.");
|
||||
}
|
||||
return { id: value.id, title: value.title, message: value.message, ready: value.ready };
|
||||
}
|
||||
|
||||
/** Read approved hacks or hacks with uploaded patches, including pending and unpublished entries. */
|
||||
export async function collectRecipients(supabase: SupabaseClient<Database>): Promise<Recipient[]> {
|
||||
const creators = new Map<string, string[]>();
|
||||
const pageSize = 1000;
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const { data, error } = await supabase.from("hacks")
|
||||
.select("slug,created_by,current_patch,patch_url,approved")
|
||||
.eq("is_archive", false).order("slug").range(offset, offset + pageSize - 1);
|
||||
if (error) throw new Error(`Cannot load hacks: ${error.message}`);
|
||||
if (!data) throw new Error("No response when loading hacks.");
|
||||
for (const hack of data) {
|
||||
if (!hack.current_patch && !hack.patch_url && !hack.approved) continue;
|
||||
const hacks = creators.get(hack.created_by) ?? [];
|
||||
hacks.push(hack.slug);
|
||||
creators.set(hack.created_by, hacks);
|
||||
}
|
||||
if (data.length < pageSize) break;
|
||||
}
|
||||
|
||||
const recipients = new Map<string, Recipient>();
|
||||
for (const [id, hacks] of creators) {
|
||||
const { data, error } = await supabase.auth.admin.getUserById(id);
|
||||
if (error) throw new Error(`Cannot load creator ${id}: ${error.message}`);
|
||||
const email = data.user?.email?.trim().toLowerCase();
|
||||
if (!isEmail(email)) throw new Error(`Creator ${id} has no valid account email. Resolve before preparing recipients.`);
|
||||
const recipient = recipients.get(email) ?? { email, userIds: [], hacks: [] };
|
||||
recipient.userIds.push(id);
|
||||
recipient.hacks.push(...hacks);
|
||||
recipients.set(email, recipient);
|
||||
}
|
||||
return [...recipients.values()].sort((a, b) => a.email.localeCompare(b.email));
|
||||
}
|
||||
|
||||
async function loadRecipients(file: string): Promise<Recipient[]> {
|
||||
const text = await readOptional(file);
|
||||
if (!text) throw new Error("No recipient snapshot. Run --prepare and review recipients.json first.");
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (!isRecord(value) || !Array.isArray(value.recipients)) throw new Error("Invalid recipient snapshot.");
|
||||
const recipients = value.recipients.map((row: unknown) => {
|
||||
if (!isRecord(row) || !isEmail(row.email) || !Array.isArray(row.userIds) || !row.userIds.length ||
|
||||
!row.userIds.every((id): id is string => typeof id === "string" && id.length > 0) ||
|
||||
!Array.isArray(row.hacks) || !row.hacks.every((slug): slug is string => typeof slug === "string")) {
|
||||
throw new Error("Invalid recipient in snapshot.");
|
||||
}
|
||||
return { email: row.email.toLowerCase(), userIds: row.userIds, hacks: row.hacks };
|
||||
});
|
||||
if (!recipients.length || new Set(recipients.map((row) => row.email)).size !== recipients.length) {
|
||||
throw new Error("Recipient snapshot is empty or has duplicate email addresses.");
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
/** Fail closed on uncertain delivery. Resolve a pending record in Resend before retrying. */
|
||||
export async function sendAnnouncement({ announcement, message, recipients, directory, mailer, delayMs = 1000 }: {
|
||||
announcement: Announcement;
|
||||
message: Message;
|
||||
recipients: Recipient[];
|
||||
directory: string;
|
||||
mailer: Mailer;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
if (!announcement.ready || /\{\{[^}]+\}\}/.test(announcement.title + announcement.message)) {
|
||||
throw new Error("Announcement is a draft. Finalize the dates and copy, then set ready to true.");
|
||||
}
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = path.join(directory, "send.lock");
|
||||
const lock = await open(lockPath, "wx", 0o600);
|
||||
try {
|
||||
// Freeze both message and audience on first send so retries cannot change either.
|
||||
const payloadPath = path.join(directory, "payload.json");
|
||||
const payload = JSON.stringify({ message, recipients });
|
||||
const previous = await readOptional(payloadPath);
|
||||
if (previous !== undefined && previous !== payload) {
|
||||
throw new Error("Message or recipients changed after sending began. Restore the original files before resuming.");
|
||||
}
|
||||
if (previous === undefined) await writeFile(payloadPath, payload, { flag: "wx", mode: 0o600 });
|
||||
|
||||
let accepted = 0;
|
||||
let skipped = 0;
|
||||
for (const recipient of recipients) {
|
||||
const recipientKey = hash(recipient.email);
|
||||
const sentPath = path.join(directory, `${recipientKey}.sent.json`);
|
||||
if (await readOptional(sentPath) !== undefined) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const pendingPath = path.join(directory, `${recipientKey}.pending.json`);
|
||||
if (await readOptional(pendingPath) !== undefined) {
|
||||
throw new Error(`Unresolved delivery for ${recipient.email}. Check its pending record and Resend before retrying.`);
|
||||
}
|
||||
const idempotencyKey = `announcement/${announcement.id}/${recipientKey}`;
|
||||
const record = { email: recipient.email, userIds: recipient.userIds, idempotencyKey, attemptedAt: new Date().toISOString() };
|
||||
await writeFile(pendingPath, JSON.stringify(record, null, 2), { flag: "wx", mode: 0o600 });
|
||||
await setTimeout(delayMs);
|
||||
const { data, error } = await mailer.send({ ...message, to: recipient.email }, { idempotencyKey });
|
||||
if (error || !data) throw new Error(`Resend did not confirm ${recipient.email}: ${error?.message ?? "missing email ID"}. Check the pending record before retrying.`);
|
||||
await writeFile(pendingPath, JSON.stringify({ ...record, emailId: data.id, acceptedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
||||
await rename(pendingPath, sentPath);
|
||||
accepted++;
|
||||
console.log(`Accepted ${accepted}: ${recipient.email} (${data.id})`);
|
||||
}
|
||||
return { accepted, skipped };
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(args = process.argv.slice(2)) {
|
||||
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
||||
prepare: { type: "boolean" }, test: { type: "string" }, send: { type: "boolean" }, help: { type: "boolean" },
|
||||
} });
|
||||
if (values.help) {
|
||||
console.log("Usage: npm run email:announcement -- <announcement.json> [--prepare | --test you@example.com | --send]\nDefault: render an offline preview. --prepare saves creator recipients. --test sends only to the specified address. --send uses the reviewed snapshot.");
|
||||
return;
|
||||
}
|
||||
if (positionals.length !== 1 || [values.prepare, values.test !== undefined, values.send].filter(Boolean).length > 1) {
|
||||
throw new Error("Provide one announcement JSON file and at most one mode. See --help.");
|
||||
}
|
||||
const announcement = await loadAnnouncement(positionals[0]);
|
||||
const directory = path.resolve(".local/announcements", announcement.id);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const html = await renderEmail("announcement", announcement);
|
||||
const text = `${announcement.title}\n\n${announcement.message}\n\nYou can reply to this email with questions.\n\nThe Hackdex Team`;
|
||||
await writeFile(path.join(directory, "preview.html"), html, { mode: 0o600 });
|
||||
await writeFile(path.join(directory, "preview.txt"), text, { mode: 0o600 });
|
||||
console.log(`Preview: ${path.join(directory, "preview.html")}${announcement.ready ? "" : " (DRAFT)"}`);
|
||||
const recipientPath = path.join(directory, "recipients.json");
|
||||
|
||||
if (values.prepare) {
|
||||
const source = requiredEnv("NEXT_PUBLIC_SUPABASE_URL");
|
||||
const supabase = createClient<Database>(source, requiredEnv("SUPABASE_SECRET_KEY"), {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
const recipients = await collectRecipients(supabase);
|
||||
if (!recipients.length) throw new Error("No eligible creators found. No snapshot written.");
|
||||
await writeFile(recipientPath, JSON.stringify({ source, preparedAt: new Date().toISOString(), recipients }, null, 2), { flag: "wx", mode: 0o600 });
|
||||
console.log(`Prepared ${recipients.length} recipients from ${source}. Review ${recipientPath} before sending.`);
|
||||
return;
|
||||
}
|
||||
if (!values.send && values.test === undefined) {
|
||||
const snapshot = await readOptional(recipientPath);
|
||||
console.log(snapshot ? `${(await loadRecipients(recipientPath)).length} recipients prepared. No email sent.` : "No recipients prepared. Use --prepare to query Supabase. No email sent.");
|
||||
return;
|
||||
}
|
||||
const from = requiredEnv("RESEND_FROM");
|
||||
const replyTo = requiredEnv("RESEND_REPLY_TO");
|
||||
if (!isEmail(replyTo)) throw new Error("RESEND_REPLY_TO must be one monitored email address.");
|
||||
const message = { from, replyTo, subject: announcement.title, html, text };
|
||||
const resend = new Resend(requiredEnv("RESEND_API_KEY"));
|
||||
if (values.test !== undefined) {
|
||||
if (!isEmail(values.test)) throw new Error("--test needs one email address.");
|
||||
const { data, error } = await resend.emails.send({ ...message, subject: `[TEST] ${message.subject}`, to: values.test }, {
|
||||
idempotencyKey: `announcement-test/${randomUUID()}`,
|
||||
});
|
||||
if (error || !data) throw new Error(`Test send failed: ${error?.message ?? "missing email ID"}`);
|
||||
console.log(`Test accepted for ${values.test}: ${data.id}. Creator recipients were not used.`);
|
||||
return;
|
||||
}
|
||||
const recipients = await loadRecipients(recipientPath);
|
||||
console.log(`Sending to ${recipients.length} recipients in the saved snapshot.`);
|
||||
const result = await sendAnnouncement({ announcement, message, recipients, directory, mailer: resend.emails });
|
||||
console.log(`Finished: ${result.accepted} accepted by Resend, ${result.skipped} previously accepted. Check delivery and bounces in Resend.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
207
scripts/send-announcement.test.mts
Normal file
207
scripts/send-announcement.test.mts
Normal file
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { Resend } from "resend";
|
||||
import { renderEmail } from "../src/emails/render.ts";
|
||||
import type { Database } from "../src/types/db.ts";
|
||||
import { collectRecipients, loadAnnouncement, main, sendAnnouncement } from "./send-announcement.mts";
|
||||
|
||||
const announcement = { id: "test-notice", title: "Creator notice", message: "Please review your credits.", ready: true };
|
||||
const message = { from: "Hackdex <notice@example.com>", replyTo: "test@example.com", subject: announcement.title, html: "<p>Please review your credits.</p>", text: announcement.message };
|
||||
const recipients = [{ email: "creator@example.com", userIds: ["00000000-0000-4000-8000-000000000001"], hacks: ["hack-one"] }];
|
||||
|
||||
async function temporaryDirectory(t: TestContext) {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "hackdex-announcement-"));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
test("announcement escapes content, preserves paragraphs, and links URLs", async () => {
|
||||
const html = await renderEmail("announcement", {
|
||||
title: '<img src=x onerror="alert(1)"> & news',
|
||||
message: 'Hello <script>alert(1)</script>\n\nRead https://www.hackdex.app/terms?a=1&b=2.\njavascript:alert(1)',
|
||||
});
|
||||
assert.ok(html.includes("<img"));
|
||||
assert.ok(html.includes("<script>"));
|
||||
assert.ok(html.includes("<br /><br />"));
|
||||
assert.ok(html.includes('href="https://www.hackdex.app/terms?a=1&b=2"'));
|
||||
assert.ok(html.includes("You can reply to this email"));
|
||||
assert.ok(!html.includes("Please do not reply"));
|
||||
assert.ok(!html.includes("<script>"));
|
||||
assert.ok(!html.includes('href="javascript:'));
|
||||
});
|
||||
|
||||
test("announcement files validate the campaign ID and required copy", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const file = path.join(directory, "notice.json");
|
||||
await writeFile(file, JSON.stringify(announcement));
|
||||
assert.deepEqual(await loadAnnouncement(file), announcement);
|
||||
for (const invalid of [{ ...announcement, id: "../escape" }, { ...announcement, message: " " }, { ...announcement, title: "Subject\nBcc: someone" }]) {
|
||||
await writeFile(file, JSON.stringify(invalid));
|
||||
await assert.rejects(loadAnnouncement(file), /Announcement needs/);
|
||||
}
|
||||
});
|
||||
|
||||
test("recipient selection paginates, excludes empty drafts, and deduplicates creator emails", async () => {
|
||||
const offsets: number[] = [];
|
||||
const authLookups: string[] = [];
|
||||
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { fetch: async (input, init) => {
|
||||
const url = new URL(new Request(input, init).url);
|
||||
if (url.pathname === "/rest/v1/hacks") {
|
||||
assert.equal(url.searchParams.get("is_archive"), "eq.false");
|
||||
const columns = new Set(["slug", "created_by", "current_patch", "patch_url", "approved"]);
|
||||
const unknown = url.searchParams.get("select")?.split(",").find((column) => !columns.has(column));
|
||||
if (unknown) return Response.json({ message: `column hacks.${unknown} does not exist`, code: "42703" }, { status: 400 });
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
offsets.push(offset);
|
||||
const row = { created_by: "00000000-0000-4000-8000-000000000001", current_patch: 1, patch_url: "", approved: false };
|
||||
return Response.json(offset === 0
|
||||
? Array.from({ length: 1000 }, (_, i) => ({ ...row, slug: `hack-${i}` }))
|
||||
: [
|
||||
{ ...row, slug: "pending", created_by: "00000000-0000-4000-8000-000000000002", current_patch: 2 },
|
||||
{ ...row, slug: "legacy", created_by: "00000000-0000-4000-8000-000000000003", current_patch: null, patch_url: "patch.bps" },
|
||||
{ ...row, slug: "approved", created_by: "00000000-0000-4000-8000-000000000003", current_patch: null, approved: true },
|
||||
{ ...row, slug: "empty-draft", created_by: "draft-owner", current_patch: null },
|
||||
]);
|
||||
}
|
||||
const id = url.pathname.split("/").at(-1)!;
|
||||
authLookups.push(id);
|
||||
return Response.json({ id, email: id === "00000000-0000-4000-8000-000000000003" ? "second@example.com" : "Creator@Example.com", aud: "authenticated", app_metadata: {}, user_metadata: {}, created_at: "2026-01-01T00:00:00Z" });
|
||||
} },
|
||||
});
|
||||
const result = await collectRecipients(supabase);
|
||||
assert.deepEqual(offsets, [0, 1000]);
|
||||
assert.deepEqual(authLookups, ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002", "00000000-0000-4000-8000-000000000003"]);
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0].email, "creator@example.com");
|
||||
assert.deepEqual(result[0].userIds, ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002"]);
|
||||
assert.equal(result[0].hacks.length, 1001);
|
||||
assert.deepEqual(result[1].hacks, ["legacy", "approved"]);
|
||||
});
|
||||
|
||||
test("recipient preparation reports database failures instead of returning an empty audience", async () => {
|
||||
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
||||
auth: { persistSession: false },
|
||||
global: { fetch: async () => Response.json({ message: "Permission denied" }, { status: 403 }) },
|
||||
});
|
||||
await assert.rejects(collectRecipients(supabase), /Cannot load hacks: Permission denied/);
|
||||
});
|
||||
|
||||
test("recipient preparation stops when an uploaded hack's creator has no email", async () => {
|
||||
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
||||
auth: { persistSession: false },
|
||||
global: { fetch: async (input, init) => {
|
||||
const url = new URL(new Request(input, init).url);
|
||||
return Response.json(url.pathname === "/rest/v1/hacks"
|
||||
? [{ slug: "uploaded", created_by: "00000000-0000-4000-8000-000000000001", current_patch: 1, patch_url: "", approved: false }]
|
||||
: { id: "00000000-0000-4000-8000-000000000001", aud: "authenticated", app_metadata: {}, user_metadata: {}, created_at: "2026-01-01T00:00:00Z" });
|
||||
} },
|
||||
});
|
||||
await assert.rejects(collectRecipients(supabase), /has no valid account email/);
|
||||
});
|
||||
|
||||
test("conflicting modes are rejected before any network request", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected network request"); });
|
||||
await assert.rejects(main(["notice.json", "--test", "preview@example.com", "--send"]), /at most one mode/);
|
||||
});
|
||||
|
||||
test("default mode renders offline without querying recipients or sending email", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(directory);
|
||||
t.after(() => process.chdir(previousCwd));
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected network request"); });
|
||||
await writeFile("notice.json", JSON.stringify({ ...announcement, ready: false }));
|
||||
await main(["notice.json"]);
|
||||
assert.ok((await readFile(".local/announcements/test-notice/preview.html", "utf8")).includes("Creator notice"));
|
||||
assert.deepEqual((await readdir(".local/announcements/test-notice")).sort(), ["preview.html", "preview.txt"]);
|
||||
});
|
||||
|
||||
test("test mode sends only to its explicit address and never uses the creator snapshot", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(directory);
|
||||
t.after(() => process.chdir(previousCwd));
|
||||
const env = { RESEND_FROM: message.from, RESEND_REPLY_TO: message.replyTo, RESEND_API_KEY: "re_test_key" };
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const old = process.env[key];
|
||||
process.env[key] = value;
|
||||
t.after(() => { if (old === undefined) delete process.env[key]; else process.env[key] = old; });
|
||||
}
|
||||
let sent = 0;
|
||||
t.mock.method(globalThis, "fetch", async (input: string, init: RequestInit) => {
|
||||
assert.equal(input, "https://api.resend.com/emails");
|
||||
const body = JSON.parse(String(init.body));
|
||||
assert.equal(body.to, "preview@example.com");
|
||||
assert.equal(body.reply_to, "test@example.com");
|
||||
assert.equal(body.subject, "[TEST] Creator notice");
|
||||
assert.equal(body.cc, undefined);
|
||||
assert.equal(body.bcc, undefined);
|
||||
sent++;
|
||||
return Response.json({ id: "test-email" });
|
||||
});
|
||||
await writeFile("notice.json", JSON.stringify({ ...announcement, ready: false }));
|
||||
await mkdir(".local/announcements/test-notice", { recursive: true });
|
||||
await writeFile(".local/announcements/test-notice/recipients.json", "intentionally invalid snapshot");
|
||||
await main(["notice.json", "--test", "preview@example.com"]);
|
||||
assert.equal(sent, 1);
|
||||
assert.equal(await readFile(".local/announcements/test-notice/recipients.json", "utf8"), "intentionally invalid snapshot");
|
||||
assert.ok(!(await readdir(".local/announcements/test-notice")).includes("payload.json"));
|
||||
});
|
||||
|
||||
test("production sending rejects drafts and unresolved placeholders", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const mailer = new Resend("re_test_key").emails;
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected email"); });
|
||||
for (const draft of [{ ...announcement, ready: false }, { ...announcement, message: "Effective {{effectiveDate}}" }]) {
|
||||
await assert.rejects(sendAnnouncement({ announcement: draft, message, recipients, directory, mailer }), /Announcement is a draft/);
|
||||
}
|
||||
assert.deepEqual(await readdir(directory), []);
|
||||
});
|
||||
|
||||
test("accepted sends persist IDs and are skipped on subsequent runs", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const requests: string[] = [];
|
||||
t.mock.method(globalThis, "fetch", async (_input: string, init: RequestInit) => {
|
||||
requests.push(new Headers(init.headers).get("Idempotency-Key")!);
|
||||
assert.equal(JSON.parse(String(init.body)).to, "creator@example.com");
|
||||
return Response.json({ id: "accepted-email" });
|
||||
});
|
||||
const options = { announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 };
|
||||
assert.deepEqual(await sendAnnouncement(options), { accepted: 1, skipped: 0 });
|
||||
assert.deepEqual(await sendAnnouncement(options), { accepted: 0, skipped: 1 });
|
||||
assert.equal(requests.length, 1);
|
||||
assert.match(requests[0], /^announcement\/test-notice\/[a-f0-9]{64}$/);
|
||||
const recordName = (await readdir(directory)).find((file) => file.endsWith(".sent.json"))!;
|
||||
const record = JSON.parse(await readFile(path.join(directory, recordName), "utf8"));
|
||||
assert.equal(record.emailId, "accepted-email");
|
||||
assert.equal(record.email, "creator@example.com");
|
||||
await assert.rejects(sendAnnouncement({ ...options, message: { ...message, subject: "Changed" } }), /Message or recipients changed/);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test("uncertain delivery stops the run and blocks blind resends", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
let requests = 0;
|
||||
t.mock.method(globalThis, "fetch", async () => { requests++; throw new Error("Connection lost"); });
|
||||
const options = { announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 };
|
||||
await assert.rejects(sendAnnouncement(options), /Resend did not confirm/);
|
||||
await assert.rejects(sendAnnouncement(options), /Unresolved delivery/);
|
||||
assert.equal(requests, 1);
|
||||
const files = await readdir(directory);
|
||||
assert.equal(files.filter((file) => file.endsWith(".pending.json")).length, 1);
|
||||
assert.equal(files.filter((file) => file.endsWith(".sent.json")).length, 0);
|
||||
assert.ok(!files.includes("send.lock"));
|
||||
});
|
||||
|
||||
test("concurrent sending cannot bypass the campaign lock", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
await writeFile(path.join(directory, "send.lock"), "another process owns this");
|
||||
await assert.rejects(sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails }), /EEXIST/);
|
||||
assert.equal(await readFile(path.join(directory, "send.lock"), "utf8"), "another process owns this");
|
||||
});
|
||||
Reference in New Issue
Block a user