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
This commit is contained in:
gator 2026-10-22 14:05:19 +00:00
parent 8cfa141648
commit 3572291933
7 changed files with 317 additions and 51 deletions

View file

@ -15,7 +15,7 @@ jq -n --rawfile code ops/founders-break-rsvp.js '{code:$code}' \
```
The reminder is `cf2a1e4c-43f9-4cf4-8fc5-652c494940ae`. Their `env` (CORBEN_KEY,
LIST_ID, FROM, FROM_ADDRESS_ID, NOTIFY) is set on the function record and is not
LIST_ID, FROM, FROM_ADDRESS_ID, NOTIFY, and reminder POSTAL_ADDRESS) is set on the function record and is not
in these files. Never paste the key into this repo.
Test without waiting for a real submission:
@ -24,3 +24,21 @@ Test without waiting for a real submission:
execute_function id=3d3c4bed-... event={"form_name":"rsvp","data":{"name":"...","email":"..."}}
execute_function id=cf2a1e4c-... event={"force":true} # sends for real
```
## October 22 reliability repair
Email links now display a confirmation screen. Opening or previewing a link
does not POST a form. The RSVP function also rejects reminder responses without
the confirmation flag, and repeated answers send no duplicate notifications.
New reminder links carry their event date; stale dated links cannot answer for
the current week. Existing undated links show the current event date explicitly
before confirmation.
POSTAL_ADDRESS is required before campaign creation and is passed in the
send_campaign delivery configuration. The reminder reuses an unsent draft after
failure, records prepared versus queued state, and checks the campaign status
and durable recipients before retrying. Queued, sent or paused campaigns are
never re-sent. A prepared marker alone is not a successful send.
Run `node --test ops/regression.test.cjs` for network-isolated source tests.
They mock all API calls, including mail. Never use `force: true` as a smoke test.

24
ops/REPAIR_2026-10-22.md Normal file
View file

@ -0,0 +1,24 @@
# Reminder and RSVP repair
The owner requested correction of the missing postal-address setting and rapid
yes/no flips. An audit found that the browser automatically posted either
answer on page load. Email security scanning is a plausible explanation for
the observed pairs, but no scanner identity was established.
The candidate adds explicit browser confirmation, rejects unconfirmed reminder
submissions before the function writes or sends anything, and suppresses repeat
notifications for unchanged answers. New links include the event date. The
ordinary site form keeps its existing registration flow.
The reminder receives the owner's previously authorized OneTouch postal address
as a private environment setting and supplies it in paced delivery configuration.
It records prepared and queued states separately, reuses a failed draft and
checks for existing recipient queues before retrying. The October 22 draft's
delivery metadata is corrected without queueing it or changing its date/copy.
The same 18 isolated regression cases report 13 failures on the pre-change
sources and all pass on the candidate. No actual email, attendee response or
contact cleanup is part of this repair. Existing answers are retained because
the historic link traffic does not establish which changes were intentional.
Local evidence: `/Users/devingator/Documents/AITesting/outputs/founders-break-fix-20261022/`.

View file

