From 20d4110a1de2e9997a1ffcd009ad49302609b87f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:58:21 +0000 Subject: [PATCH] Board invites: SMTP on port 2525 with a calendar part, API as fallback Invites sent through the Brevo API only carried invite.ics as a plain attachment, so Gmail didn't show them as invites (no Yes / Maybe / No). - Invites go out over Brevo SMTP on port 2525 (STARTTLS, BREVO_SMTP_LOGIN / BREVO_SMTP_KEY) with nodemailer's icalEvent: a "text/calendar; method=REQUEST" (or CANCEL) part, and the same .ics attached as invite.ics. Each SMTP attempt gives up after 15 seconds. - If SMTP isn't set up, fails or times out, the invite goes through the Brevo API as before. After one SMTP failure the rest of the batch goes straight to the API. The log says how many went each way. - inviteSetupProblem needs a sender plus SMTP or the API key. - The test invite box warns when the address is the organizer's: Gmail doesn't show RSVP buttons on an invite sent to its own organizer. - BOARD_INVITE_OUTBOX writes each invite as an .eml (the SMTP version) with the .ics next to it. Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 +- BOARD_SETUP.md | 26 ++++-- components/board/ScheduleForm.tsx | 3 + lib/board/invites.ts | 135 +++++++++++++++++++++++------- package.json | 2 + pnpm-lock.yaml | 19 +++++ 6 files changed, 154 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 8c15266..09f7db9 100644 --- a/.env.example +++ b/.env.example @@ -23,8 +23,12 @@ 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 the Brevo API -# (BREVO_API_KEY), like the other board email. Each chapter's +# Calendar invites from the board hubs' Meetings tab go over Brevo SMTP on +# port 2525 (Brevo → SMTP & API → SMTP: the login and an SMTP key), which +# gives Gmail's Yes / Maybe / No. Without these, invites fall back to the +# Brevo API (BREVO_API_KEY) as a plain .ics attachment. 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 75f5b3b..d779640 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -107,25 +107,33 @@ a member shouldn't see; use Hub admins for that. ## Calendar invites Admins schedule meetings on the Meetings tab ("Schedule a meeting"). Each -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, +person invited gets a calendar invite: in Gmail, Apple Mail and Outlook it +shows Yes / Maybe / No and adds the event. 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. +Invites come from the chapter's sender (above) and go out over Brevo's SMTP +relay on port 2525 (Railway blocks 587 and 465). Only SMTP can mark the +invite as a calendar request, which is what shows the RSVP buttons. Full +calendar invites 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). + +Without them, or if SMTP fails or times out, invites go through the Brevo +API (`BREVO_API_KEY`) instead. Those still carry the event, but only as an +`invite.ics` attachment to open: no Yes / Maybe / No. The server log says +which way each batch went. `BOARD_INVITE_FROM` is only needed for a +chapter with no sender of its own. 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. +board. Send it to an address other than the organizer's: Gmail doesn't +show RSVP buttons on an invite sent to its own organizer. ## One-time setup in Railway diff --git a/components/board/ScheduleForm.tsx b/components/board/ScheduleForm.tsx index f656b0a..eed934c 100644 --- a/components/board/ScheduleForm.tsx +++ b/components/board/ScheduleForm.tsx @@ -216,6 +216,9 @@ export function ScheduleForm({ existing, onClose }: { existing?: ScheduledMeetin Send me a test invite + {data.organizerEmail && testTo.trim().toLowerCase() === data.organizerEmail.toLowerCase() && ( +

Gmail won't show RSVP buttons on an invite sent to its own organizer. Use a different address to test.

