// 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; 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, "'"); } 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/e7119448-83ba-4da4-95d8-b64045c21160/email-header-trim.png"; 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 ? '' + esc(opts.imageAlt || ' : ""; return '' + '' + '' + '' + '' + '
' + esc(opts.preview || "") + '
' + '' + '
' + '' + image + '' + '' + '' + '
' + '

The Founder\'s Break

' + '

' + opts.headline + '

' + '
' + opts.body + '
' + '

' + 'Hosted by OneTouch Studio. ' + 'You are getting this because you RSVPed at foundersbreak.onetouch.studio. ' + 'Reply and we will take you off the list.' + '

'; } // The date and place, sized like an invitation rather than a spec sheet. function marquee(when) { return '' + '
' + '

' + esc(when) + '

' + '

7:00 to 9:00 PM

' + '

' + '' + VENUE + '
' + STREET + ', ' + CITY + '

' + '

Free to get in. 18 and over, so bring an ID.

' + '
'; } function button(label, href) { return '
' + '' + label + '' + '
'; } // ── 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(); // 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 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. var confirmBody = '

Nice one, ' + esc(first) + '. Nothing else to do now. Just turn up and find us at the tables.

' + marquee(when) + '

You do not need to be good at pool. Most of us are not. Bring whatever you are working on and we will point you at the people worth meeting.

' + '

We will nudge you the day before so it does not sneak up on you.

' + button("See what to expect", SITE); await tool("send_email", { from: env.FROM, to: email, subject: "You are in: The Founder's Break, " + when, html: shell({ headline: "You are in.", 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, }), text: "Nice one, " + first + ". Nothing else to do now, just turn up and find us at the tables.\n\n" + when + ", 7:00 to 9:00 PM\n" + VENUE + "\n" + STREET + ", " + CITY + "\nFree to get in. 18 and over, so bring an ID.\n\n" + "You do not need to be good at pool. Bring whatever you are working on.\n" + "We will nudge you the day before.\n\n" + SITE, }); // 4. Internal heads-up. Plain on purpose, this one is for us. var line = function (label, value) { return '

' + '' + label + ' ' + '' + esc(value) + '

'; }; var notifyBody = line("Email", email) + line("Building", building || "did not say") + line("Coming to", when) + line("Mailing list", listed ? "added" : "not added, check the logs") + '

Their confirmation has already gone out.

'; await tool("send_email", { from: env.FROM, to: env.NOTIFY, subject: (name || email) + " is coming on " + when, html: shell({ headline: esc(name || "Someone") + " RSVPed.", preview: (name || email) + " for " + when, body: notifyBody, }), text: (name || "(no name)") + " 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 };