@ -6,7 +6,7 @@
// 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, NOTIFY
// 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;
@ -135,12 +135,12 @@ function button(label, href) {
}
// Yes / No in one tap. The links land on the site, which posts to the same
// /forms/rsvp the form uses, so there is no second public write endpoint.
// 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() {
var yes = SITE + "/?a=yes&e={{email}}#rsvp";
var no = SITE + "/?a=no&e={{email}}#rsvp";
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;">' +
@ -151,7 +151,7 @@ function answerButtons() {
'<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;">One tap, nothing to fill in. Either answer keeps you on the list for future weeks.</p>';
'<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 ────────────────────────────────────────────────────────────────
@ -168,7 +168,26 @@ 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;
if (!force && (await kv.get(marker))) return { skipped: "already sent for " + 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);
@ -176,9 +195,9 @@ 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();
answerButtons(target);
var campaign = await tool("create_campaign", {
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,
@ -201,11 +220,15 @@ var campaign = await tool("create_campaign", {
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));
// Mark before sending. A crash after this leaves the week unsent, which is a
// far better failure than mailing the whole list twice.
await kv.set(marker, new Date().toISOString());
await tool("send_campaign", { id: campaignId });
log("sent reminder for " + target);
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 };

View file

@ -125,6 +125,13 @@ 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" };
@ -137,6 +144,8 @@ var returning = !!(await kv.get(seenKey));
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";
@ -289,7 +298,7 @@ var notifyBody =
line("Coming to", when) +
line("Answer", (attending === "no" ? "NOT coming this week" : "coming") +
(previous && changed ? " (changed from " + esc(previous) + ")" : "")) +
line("Came from", viaEmail ? "one-click in the reminder email" : "the site form") +
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>';

160
ops/regression.test.cjs Normal file
View file

@ -0,0 +1,160 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const root = process.env.FOUNDERS_SOURCE_ROOT || path.resolve(__dirname, '..');
const source = file => fs.readFileSync(path.join(root, file), 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
function page(query, response = { ok: true }) {
const nodes = new Map(), requests = [];
class Element {
constructor(id) { this.id = id; this.listeners = {}; this.children = []; this.value = ''; this.hidden = false;
this.style = {}; this.dataset = {}; this.classList = { add() {}, toggle() {}, contains() { return false; } }; }
setAttribute() {} removeAttribute() {} focus() {} prepend() {}
appendChild(child) { this.children.push(child); if (child.id) nodes.set(child.id, child); return child; }
insertBefore(child) { this.appendChild(child); }
addEventListener(name, fn) { this.listeners[name] = fn; }
querySelector(selector) {
if (selector === '[name="event_date"]') return { value: '2026-10-22' };
return new Element();
}
querySelectorAll() { return []; }
closest() { return new Element(); }
}
for (const id of ['nav', 'rsvp', 'rsvp-form', 'rsvp-submit', 'form-status', 'rsvp-done', 'rsvp-live', 'f-name', 'f-email']) nodes.set(id, new Element(id));
nodes.get('rsvp-form').action = 'https://qa.invalid/forms/rsvp';
nodes.get('rsvp-done').hidden = true;
const document = { body: new Element(), currentScript: null,
getElementById: id => nodes.get(id) || null, querySelectorAll: () => [], querySelector: () => null,
createElement: () => new Element() };
const location = { search: query, pathname: '/' };
const window = { matchMedia: () => ({ matches: true }), location, history: { replaceState() {} } };
const fetch = async (url, options) => { requests.push({ url, options }); return { ok: true, json: async () => response }; };
vm.runInNewContext(source('public/js/main.js'), { document, window, location, URLSearchParams, Intl, Date,
IntersectionObserver: class { observe() {} disconnect() {} }, fetch, setTimeout });
return { nodes, requests, click: (choice, isTrusted = true) => nodes.get('confirm-rsvp-' + choice)?.listeners.click({ isTrusted }) };
}
for (const choice of ['yes', 'no']) test(`opening a legacy ${choice} link never submits`, () => {
const p = page(`?a=${choice}&e=qa%40example.invalid`);
assert.equal(p.requests.length, 0);
});
test('dated link needs a trusted click and submits only once', async () => {
const p = page('?a=yes&e=qa%2Btest%40example.invalid&d=2026-10-22');
assert.equal(p.requests.length, 0);
assert.ok(p.nodes.has('confirm-rsvp-yes'));
p.click('yes', false); assert.equal(p.requests.length, 0);
p.click('yes'); p.click('no');
await tick();
assert.equal(p.requests.length, 1);
const body = new URLSearchParams(p.requests[0].options.body);
assert.equal(body.get('email'), 'qa+test@example.invalid');
assert.equal(body.get('event_date'), '2026-10-22');
assert.equal(body.get('attending'), 'yes');
assert.equal(body.get('rsvp_confirmed'), 'yes');
assert.equal(p.nodes.get('rsvp-done').hidden, false);
});
test('a deliberate no is preserved', async () => {
const p = page('?a=no&e=qa%40example.invalid&d=2026-10-22');
p.click('no'); await tick();
assert.equal(new URLSearchParams(p.requests[0].options.body).get('attending'), 'no');
});
test('a stale dated invitation cannot silently answer for another week', () => {
const p = page('?a=no&e=qa%40example.invalid&d=2026-10-15');
assert.equal(p.requests.length, 0);
assert.equal(p.nodes.has('confirm-rsvp-no'), false);
assert.match(p.nodes.get('form-status').textContent, /different week/);
});
test('an invalid address keeps the ordinary form available', () => {
const p = page('?a=yes&e=invalid');
assert.equal(p.requests.length, 0);
assert.equal(p.nodes.get('rsvp-form').hidden, false);
});
test('a rejected save leaves confirmation available for retry', async () => {
const p = page('?a=yes&e=qa%40example.invalid', { ok: false });
p.click('yes'); await tick();
assert.equal(p.nodes.get('confirm-rsvp-yes').disabled, false);
assert.equal(p.nodes.get('rsvp-done').hidden, true);
assert.equal(p.nodes.get('rsvp-live').hidden, false);
});
function functionFixture(file, { now = '2026-10-28T18:00:00Z', event = {}, initial = [], campaigns = [], recipients = [] } = {}) {
const writes = [], calls = [], store = new Map(initial);
let failSend = false;
class FixedDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return Date.parse(now); } }
const fn = vm.runInNewContext(`(async function(ctx) { ${source('ops/' + file)}\n})`, { Date: FixedDate, Intl, URLSearchParams });
const env = { POSTAL_ADDRESS: 'Synthetic QA postal fixture, not a production address', LIST_ID: 'qa-list', FROM_ADDRESS_ID: 'qa-sender', NOTIFY: 'qa@example.invalid', FROM: 'qa@example.invalid', CORBEN_KEY: 'synthetic-key' };
const ctx = { event, env, log() {}, kv: { get: async key => store.get(key), set: async (key, value) => { writes.push({ key, value }); store.set(key, value); } },
fetch: async (_url, options) => {
const request = JSON.parse(options.body); calls.push(request);
if (request.tool === 'send_campaign' && failSend) return { ok: false, status: 500, text: 'synthetic queue failure' };
let data;
if (request.tool === 'list_campaigns') data = campaigns;
else if (request.tool === 'get_campaign_stats') data = recipients;
else if (request.tool === 'create_campaign') { data = { id: 'qa-campaign', ...request.arguments, status: 'draft' }; campaigns.push(data); }
else if (request.tool === 'send_campaign') { data = { ok: true, queued: 2 }; campaigns.find(c => c.id === request.arguments.id).status = 'sending'; }
else if (request.tool === 'list_contacts') data = [{ id: 'qa-contact', email: 'qa@example.invalid', tags: ['founders-break'] }];
else data = { id: 'synthetic-id', ok: true };
return { ok: true, status: 200, json: data };
} };
return { env, calls, writes, store, run: () => fn(ctx), failSend: value => { failSend = value; } };
}
const answerData = { source: 'reminder-email', name: 'Synthetic QA', email: 'qa@example.invalid', event_date: '2026-10-22', attending: 'yes' };
for (const attending of ['yes', 'no']) test(`backend rejects unconfirmed ${attending} without a write or send`, async () => {
const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, attending } } });
assert.equal((await f.run()).skipped, 'confirmation required');
assert.equal(f.writes.length, 0); assert.equal(f.calls.length, 0);
});
test('repeated confirmed answer sends no duplicate notification', async () => {
const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, rsvp_confirmed: 'yes' } }, initial: [['founders-break:answer:2026-10-22:qa@example.invalid', 'yes']] });
assert.equal((await f.run()).changed, false); assert.equal(f.calls.length, 0); assert.equal(f.writes.length, 0);
});
test('a confirmed new answer still reaches the existing RSVP pipeline', async () => {
const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, rsvp_confirmed: 'yes' } } });
assert.equal((await f.run()).attending, 'yes');
assert.equal(f.calls.filter(c => c.tool === 'send_email').length, 2);
});
test('ordinary site registration still works', async () => {
const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, source: undefined } } });
assert.equal((await f.run()).ok, true);
});
test('weekly queue request includes postal configuration and dated links', async () => {
const f = functionFixture('founders-break-reminder.js'); await f.run();
const send = f.calls.find(c => c.tool === 'send_campaign');
assert.equal(send.arguments.delivery.postal_address, f.env.POSTAL_ADDRESS);
assert.equal(send.arguments.delivery.timezone, 'America/New_York');
assert.match(f.calls.find(c => c.tool === 'create_campaign').arguments.html_body, /&d=2026-10-29/);
assert.equal(f.store.get('founders-break:reminder:2026-10-29').state, 'queued');
});
test('failed send retains a prepared draft and a later run reuses it', async () => {
const f = functionFixture('founders-break-reminder.js'); f.failSend(true);
await assert.rejects(f.run(), /send_campaign failed/);
assert.equal(f.store.get('founders-break:reminder:2026-10-29').state, 'prepared');
f.failSend(false); await f.run();
assert.equal(f.calls.filter(c => c.tool === 'create_campaign').length, 1);
assert.equal(f.store.get('founders-break:reminder:2026-10-29').state, 'queued');
});
test('a legacy failure marker does not block reuse of an unsent draft', async () => {
const f = functionFixture('founders-break-reminder.js', { initial: [['founders-break:reminder:2026-10-29', 'legacy-timestamp']], campaigns: [{ id: 'draft-id', name: "Founder's Break reminder 2026-10-29", status: 'draft' }] });
await f.run(); assert.equal(f.calls.filter(c => c.tool === 'create_campaign').length, 0);
assert.equal(f.calls.find(c => c.tool === 'send_campaign').arguments.id, 'draft-id');
});
test('queued campaigns and draft campaigns with recipient queues are not sent again', async () => {
for (const state of ['sending', 'sent', 'paused', 'scheduled', 'draft']) {
const f = functionFixture('founders-break-reminder.js', { campaigns: [{ id: 'existing-id', name: "Founder's Break reminder 2026-10-29", status: state }], recipients: [{}] });
await f.run(); assert.equal(f.calls.filter(c => c.tool === 'send_campaign').length, 0);
}
});
test('missing postal address fails before campaign creation', async () => {
const f = functionFixture('founders-break-reminder.js'); f.env.POSTAL_ADDRESS = '';
await assert.rejects(f.run(), /POSTAL_ADDRESS/);
assert.equal(f.calls.length, 0);
});
test('Thursday remains unsent without running a catch-up campaign', async () => {
const f = functionFixture('founders-break-reminder.js', { now: '2026-10-22T18:00:00Z' });
assert.match((await f.run()).skipped, /not wednesday/);
assert.equal(f.calls.length, 0);
});

