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
324 lines
15 KiB
JavaScript
324 lines
15 KiB
JavaScript
// The Founder's Break - RSVP handler.
|
|
//
|
|
// Fires on form.submitted (form_name "rsvp"). Deterministic on purpose: an LLM
|
|
// in a transactional email path is a reliability problem, not a feature. Every
|
|
// string below is a fixed template.
|
|
//
|
|
// env: CORBEN_KEY (email + crm scoped), LIST_ID, FROM, NOTIFY
|
|
|
|
// User code is compiled as `async function (ctx) { ... }`, so the sandbox
|
|
// capabilities arrive on ctx rather than as globals.
|
|
var event = ctx.event, env = ctx.env, fetch = ctx.fetch, log = ctx.log, kv = ctx.kv;
|
|
|
|
var API = "https://api.corben.world/mcp";
|
|
|
|
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";
|
|
|
|
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 esc(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
|
|
function prettyDate(iso) {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso || "")) return "this Thursday";
|
|
var p = iso.split("-").map(Number);
|
|
var d = new Date(Date.UTC(p[0], p[1] - 1, p[2]));
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
|
|
}).format(d);
|
|
}
|
|
|
|
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>';
|
|
}
|
|
|
|
// ── main ────────────────────────────────────────────────────────────────
|
|
var meta = event || {};
|
|
if (meta.form_name && meta.form_name !== "rsvp") return { skipped: "not the rsvp form" };
|
|
|
|
var data = meta.data || {};
|
|
var email = String(data.email || "").trim();
|
|
var name = String(data.name || "").trim();
|
|
var building = String(data.building || "").trim();
|
|
var eventDate = String(data.event_date || "").trim();
|
|
var attending = String(data.attending || "yes").trim().toLowerCase();
|
|
var viaEmail = String(data.source || "") === "reminder-email";
|
|
|
|
// Email link previews must never change an answer or send notifications.
|
|
// The landing page adds this only after its explicit confirmation button.
|
|
if (viaEmail && String(data.rsvp_confirmed || "") !== "yes") {
|
|
return { skipped: "confirmation required" };
|
|
}
|
|
if (attending !== "yes" && attending !== "no") return { skipped: "invalid answer" };
|
|
|
|
// Honeypot: a real browser never fills this.
|
|
if (String(data.company_website || "").trim()) return { skipped: "honeypot" };
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return { skipped: "no usable email" };
|
|
|
|
var seenKey = "founders-break:seen:" + email.toLowerCase();
|
|
var returning = !!(await kv.get(seenKey));
|
|
|
|
// One answer per person per event date. Clicking Yes then No must land on No,
|
|
// and clicking the same answer twice must not send a second email.
|
|
var answerKey = "founders-break:answer:" + (eventDate || "next") + ":" + email.toLowerCase();
|
|
var previous = await kv.get(answerKey);
|
|
var changed = previous !== attending;
|
|
if (!changed) return { ok: true, event_date: eventDate, attending: attending,
|
|
previous: previous, changed: false, confirmation: "skipped (already confirmed)", via_email: viaEmail };
|
|
await kv.set(answerKey, attending);
|
|
|
|
var first = name.split(/\s+/)[0] || "there";
|
|
var last = name.split(/\s+/).slice(1).join(" ");
|
|
var when = prettyDate(eventDate);
|
|
|
|
// 1. Find or create the contact.
|
|
//
|
|
// list_contacts does a fuzzy search, so match on exact email ourselves. Sending
|
|
// mail auto-creates an "auto-created" contact for the recipient, so the same
|
|
// address can legitimately appear several times: prefer the row we tagged,
|
|
// then any real row, and only fall back to an auto-created one.
|
|
var contactId = null;
|
|
var found = await tool("list_contacts", { search: email });
|
|
var rows = Array.isArray(found) ? found : (found && found.result) || [];
|
|
var exact = rows.filter(function (r) {
|
|
return String(r.email || "").toLowerCase() === email.toLowerCase();
|
|
});
|
|
var tagged = exact.filter(function (r) { return (r.tags || []).indexOf("founders-break") !== -1; });
|
|
var real = exact.filter(function (r) { return (r.tags || []).indexOf("auto-created") === -1; });
|
|
var pick = tagged[0] || real[0] || exact[0];
|
|
if (pick) contactId = pick.id;
|
|
log("contact search: " + rows.length + " rows, " + exact.length + " exact, picked " +
|
|
(pick ? pick.id + " <" + (pick.email || "no email") + ">" : "none"));
|
|
|
|
if (!contactId) {
|
|
var made = await tool("create_contact", {
|
|
first_name: first,
|
|
last_name: last,
|
|
email: email,
|
|
tags: ["founders-break"],
|
|
notes: (eventDate || "RSVP") + (building ? " " + building : ""),
|
|
});
|
|
contactId = made && (made.id || (made.result && made.result.id));
|
|
log("created contact " + contactId + " for " + email);
|
|
}
|
|
|
|
// A list member with no address is dead weight and never receives anything.
|
|
if (!contactId) return { skipped: "could not resolve a contact for " + email };
|
|
|
|
// create_contact dedupes server-side and can hand back an existing record that
|
|
// has no email on it, which would put an unmailable member on the list. Write
|
|
// the address back explicitly so the row is always usable.
|
|
if ((!pick || !pick.email) && contactId) {
|
|
try {
|
|
await tool("update_contact", {
|
|
id: contactId,
|
|
email: email,
|
|
first_name: first,
|
|
last_name: last,
|
|
tags: ["founders-break"],
|
|
});
|
|
} catch (e) {
|
|
log("update_contact: " + e.message);
|
|
}
|
|
}
|
|
|
|
|
|
// 2. Put them on the list. Already-a-member is not an error worth failing over.
|
|
var listed = false;
|
|
if (contactId) {
|
|
try {
|
|
await tool("add_list_members", { list_id: env.LIST_ID, contact_ids: [contactId] });
|
|
listed = true;
|
|
} catch (e) {
|
|
log("add_list_members: " + e.message);
|
|
}
|
|
}
|
|
|
|
// 3. Confirmation to the person who RSVPed.
|
|
//
|
|
// Three cases, because the same email for all of them would be wrong: a first
|
|
// timer needs the full invitation, somebody who already knows the drill needs
|
|
// one line, and somebody who just said no needs nothing at all.
|
|
var mailed = "none";
|
|
|
|
if (attending === "no") {
|
|
// Saying no gets no email. Confirming a non-attendance by mail is noise, and
|
|
// the page they clicked from already told them it landed.
|
|
mailed = "skipped (declined)";
|
|
} else if (!changed) {
|
|
// Same answer as last time, most likely a second click on the same link.
|
|
mailed = "skipped (already confirmed)";
|
|
} else if (returning) {
|
|
var backBody =
|
|
'<p style="margin:0;">You are confirmed for this week, ' + esc(first) + '. Same place, same time.</p>' +
|
|
marquee(when) +
|
|
'<p style="margin:20px 0 0;">Bring whatever you are working on. Forward this to anyone who should be there.</p>' +
|
|
button("See what to expect", SITE);
|
|
|
|
await tool("send_email", {
|
|
from: env.FROM,
|
|
to: email,
|
|
subject: "See you " + when,
|
|
html: shell({
|
|
headline: "Confirmed for " + when.split(",")[0] + ".",
|
|
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: backBody,
|
|
unsubscribeFor: encodeURIComponent(email),
|
|
pixel: openPixel('confirm-returning', encodeURIComponent(email), eventDate),
|
|
}),
|
|
text: "You are confirmed for this week, " + first + ".\n\n" + when + ", 7:00 to 9:00 PM\n" +
|
|
VENUE + "\n" + STREET + ", " + CITY + "\nFree entry. 18 and over, so bring an ID.\n\n" + SITE,
|
|
});
|
|
mailed = "returning";
|
|
} else {
|
|
var confirmBody =
|
|
'<p style="margin:0;">You are on the list, ' + esc(first) + '. There is nothing else to do before then.</p>' +
|
|
marquee(when) +
|
|
'<p style="margin:20px 0 0;">Pool experience is not expected. Bring whatever you are working on and we will introduce you to the people worth meeting.</p>' +
|
|
'<p style="margin:14px 0 0;">A reminder will follow the day before.</p>' +
|
|
button("See what to expect", SITE);
|
|
|
|
await tool("send_email", {
|
|
from: env.FROM,
|
|
to: email,
|
|
subject: "You are on the list: The Founder's Break, " + when,
|
|
html: shell({
|
|
headline: "You are on the list.",
|
|
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: confirmBody,
|
|
unsubscribeFor: encodeURIComponent(email),
|
|
pixel: openPixel('confirm-first', encodeURIComponent(email), eventDate),
|
|
}),
|
|
text: "You are on the list, " + first + ". There is nothing else to do before then.\n\n" +
|
|
when + ", 7:00 to 9:00 PM\n" + VENUE + "\n" + STREET + ", " + CITY +
|
|
"\nFree entry. 18 and over, so bring an ID.\n\n" +
|
|
"Pool experience is not expected. Bring whatever you are working on.\n" +
|
|
"A reminder will follow the day before.\n\n" + SITE,
|
|
});
|
|
mailed = "first time";
|
|
}
|
|
|
|
await kv.set(seenKey, new Date().toISOString());
|
|
|
|
// 4. Internal heads-up. Plain on purpose, this one is for us.
|
|
var line = function (label, value) {
|
|
return '<p style="margin:0 0 10px;font:400 15px/1.5 ' + SANS + ';color:#c2c0d4;">' +
|
|
'<span style="color:#8b89a6;">' + label + '</span> ' +
|
|
'<span style="color:#f1f0ff;font-weight:600;">' + esc(value) + '</span></p>';
|
|
};
|
|
|
|
var notifyBody =
|
|
line("Email", email) +
|
|
line("Building", building || "did not say") +
|
|
line("Coming to", when) +
|
|
line("Answer", (attending === "no" ? "NOT coming this week" : "coming") +
|
|
(previous && changed ? " (changed from " + esc(previous) + ")" : "")) +
|
|
line("Came from", viaEmail ? "confirmed reminder-email response" : "the site form") +
|
|
line("Mailing list", listed ? "added" : "not added, check the logs") +
|
|
'<p style="margin:18px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Confirmation sent: ' + mailed + '.</p>';
|
|
|
|
await tool("send_email", {
|
|
from: env.FROM,
|
|
to: env.NOTIFY,
|
|
subject: (name || email) + (attending === "no" ? " is OUT for " : " is coming on ") + when,
|
|
html: shell({
|
|
headline: esc(name || "Someone") + (attending === "no" ? " cannot make it." : " RSVPed."),
|
|
preview: (name || email) + " for " + when,
|
|
body: notifyBody,
|
|
unsubscribeFor: encodeURIComponent(env.NOTIFY),
|
|
}),
|
|
text: (name || "(no name)") + (attending === "no" ? " is NOT coming " : " RSVPed for ") + when +
|
|
"\nEmail: " + email + "\nBuilding: " + (building || "did not say") +
|
|
"\nList: " + (listed ? "added" : "not added"),
|
|
});
|
|
|
|
return {
|
|
ok: true, contact_id: contactId, listed: listed, event_date: eventDate,
|
|
attending: attending, previous: previous || null, changed: changed,
|
|
returning: returning, confirmation: mailed, via_email: viaEmail,
|
|
};
|