+ )} {msg && (

diff --git a/lib/board/invites.ts b/lib/board/invites.ts index 51b77d1..c3eb728 100644 --- a/lib/board/invites.ts +++ b/lib/board/invites.ts @@ -1,36 +1,85 @@ import "server-only"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { EmailTimeoutError, sendEmail } from "@/lib/brevo"; +import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer"; +import { EMAIL_TIMEOUT_MS, EmailTimeoutError, sendEmail } from "@/lib/brevo"; import { chapterConfig, type ChapterId } from "./chapters"; import { CHAPTER_SERVER, chapterSender } from "./chapter-server"; import { buildIcs } from "./ics"; import { longDate, monthDay, repeatText, shortDate, timeRange, whenShort, type Invitee, type ScheduledMeeting } from "./schedule"; -// Sends board meeting invites through Brevo's HTTP API, like the other board -// email (Railway blocks outbound SMTP). Each email carries the event as an -// "invite.ics" attachment with METHOD:REQUEST or METHOD:CANCEL, the meeting's -// UID and SEQUENCE, so calendars add, update or remove the same event. The -// API has no field for an attachment's content type; Brevo sets it from the -// .ics extension. +// Sends board meeting invites. Each one carries the event (METHOD:REQUEST or +// METHOD:CANCEL, the meeting's UID and SEQUENCE) so calendars add, update or +// remove the same event. // -// Railway variables: BREVO_API_KEY, and a sender: the chapter's "from" in -// chapter-server.ts, or BOARD_INVITE_FROM for a chapter without one. +// 1. Brevo SMTP on port 2525 (Railway blocks 587 and 465), with nodemailer's +// icalEvent: a "text/calendar; method=REQUEST" (or CANCEL) part, which is +// what makes Gmail show Yes / Maybe / No. The same .ics is also attached. +// 2. If SMTP isn't set up, fails or times out, the Brevo HTTP API, which can +// only attach invite.ics as a file (no calendar part, so no RSVP buttons). +// Each attempt gives up after EMAIL_TIMEOUT_MS. The log says which path +// each invite took. // -// For local testing, BOARD_INVITE_OUTBOX= writes each API request -// there as a .json file (and the .ics next to it) instead of sending it. +// Railway variables: BREVO_SMTP_LOGIN and BREVO_SMTP_KEY (Brevo → SMTP & API +// → SMTP) for full calendar invites; BREVO_API_KEY for the fallback; and a +// sender: the chapter's "from" in chapter-server.ts, or BOARD_INVITE_FROM. +// +// For local testing, BOARD_INVITE_OUTBOX= writes each email there as +// an .eml file (the SMTP version, with the .ics next to it) instead of +// sending it. export type InviteKind = "new" | "update" | "cancel" | "cancel-one"; const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); -// The Railway variables invites still need, or null. +const SMTP_HOST = "smtp-relay.brevo.com"; +const SMTP_PORT = 2525; +const smtpReady = () => !!(process.env.BREVO_SMTP_LOGIN?.trim() && process.env.BREVO_SMTP_KEY?.trim()); + +// The Railway variables invites can't go out without, or null. SMTP alone, +// or the API key alone, is enough to send; full calendar invites need SMTP. export function inviteSetupProblem(chapter: ChapterId): string | null { if (process.env.BOARD_INVITE_OUTBOX) return null; - const missing = process.env.BREVO_API_KEY?.trim() ? [] : ["BREVO_API_KEY"]; + const missing: string[] = []; + if (!smtpReady() && !process.env.BREVO_API_KEY?.trim()) missing.push("BREVO_SMTP_LOGIN and BREVO_SMTP_KEY (or BREVO_API_KEY)"); if (!chapterSender(chapter, "")) missing.push("BOARD_INVITE_FROM"); - return missing.length ? `Invites aren't set up yet. Rana: add ${missing.join(", ").replace(/, ([^,]*)$/, " and $1")} in Railway (see BOARD_SETUP.md).` : null; + return missing.length ? `Invites aren't set up yet. Rana: add ${missing.join(" and ")} in Railway (see BOARD_SETUP.md).` : null; +} + +let transport: Transporter | null = null; +function smtp(): Transporter { + transport ??= nodemailer.createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + secure: false, + requireTLS: true, // STARTTLS + auth: { user: process.env.BREVO_SMTP_LOGIN!.trim(), pass: process.env.BREVO_SMTP_KEY!.trim() }, + connectionTimeout: EMAIL_TIMEOUT_MS, + greetingTimeout: EMAIL_TIMEOUT_MS, + socketTimeout: EMAIL_TIMEOUT_MS, + }); + return transport; +} + +// Sends over SMTP, giving up after EMAIL_TIMEOUT_MS overall. A stuck +// connection is closed so the next batch starts fresh. +async function sendSmtp(msg: SendMailOptions) { + let timer: ReturnType | undefined; + try { + await Promise.race([ + smtp().sendMail(msg), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new EmailTimeoutError()), EMAIL_TIMEOUT_MS); + }), + ]); + } catch (e) { + transport?.close(); + transport = null; + throw e; + } finally { + clearTimeout(timer); + } } const siteUrl = () => (process.env.NEXT_PUBLIC_SITE_URL?.trim() || "https://www.getvindicated.org").replace(/\/$/, ""); @@ -84,8 +133,9 @@ ${paras.map((p) => `

${esc(p).replace(/\n/g, "
")}

`).join("\n")} } // Sends one email per recipient, each with the same .ics (all attendees -// listed). Returns who it reached and who it didn't. If Brevo times out, the -// rest aren't tried (they'd most likely hang too) and count as not reached. +// listed). Returns who it reached and who it didn't. After SMTP fails once, +// the rest of the batch goes straight to the API. If the API times out too, +// the rest aren't tried (they'd most likely hang too) and count as not reached. export async function sendInvites(opts: { chapter: ChapterId; meeting: ScheduledMeeting; @@ -110,31 +160,56 @@ export async function sendInvites(opts: { }); const sender = chapterSender(chapter, config.title) ?? { name: config.title, email: "board@getvindicated.org" }; const outbox = process.env.BOARD_INVITE_OUTBOX; - const attachment = [{ name: "invite.ics", content: Buffer.from(ics, "utf8").toString("base64") }]; + let useSmtp = smtpReady(); + const via = { smtp: 0, api: 0 }; const sent: string[] = []; const failed: string[] = []; for (const [i, r] of recipients.entries()) { const first = r.name.split(/\s+/)[0] || r.name; const c = content(kind, meeting, first, org.short, hub, onlyDate); - const mail = { - sender, - to: [{ name: r.name, email: r.email }], - replyTo: { name: org.name, email: org.email }, + // SMTP: icalEvent adds the "text/calendar; method=…" part Gmail turns + // into an invite, and attaches the same .ics as invite.ics. + const msg: SendMailOptions = { + from: { name: sender.name, address: sender.email }, + to: { name: r.name, address: r.email }, + replyTo: { name: org.name, address: org.email }, subject: c.subject, - htmlContent: c.html, - textContent: c.text, - attachment, + text: c.text, + html: c.html, + icalEvent: { method, filename: "invite.ics", content: ics }, }; try { if (outbox) { + const info = await nodemailer.createTransport({ streamTransport: true, buffer: true, newline: "windows" }).sendMail(msg); mkdirSync(outbox, { recursive: true }); const base = join(outbox, `${Date.now()}-${i}-${kind}-${r.email}`); - writeFileSync(`${base}.json`, JSON.stringify(mail, null, 2)); + writeFileSync(`${base}.eml`, info.message as Buffer); writeFileSync(`${base}.ics`, ics); - } else { - await sendEmail(mail); + sent.push(r.email); + continue; + } + if (useSmtp) { + try { + await sendSmtp(msg); + via.smtp++; + sent.push(r.email); + continue; + } catch (e) { + useSmtp = false; + console.warn(`[board] Invite to ${r.email} over SMTP failed (${e instanceof Error ? e.message : e}); sending through the Brevo API instead.`); + } } + await sendEmail({ + sender, + to: [{ name: r.name, email: r.email }], + replyTo: { name: org.name, email: org.email }, + subject: c.subject, + htmlContent: c.html, + textContent: c.text, + attachment: [{ name: "invite.ics", content: Buffer.from(ics, "utf8").toString("base64") }], + }); + via.api++; sent.push(r.email); } catch (e) { console.error(`[board] Invite to ${r.email} failed:`, e instanceof Error ? e.message : e); @@ -145,6 +220,10 @@ export async function sendInvites(opts: { } } } - console.log(`[board] ${kind} invite "${meeting.title}" (${chapter}): ${sent.length} sent, ${failed.length} failed.`); + console.log( + `[board] ${kind} invite "${meeting.title}" (${chapter}): ${sent.length} sent` + + (outbox ? " to the outbox" : ` (${via.smtp} over SMTP, ${via.api} through the Brevo API)`) + + `, ${failed.length} failed.`, + ); return { sent, failed }; } diff --git a/package.json b/package.json index 133c4b8..e35c8f5 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "next": "16.2.2", + "nodemailer": "^6.10.1", "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4" @@ -21,6 +22,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^22.19.17", + "@types/nodemailer": "^6.4.24", "@types/react": "^19", "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8faff61..ae8a724 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: next: specifier: '>=16.2.6' version: 16.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nodemailer: + specifier: ^6.10.1 + version: 6.10.1 postgres: specifier: ^3.4.9 version: 3.4.9 @@ -37,6 +40,9 @@ importers: '@types/node': specifier: ^22.19.17 version: 22.19.17 + '@types/nodemailer': + specifier: ^6.4.24 + version: 6.4.24 '@types/react': specifier: ^19 version: 19.2.14 @@ -440,6 +446,9 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/nodemailer@6.4.24': + resolution: {integrity: sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1474,6 +1483,10 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + nodemailer@6.10.1: + resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + engines: {node: '>=6.0.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2189,6 +2202,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@6.4.24': + dependencies: + '@types/node': 22.19.17 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -3390,6 +3407,8 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + nodemailer@6.10.1: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {}