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