founders-break/ops/founders-break-reminder.js
gator 3572291933 Fix reminder postal configuration and require deliberate RSVP confirmation
Link previews no longer submit answers. Failed queue attempts retain reusable drafts; recipient queues prevent duplicate retries.

Tested: 18 isolated regressions pass; 13 fail on baseline.
AI-Agent: Codex (OpenAI)
AI-Session: 01a14186-e2ee-77b1-9526-a32c58cd6a00
2026-10-22 14:05:19 +00:00

234 lines
13 KiB
JavaScript

// The Founder's Break - weekly reminder.
//
// Runs on a plain interval (the cron runner has no day-of-week syntax), works
// out the Eastern weekday itself, and only sends on Wednesday. Deterministic on
// purpose: no LLM anywhere in the send path. A kv marker keyed to the event date
// makes a double-fire a no-op, which is what lets the job run more than once a
// day without ever mailing the list twice.
//
// env: CORBEN_KEY, LIST_ID, FROM, FROM_ADDRESS_ID, NOTIFY, POSTAL_ADDRESS
// event: { force: true } sends regardless of the day, for testing.
var event = ctx.event, env = ctx.env, fetch = ctx.fetch, log = ctx.log, kv = ctx.kv;
var API = "https://api.corben.world/mcp";
var TZ = "America/New_York";
var VENUE = "Silver Q Billiards & Sports Bar";
var STREET = "225 W University Ave";
var CITY = "Gainesville, FL 32601";
var SITE = "https://foundersbreak.onetouch.studio";
var MAP = "https://www.google.com/maps/search/?api=1&query=Silver+Q+Billiards+225+W+University+Ave+Gainesville+FL+32601";
var WEEKDAY = 4; // Thursday
var SEND_DAY = 3; // Wednesday
var CANCELLED = []; // ISO dates to skip; keep in step with site.config.js
async function tool(name, args) {
var res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + env.CORBEN_KEY },
body: JSON.stringify({ tool: name, arguments: args }),
});
if (!res.ok) throw new Error(name + " failed: " + res.status + " " + String(res.text).slice(0, 200));
return res.json;
}
function etNow() {
var now = new Date();
var iso = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit",
}).format(now);
var wd = new Intl.DateTimeFormat("en-US", { timeZone: TZ, weekday: "short" }).format(now);
var hour = Number(new Intl.DateTimeFormat("en-US", { timeZone: TZ, hour: "2-digit", hourCycle: "h23" }).format(now)) % 24;
var order = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
return { iso: iso, day: order[wd], hour: hour };
}
function addDays(iso, n) {
var p = iso.split("-").map(Number);
var d = new Date(Date.UTC(p[0], p[1] - 1, p[2]));
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function pretty(iso) {
var p = iso.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
}).format(new Date(Date.UTC(p[0], p[1] - 1, p[2])));
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
var HERO = "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/5b98d309-7283-4202-bf43-263dbcec74c2/email-header-web.png";
var SITE_ID = "4ae63819-3f63-4fea-b0de-eb1e5befe607";
var PIXEL = "https://api.corben.world/analytics/open.gif";
// 1x1 open pixel. Opens skew high because Apple Mail Privacy Protection and
// Gmail's proxy prefetch images, so read it as a ceiling, not a headcount.
function openPixel(kind, who, when) {
return '<img src="' + PIXEL + '?s=' + SITE_ID +
'&k=' + encodeURIComponent(kind) +
'&e=' + who +
'&d=' + encodeURIComponent(when || '') +
'" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;opacity:0;">';
}
var UNSUB = "https://api.corben.world/campaigns/unsubscribe?user_id=c23ea202-5442-41b4-9a1b-1586e81c50eb&email=";
var SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// One shell for every outbound email so they all look like the site. `image`
// is optional: the internal notification does not need a photograph.
function shell(opts) {
var image = opts.image
? '<tr><td style="padding:0;"><img src="' + opts.image + '" width="560" alt="' + esc(opts.imageAlt || "") + '" ' +
'style="display:block;width:100%;max-width:560px;height:auto;border-radius:14px 14px 0 0;border:0;"></td></tr>'
: "";
return '<!DOCTYPE html><html><head><meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<meta name="color-scheme" content="dark light">' +
'<meta name="supported-color-schemes" content="dark light"></head>' +
'<body style="margin:0;padding:0;background:#06060b;">' +
'<div style="display:none;max-height:0;overflow:hidden;opacity:0;">' + esc(opts.preview || "") + '</div>' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#06060b;padding:32px 16px;">' +
'<tr><td align="center">' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#0c0c14;border:1px solid rgba(16,185,129,0.18);border-radius:14px;overflow:hidden;">' +
image +
'<tr><td style="padding:34px 34px 0;">' +
'<p style="margin:0 0 8px;font:700 11px/1.4 ' + SANS + ';letter-spacing:.16em;text-transform:uppercase;color:#34d399;">The Founder\'s Break</p>' +
'<h1 style="margin:0;font:700 27px/1.18 ' + SANS + ';letter-spacing:-.02em;color:#f1f0ff;">' + opts.headline + '</h1>' +
'</td></tr>' +
'<tr><td style="padding:16px 34px 34px;font:400 16px/1.65 ' + SANS + ';color:#c2c0d4;">' + opts.body + '</td></tr>' +
'<tr><td style="padding:0 34px 30px;">' +
'<p style="margin:0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;border-top:1px solid rgba(255,255,255,.08);padding-top:18px;">' +
'Hosted by <a href="https://onetouch.studio" style="color:#34d399;text-decoration:none;">OneTouch Studio</a>. ' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>.' +
'</p><p style="margin:12px 0 0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;">' +
'<a href="' + UNSUB + (opts.unsubscribeFor || '') + '" style="color:#8b89a6;text-decoration:underline;">Unsubscribe from these emails</a>' +
' and we will not email you about the Break again.' +
'</p></td></tr>' +
'</table></td></tr></table>' + (opts.pixel || '') + '</body></html>';
}
// The date and place, sized like an invitation rather than a spec sheet.
function marquee(when) {
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0 6px;background:rgba(16,185,129,.07);border:1px solid rgba(16,185,129,.22);border-radius:12px;">' +
'<tr><td style="padding:22px 24px;">' +
'<p style="margin:0;font:700 21px/1.3 ' + SANS + ';color:#f1f0ff;letter-spacing:-.01em;">' + esc(when) + '</p>' +
'<p style="margin:6px 0 0;font:600 16px/1.5 ' + SANS + ';color:#34d399;">7:00 to 9:00 PM</p>' +
'<p style="margin:14px 0 0;font:400 15px/1.55 ' + SANS + ';color:#c2c0d4;">' +
'<a href="' + MAP + '" style="color:#c2c0d4;text-decoration:none;border-bottom:1px solid rgba(16,185,129,.45);">' +
VENUE + '<br>' + STREET + ', ' + CITY + '</a></p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free entry. 18 and over, so bring an ID.</p>' +
'</td></tr></table>';
}
function button(label, href) {
return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:26px 0 4px;"><tr><td style="border-radius:10px;background:#10b981;">' +
'<a href="' + href + '" style="display:inline-block;padding:13px 28px;font:700 15px/1 ' + SANS + ';color:#04140f;text-decoration:none;border-radius:10px;">' + label + '</a>' +
'</td></tr></table>';
}
// The links select an answer; an explicit confirmation on the landing page
// submits it. Merely opening an email link must never change an RSVP.
// {{email}} is a campaign merge field and resolves per recipient.
function answerButtons(date) {
var yes = SITE + "/?a=yes&e={{email}}&d=" + encodeURIComponent(date) + "#rsvp";
var no = SITE + "/?a=no&e={{email}}&d=" + encodeURIComponent(date) + "#rsvp";
return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">' +
'<tr>' +
'<td style="border-radius:10px;background:#10b981;">' +
'<a href="' + yes + '" style="display:inline-block;padding:13px 30px;font:700 15px/1 ' + SANS + ';color:#04140f;text-decoration:none;border-radius:10px;">Yes, I am coming</a>' +
'</td>' +
'<td style="width:12px;"></td>' +
'<td style="border-radius:10px;border:1px solid rgba(255,255,255,.18);">' +
'<a href="' + no + '" style="display:inline-block;padding:12px 26px;font:600 15px/1 ' + SANS + ';color:#c2c0d4;text-decoration:none;border-radius:10px;">Not this week</a>' +
'</td>' +
'</tr></table>' +
'<p style="margin:12px 0 0;font:400 13px/1.5 ' + SANS + ';color:#6f6d88;">Confirm your answer on the page that opens. Either answer keeps you on the list for future weeks.</p>';
}
// ── main ────────────────────────────────────────────────────────────────
var force = !!(event && event.force);
var now = etNow();
if (!force) {
if (now.day !== SEND_DAY) return { skipped: "not wednesday (et day " + now.day + ")" };
if (now.hour < 9 || now.hour > 20) return { skipped: "outside sending hours (et hour " + now.hour + ")" };
}
// Tomorrow is the Thursday we are reminding people about.
var target = addDays(now.iso, (WEEKDAY - now.day + 7) % 7 || 7);
if (CANCELLED.indexOf(target) !== -1) return { skipped: "next Break is cancelled: " + target };
var marker = "founders-break:reminder:" + target;
var previousMarker = await kv.get(marker);
var postalAddress = String(env.POSTAL_ADDRESS || "").trim();
if (!postalAddress) throw new Error("POSTAL_ADDRESS must be configured before creating a reminder");
// Reuse a draft after failure. A marker records preparation, not successful
// queueing. Consult the campaign and its durable recipients before any retry.
var campaigns = await tool("list_campaigns", {});
var campaignRows = Array.isArray(campaigns) ? campaigns : (campaigns && campaigns.result) || [];
var existing = campaignRows.find(function (c) { return c.name === "Founder's Break reminder " + target; });
if (existing && ["sent", "sending", "scheduled", "paused"].indexOf(existing.status) !== -1) {
return { skipped: "campaign already queued, sent or paused", event_date: target, campaign_id: existing.id };
}
if (existing) {
if (existing.status !== "draft") return { skipped: "campaign requires review", campaign_id: existing.id };
var recipients = await tool("get_campaign_stats", { campaign_id: existing.id });
var recipientRows = Array.isArray(recipients) ? recipients : (recipients && recipients.result) || [];
if (recipientRows.length) return { skipped: "delivery queue already exists; review required", campaign_id: existing.id };
} else if (previousMarker) {
return { skipped: "marker without campaign; review required", event_date: target };
}
var when = pretty(target);
var body =
'<p style="margin:0;">The Break is tomorrow night. Same place, same time.</p>' +
marquee(when) +
'<p style="margin:20px 0 0;">Bring a valid ID and whatever you are working on.</p>' +
answerButtons(target);
var campaign = existing || await tool("create_campaign", {
name: "Founder's Break reminder " + target,
subject: "Tomorrow: The Founder's Break, " + when,
list_id: env.LIST_ID,
from_address_id: env.FROM_ADDRESS_ID,
reply_to: env.NOTIFY,
html_body: shell({
headline: "See you tomorrow.",
preview: when + ", 7:00 PM at Silver Q. Free, 18+.",
image: HERO,
imageAlt: "A blueprint drawing of a pool table and the break shot.",
body: body,
unsubscribeFor: '{{email}}',
pixel: openPixel('reminder', '{{email}}', target),
}),
text_body: "The Break is tomorrow, " + when + ".\n\n" + VENUE + ", " + STREET + ", " + CITY +
"\n7:00 to 9:00 PM. Free entry, 18 and over.\n\nBring a valid ID.\n\n" + SITE,
tags: ["founders-break", "reminder"],
});
var campaignId = campaign && (campaign.id || (campaign.result && campaign.result.id));
if (!campaignId) throw new Error("create_campaign returned no id: " + JSON.stringify(campaign).slice(0, 200));
await kv.set(marker, { state: "prepared", campaign_id: campaignId, event_date: target });
var queued = await tool("send_campaign", { id: campaignId, delivery: {
postal_address: postalAddress,
sender_name: "The Founder's Break | OneTouch Studio",
timezone: TZ, interval_min_seconds: 60, interval_max_seconds: 60,
window_start_hour: 9, window_end_hour: 21, daily_cap: 50,
} });
if (!queued || queued.ok !== true) throw new Error("Campaign queueing did not report success");
await kv.set(marker, { state: "queued", campaign_id: campaignId, event_date: target, queued_at: new Date().toISOString() });
log("queued reminder for " + target);
return { ok: true, event_date: target, campaign_id: campaignId };