From 0ca460e1b77df44940f5c3a3400c3d0f87de04a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:32:31 +0000 Subject: [PATCH] Board invites: send through the Brevo API, 15s email timeout Scheduling a meeting hung forever: invites went out with nodemailer over SMTP (smtp-relay.brevo.com:587), Railway blocks outbound SMTP, and nothing timed out. - Invites now go through POST /v3/smtp/email like the other board email, with the event attached as invite.ics (base64). The .ics is built exactly as before: METHOD:REQUEST/CANCEL, UID, SEQUENCE, RRULE, RECURRENCE-ID and VTIMEZONE. The API has no attachment content-type field; Brevo types it from the .ics extension. - nodemailer and the Brevo SDK are removed. lib/brevo.ts calls the API with fetch and gives up after 15 seconds (EMAIL_TIMEOUT_MS), for every email the site sends. A batch of invites or announcements stops at the first timeout instead of waiting 15 seconds per person. - inviteSetupProblem needs only BREVO_API_KEY and a sender. - board_scheduled.invites_sent counts invites that went through. If none did, the request returns "The invites didn't go through. Try again." and the Meetings tab shows the meeting as "Invites not sent" with Resend invites and Delete (which sends nothing). Cancellations go out before the meeting changes, so a failed cancel can be retried. - BOARD_INVITE_OUTBOX writes each API request (.json) and its .ics to the folder instead of sending. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +- BOARD_SETUP.md | 32 ++++----- app/api/board/schedule/[id]/route.ts | 40 ++++++++--- app/api/board/schedule/route.ts | 12 ++-- components/board/MeetingsTab.tsx | 63 ++++++++++++++--- components/board/ScheduleForm.tsx | 16 ++++- components/board/util.ts | 19 ++++- lib/board/announce.ts | 9 ++- lib/board/invites.ts | 75 ++++++++++---------- lib/board/schedule-server.ts | 13 +++- lib/board/schedule.ts | 2 + lib/brevo.ts | 102 ++++++++++++++++++--------- package.json | 3 - pnpm-lock.yaml | 28 -------- scripts/board-schema.sql | 5 ++ 15 files changed, 269 insertions(+), 156 deletions(-) diff --git a/.env.example b/.env.example index 26ff070..8c15266 100644 --- a/.env.example +++ b/.env.example @@ -23,10 +23,8 @@ BOARD_UCLA_PASSWORD= # Signs the board hub login cookies (both chapters). Any random string of 32+ characters, # e.g. the output of: openssl rand -base64 32 BOARD_SESSION_SECRET= -# Calendar invites from the board hubs' Meetings tab go through Brevo's SMTP -# relay (Brevo → SMTP & API → SMTP: the login and an SMTP key). Each chapter's +# Calendar invites from the board hubs' Meetings tab go through the Brevo API +# (BREVO_API_KEY), like the other board email. Each chapter's # from address is in lib/board/chapter-server.ts; BOARD_INVITE_FROM is the # fallback for a chapter without one. -BREVO_SMTP_LOGIN= -BREVO_SMTP_KEY= BOARD_INVITE_FROM=board@getvindicated.org diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md index c721410..9215330 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -97,22 +97,22 @@ with reply-to set to the chapter's organizer. They need `BREVO_API_KEY`. ## Calendar invites Admins schedule meetings on the Meetings tab ("Schedule a meeting"). Each -person invited gets an email with a calendar invite (invite.ics); Gmail, -Apple Mail and Outlook show Yes / Maybe / No, and the reply goes to the -chapter's organizer (Fiona for UCLA, Rana for Berkeley, set in -`lib/board/chapter-server.ts`). Editing a meeting updates the event already -on people's calendars; cancelling a date or the whole meeting removes it. - -These go through Brevo's SMTP relay rather than the Brevo API, because only -SMTP can mark the invite as a calendar request. They come from the -chapter's sender (above) and need two Railway variables: - -- `BREVO_SMTP_LOGIN` and `BREVO_SMTP_KEY`: in Brevo, **SMTP & API → SMTP** - (the login shown there, and an SMTP key you generate). - -`BOARD_INVITE_FROM` is only needed for a chapter with no sender of its own; -its domain, **getvindicated.org**, must be verified in Brevo (**Senders, -Domains & Dedicated IPs → Domains**). +person invited gets an email with the event attached as `invite.ics`. +Replies go to the chapter's organizer (Fiona for UCLA, Rana for Berkeley, +set in `lib/board/chapter-server.ts`). Editing a meeting sends the same +event with a higher sequence number, so calendars update the copy they +have; cancelling a date or the whole meeting sends a cancellation for it. + +Invites go through the Brevo API like the other board email (Railway blocks +outbound SMTP), from the chapter's sender (above). They need +`BREVO_API_KEY`; `BOARD_INVITE_FROM` only for a chapter with no sender of +its own. The API can't mark the attachment as a calendar request the way +SMTP could, so some mail apps show `invite.ics` as an attachment to open +instead of Yes / Maybe / No buttons. Opening it adds the event. + +Every email call gives up after 15 seconds. If no invite goes through, the +meeting stays on the Meetings tab marked **Invites not sent**, with +**Resend invites** and **Delete** (deleting it sends nothing). Use "Send me a test invite" in the form to check one before sending to the board. diff --git a/app/api/board/schedule/[id]/route.ts b/app/api/board/schedule/[id]/route.ts index e8c6ac2..f3ef936 100644 --- a/app/api/board/schedule/[id]/route.ts +++ b/app/api/board/schedule/[id]/route.ts @@ -1,11 +1,13 @@ import { bad, guard, idFrom, isDate, notFound, readJson } from "@/lib/board/api"; import { db } from "@/lib/board/db"; import { inviteSetupProblem, sendInvites } from "@/lib/board/invites"; -import { getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server"; +import { NOT_SENT, countSent, getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server"; import { occurrences } from "@/lib/board/schedule"; type Ctx = { params: Promise<{ id: string }> }; +const NOT_CANCELLED = "The cancellations didn't go through. Try again."; + async function load(request: Request, ctx: Ctx) { const user = await guard(request, ["admin"]); if (user instanceof Response) return user; @@ -57,9 +59,13 @@ export async function PATCH(request: Request, ctx: Ctx) { const added = invitees.filter((i) => !had.has(i.email.toLowerCase())); const removed = old.invitees.filter((i) => !has.has(i.email.toLowerCase())); - const a = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "update", recipients: kept, attendees: invitees }); - const b = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "new", recipients: added, attendees: invitees }); - const c = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "cancel", recipients: removed }); + // If no invite ever went through, everyone gets a first invite instead. + const neverSent = old.invitesSent === 0; + const a = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "update", recipients: neverSent ? [] : kept, attendees: invitees }); + const b = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "new", recipients: neverSent ? invitees : added, attendees: invitees }); + const c = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "cancel", recipients: neverSent ? [] : removed }); + await countSent(user.chapter, old.id, a.sent.length + b.sent.length); + if (a.sent.length + b.sent.length === 0) return bad(NOT_SENT, 502); return Response.json({ ok: true, sent: a.sent.length + b.sent.length, @@ -69,9 +75,23 @@ export async function PATCH(request: Request, ctx: Ctx) { }); } +// Resends the invite to everyone on it, unchanged (same UID and SEQUENCE). +// For a meeting whose invites never went through. +export async function POST(request: Request, ctx: Ctx) { + const x = await load(request, ctx); + if (x instanceof Response) return x; + const { user, meeting: m } = x; + const r = await sendInvites({ chapter: user.chapter, meeting: m, kind: "new", recipients: m.invitees }); + await countSent(user.chapter, m.id, r.sent.length); + if (r.sent.length === 0) return bad(NOT_SENT, 502); + return Response.json({ ok: true, sent: r.sent.length, failed: r.failed }); +} + // Cancels a meeting. With ?date=YYYY-MM-DD, cancels just that date of a // weekly series (METHOD:CANCEL with RECURRENCE-ID); otherwise the whole -// meeting, which is then deleted. +// meeting, which is then deleted. Cancellations go out first, and nothing +// changes unless at least one gets through, so a failed cancel can be tried +// again. A meeting whose invites never went out is changed without email. export async function DELETE(request: Request, ctx: Ctx) { const x = await load(request, ctx); if (x instanceof Response) return x; @@ -87,21 +107,23 @@ export async function DELETE(request: Request, ctx: Ctx) { // Nothing would be left: cancel the whole meeting instead. return cancelAll(); } - await sql`UPDATE board_scheduled SET sequence = ${sequence}, cancelled_dates = ${cancelledDates} - WHERE chapter = ${user.chapter} AND id = ${m.id}`; const r = await sendInvites({ chapter: user.chapter, meeting: { ...m, sequence, cancelledDates }, kind: "cancel-one", - recipients: m.invitees, + recipients: m.invitesSent ? m.invitees : [], onlyDate: date, }); + if (m.invitesSent && m.invitees.length && r.sent.length === 0) return bad(NOT_CANCELLED, 502); + await sql`UPDATE board_scheduled SET sequence = ${sequence}, cancelled_dates = ${cancelledDates} + WHERE chapter = ${user.chapter} AND id = ${m.id}`; return Response.json({ ok: true, cancelled: r.sent.length, failed: r.failed }); } return cancelAll(); async function cancelAll() { - const r = await sendInvites({ chapter: user.chapter, meeting: { ...m, sequence }, kind: "cancel", recipients: m.invitees }); + const r = await sendInvites({ chapter: user.chapter, meeting: { ...m, sequence }, kind: "cancel", recipients: m.invitesSent ? m.invitees : [] }); + if (m.invitesSent && m.invitees.length && r.sent.length === 0) return bad(NOT_CANCELLED, 502); await sql`DELETE FROM board_scheduled WHERE chapter = ${user.chapter} AND id = ${m.id}`; return Response.json({ ok: true, cancelled: r.sent.length, failed: r.failed }); } diff --git a/app/api/board/schedule/route.ts b/app/api/board/schedule/route.ts index af6fe96..75afd95 100644 --- a/app/api/board/schedule/route.ts +++ b/app/api/board/schedule/route.ts @@ -2,11 +2,13 @@ import { randomUUID } from "node:crypto"; import { bad, guard, readJson } from "@/lib/board/api"; import { db } from "@/lib/board/db"; import { inviteSetupProblem, sendInvites } from "@/lib/board/invites"; -import { getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server"; +import { NOT_SENT, countSent, getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server"; import { isEmail } from "@/lib/board/submit-rules"; // Schedules a meeting and emails the invites (admins only). With `test` -// set to an address, sends one invite there and saves nothing. +// set to an address, sends one invite there and saves nothing. If no invite +// goes through, the meeting stays saved and the Meetings tab offers +// "Resend invites". export async function POST(request: Request) { const user = await guard(request, ["admin"]); if (user instanceof Response) return user; @@ -21,9 +23,9 @@ export async function POST(request: Request) { if (body.test !== undefined) { const to = typeof body.test === "string" ? body.test.trim() : ""; if (!isEmail(to)) return bad("Add the email to send the test to."); - const test = { ...meeting, id: 0, uid: `test-${randomUUID()}@getvindicated.org`, sequence: 0, invitees: [], audience: "", cancelledDates: [] }; + const test = { ...meeting, id: 0, uid: `test-${randomUUID()}@getvindicated.org`, sequence: 0, invitees: [], audience: "", cancelledDates: [], invitesSent: 0 }; const r = await sendInvites({ chapter: user.chapter, meeting: test, kind: "new", recipients: [{ name: to, email: to }] }); - return r.sent.length ? Response.json({ ok: true, test: to }) : bad("The test invite didn't go through. Check the address and try again.", 502); + return r.sent.length ? Response.json({ ok: true, test: to }) : bad("The test invite didn't go through. Try again.", 502); } const { invitees, skipped, audience } = await resolveInvitees(user.chapter, invite); @@ -46,5 +48,7 @@ export async function POST(request: Request) { })} RETURNING id`; const saved = (await getScheduled(user.chapter, row.id))!; const r = await sendInvites({ chapter: user.chapter, meeting: saved, kind: "new", recipients: invitees }); + await countSent(user.chapter, row.id, r.sent.length); + if (r.sent.length === 0) return bad(NOT_SENT, 502); return Response.json({ ok: true, id: row.id, sent: r.sent.length, failed: r.failed, skipped }, { status: 201 }); } diff --git a/components/board/MeetingsTab.tsx b/components/board/MeetingsTab.tsx index f3fc737..68b782a 100644 --- a/components/board/MeetingsTab.tsx +++ b/components/board/MeetingsTab.tsx @@ -5,7 +5,7 @@ import { useBoard } from "./BoardApp"; import { RowEditor, type FieldDef } from "./RowEditor"; import { ScheduleForm } from "./ScheduleForm"; import { EditLink, TextSetting } from "./Editors"; -import { api, fmt } from "./util"; +import { INVITE_TIMEOUT, api, apiCall, fmt } from "./util"; import { pastDates, repeatText, shortDate, timeRange, upcomingDates, type ScheduledMeeting } from "@/lib/board/schedule"; const meetingFields = (attendance: boolean): FieldDef[] => [ @@ -28,7 +28,9 @@ export function MeetingsTab() { // A past date of a scheduled meeting being logged: "|". const [logging, setLogging] = useState(null); - const upcoming = data.scheduled.filter((m) => upcomingDates(m, today).length > 0); + // Admins also see meetings whose invites never went out, even past ones, + // so they can resend or delete them. + const upcoming = data.scheduled.filter((m) => upcomingDates(m, today).length > 0 || (admin && m.invitesSent === 0)); // Past dates of scheduled meetings with nothing logged yet (most recent first). const logged = new Set(data.meetings.map((m) => m.date)); const toLog = admin @@ -166,6 +168,7 @@ export function MeetingsTab() { function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; onEdit: () => void }) { const { today, refresh } = useBoard(); const dates = upcomingDates(m, today); + const notSent = m.invitesSent === 0; const [date, setDate] = useState(dates[0]); const [confirm, setConfirm] = useState<"one" | "all" | null>(null); const [busy, setBusy] = useState(false); @@ -182,11 +185,29 @@ function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; o else refresh(); } + async function resend() { + setBusy(true); + setError(""); + const { error: err } = await apiCall(`/api/board/schedule/${m.id}`, "POST", undefined, INVITE_TIMEOUT); + setBusy(false); + if (err) setError(err); + else refresh(); + } + return (

{m.title}

- Next: {shortDate(dates[0])} · {timeRange(m.startTime, m.endTime)} + {dates.length ? ( + <> + Next: {shortDate(dates[0])} + + ) : ( + <> + Was: {shortDate(m.date)} + + )}{" "} + · {timeRange(m.startTime, m.endTime)}

{m.weekly &&

{repeatText(m)}

} {m.location && ( @@ -201,16 +222,32 @@ function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; o )}

)} -

- Invited: {m.audience} ({m.invitees.length} {m.invitees.length === 1 ? "invite" : "invites"} sent) -

+ {notSent && admin ? ( + <> +

+ Invites not sent. No one has received an invite for this meeting yet. +

+

+ To invite: {m.audience} +

+ + ) : ( +

+ Invited: {m.audience} ({m.invitees.length} {m.invitees.length === 1 ? "invite" : "invites"} sent) +

+ )} {m.notes &&

{m.notes}

} {admin && (
+ {notSent && dates.length > 0 && ( + + )} - {m.weekly && dates.length > 1 && ( + {!notSent && m.weekly && dates.length > 1 && (