View file

@ -287,46 +287,78 @@
var params = new URLSearchParams(window.location.search);
if (params.get("rsvp") === "ok") showDone();
/* One-click answer from the weekly reminder: ?a=yes|no&e=<address>.
This posts to the same /forms/rsvp the form uses rather than opening a new
public write endpoint, so there is exactly one path into the pipeline. */
/* Email links select an answer, but only a trusted confirmation click
submits it. Security scanners can open both links without changing RSVPs. */
var answer = params.get("a");
if (answer === "yes" || answer === "no") {
// URLSearchParams turns "+" into a space, and an address can never contain
// a literal space, so putting it back is safe and fixes plus-addressing.
var who = (params.get("e") || "").trim().replace(/ /g, "+");
var currentEvent = form.querySelector('[name="event_date"]').value;
var linkedEvent = params.get("d");
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(who)) {
// Bad or missing address: fall back to the normal form rather than guess.
status.textContent = "We could not read that link. Fill this in and you are set.";
} else if (linkedEvent && linkedEvent !== currentEvent) {
status.textContent = "That invitation is for a different week. Use this form to RSVP for the date shown above.";
} else {
emailField.value = who;
submit.setAttribute("aria-busy", "true");
var oneClick = new URLSearchParams();
oneClick.set("name", params.get("n") || who.split("@")[0]);
oneClick.set("email", who);
oneClick.set("attending", answer);
oneClick.set("source", "reminder-email");
oneClick.set("event_date", form.querySelector('[name="event_date"]').value);
fetch(form.action, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
Accept: "application/json",
},
body: oneClick.toString(),
})
.then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json().catch(function () { return {}; }); })
.then(function (body) {
if (body && body.ok === false) throw new Error("rejected");
track("rsvp_completed", { via: "reminder-email", answer: answer });
showDone(answer);
})
.catch(function (err) {
track("rsvp_failed", { via: "reminder-email", answer: answer, reason: String(err && err.message || "unknown").slice(0, 80) });
submit.removeAttribute("aria-busy");
status.textContent = "That link did not go through. Hit RSVP below and you are set.";
form.hidden = true;
var panel = document.createElement("div");
panel.id = "email-rsvp-confirm";
var message = document.createElement("p");
var prettyEvent = new Intl.DateTimeFormat("en-US", {
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
}).format(new Date(currentEvent + "T12:00:00Z"));
message.textContent = "Confirm your RSVP for " + prettyEvent + " as " + who + ". Nothing is saved until you confirm.";
panel.appendChild(message);
var feedback = document.createElement("p");
feedback.setAttribute("role", "status");
feedback.setAttribute("aria-live", "polite");
var buttons = [];
var pending = false;
[answer, answer === "yes" ? "no" : "yes"].forEach(function (choice, i) {
var button = document.createElement("button");
button.type = "button";
button.id = "confirm-rsvp-" + choice;
button.className = "btn btn-block" + (i === 0 ? " btn-primary" : "");
button.textContent = choice === "yes" ? "Confirm: I am coming" : "Confirm: I cannot make it";
button.addEventListener("click", function (event) {
if (!event.isTrusted || pending) return;
pending = true;
feedback.textContent = "Saving your answer...";
buttons.forEach(function (b) { b.disabled = true; });
var payload = new URLSearchParams();
payload.set("name", params.get("n") || who.split("@")[0]);
payload.set("email", who);
payload.set("attending", choice);
payload.set("source", "reminder-email");
payload.set("rsvp_confirmed", "yes");
payload.set("event_date", currentEvent);
fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", Accept: "application/json" },
body: payload.toString(),
})
.then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); })
.then(function (body) {
if (!body || body.ok !== true) throw new Error("rejected");
track("rsvp_completed", { via: "reminder-email", answer: choice });
["a", "e", "n", "d"].forEach(function (key) { params.delete(key); });
var query = params.toString();
window.history.replaceState(null, "", window.location.pathname + (query ? "?" + query : "") + "#rsvp");
showDone(choice);
})
.catch(function (err) {
pending = false;
buttons.forEach(function (b) { b.disabled = false; });
feedback.textContent = "That did not go through. Please try confirming again.";
track("rsvp_failed", { via: "reminder-email", answer: choice, reason: String(err && err.message || "unknown").slice(0, 80) });
});
});
buttons.push(button);
panel.appendChild(button);
});
panel.appendChild(feedback);
live.insertBefore(panel, form);
}
}
})();

View file

@ -352,6 +352,6 @@
</div>
</footer>
<script src="/js/main.js" data-site="<%= tenant.id %>" defer></script>
<script src="/js/main.js?v=20261022-confirm" data-site="<%= tenant.id %>" defer></script>
</body>
</html>