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: {}