Skip to content

Board invites: send through the Brevo API, 15s timeout on all email - #106

Merged
getvindicated2day merged 1 commit into
mainfrom
board-invites-api
Oct 14, 2026
Merged

getvindicated2day merged 1 commit into
mainfrom
board-invites-api

Conversation

@getvindicated2day

@getvindicated2day getvindicated2day commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

The bug

Scheduling a board meeting on the Meetings tab (both hubs) hung forever. lib/board/invites.ts sent invites with nodemailer over SMTP (smtp-relay.brevo.com:587), Railway blocks outbound SMTP on our plan, and nothing timed out, so the request never returned. The meeting row was saved before the send, so earlier attempts left meetings whose invites never went out.

The fix

Invites go through the Brevo HTTP API. They use POST https://api.brevo.com/v3/smtp/email with BREVO_API_KEY, the same as announcements and submissions. Each email has the HTML body (plus a plain-text version) and the event attached as invite.ics (base64). The .ics comes from the unchanged lib/board/ics.ts, so METHOD:REQUEST / METHOD:CANCEL, UID, SEQUENCE, RRULE, RECURRENCE-ID and VTIMEZONE are exactly as before.

What Brevo allows:

  • .ics is on Brevo's list of allowed attachment extensions, per the API reference text in the Brevo SDK (v5.0.3).
  • An attachment can only have content, name or url. There's no field for its content type, and Brevo sets the type from the file extension. So the API can't mark the attachment text/calendar; method=REQUEST / CANCEL the way nodemailer did. The method is still inside the .ics.
  • Expect some mail apps to show invite.ics as an attachment to open rather than Yes / Maybe / No buttons. BOARD_SETUP.md says so.

SMTP is gone.

  • nodemailer, @types/nodemailer and the Brevo SDK are removed; nothing else used them.
  • inviteSetupProblem now needs only BREVO_API_KEY and a sender (the chapter's from, or BOARD_INVITE_FROM).
  • BREVO_SMTP_LOGIN and BREVO_SMTP_KEY are no longer used and are removed from .env.example and BOARD_SETUP.md.

15-second timeout on every email.

  • lib/brevo.ts now calls the API with fetch and AbortSignal.timeout(15000). That covers invites, announcements, submissions and the site's volunteer and contact forms.
  • A batch (invites, announcement emails) stops at the first timeout, so a hung service ends one request in about 15 seconds instead of 15 seconds per person.
  • The schedule form also stops waiting after 90 seconds as a backstop.
  • The admin sees "The invites didn't go through. Try again." instead of a stuck loader.

Meetings whose invites never went out.

  • A new column, board_scheduled.invites_sent, counts invites that went through. Existing rows start at 0, which matches the meetings saved while sending was broken.
  • Admins see those meetings on the Meetings tab, even if the date has passed, marked Invites not sent, with:
    • Resend invites: same UID and SEQUENCE.
    • Edit and send: everyone gets a first invite.
    • Delete: removes it without emailing anyone.
  • Nothing is deleted automatically.
  • Cancelling a date or a series now sends the cancellations before changing the meeting, so a failed cancel can be retried.

Outbox test mode. BOARD_INVITE_OUTBOX=<folder> writes each API request as .json, with the decoded .ics beside it, instead of sending.

Tested (production build)

Outbox mode, with each .ics parsed by Python icalendar:

Step Response Method SEQUENCE Contents
Test invite 200 in 0.04s REQUEST 0 Weekly RRULE, America/Los_Angeles VTIMEZONE
New weekly meeting (3 people) 201 REQUEST 0 3 emails
Edit (new time) 200 REQUEST 1 "Updated invitation"
Cancel one date 200 CANCEL 2 RECURRENCE-ID for that date; the date is recorded in cancelled_dates
Cancel the series 200 CANCEL 3 Row deleted

The same UID ran through every step, every file has CRLF line endings, and each email has an HTML and a text body.

Brevo hanging (the stand-in never answers):

Request Result
Create meeting 502 "The invites didn't go through. Try again." after 15.1s; one Brevo call; row saved with invites_sent = 0
Test invite 502 after 15.0s
Announcement email (16 people) Ended after 15.0s with all 16 marked failed (Retry shows)
Board submission 502 "The email didn't go through…" after 15.0s

In the browser:

  • Scheduling while Brevo hangs shows "The invites didn't go through. Try again." after 15.5s and stops spinning. The meeting appears as Invites not sent.
  • With Brevo answering again, Resend invites on a stand-in for an old stuck row sent both invites through the API with invite.ics attached. The card now reads "2 invites sent".
  • Delete on another unsent meeting sent nothing.
  • No sideways scrolling at 390px or 1280px.

Lint (no errors; 4 existing warnings), typecheck and build pass.

Deploy notes

  • BREVO_SMTP_LOGIN and BREVO_SMTP_KEY can be deleted from Railway. Invites only need BREVO_API_KEY and the chapter senders.
  • The new column is added automatically on first load.

Generated by Claude Code

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 <noreply@anthropic.com>
@getvindicated2day
getvindicated2day merged commit 7b98c6c into main Oct 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants