diff --git a/.env.example b/.env.example index 8b0dc32..7859710 100644 --- a/.env.example +++ b/.env.example @@ -22,8 +22,9 @@ BOARD_UCLA_PASSWORD= # 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). The from -# address must be on a domain verified in Brevo (getvindicated.org). +# relay (Brevo → SMTP & API → SMTP: the login and an SMTP key). 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 74ed63e..37cd6a8 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -49,6 +49,18 @@ for one). The recipient lists are in `lib/board/chapter-server.ts`. +## Who board emails come from + +Announcements, calendar invites and submission emails come from the +chapter's sender, set as `from` in `lib/board/chapter-server.ts`: + +- Berkeley: Rana +- UCLA: President Fiona + +Each must be a verified sender in Brevo. A chapter without one (`from: null`) +uses `BOARD_INVITE_FROM` instead. Replies still go to the organizer (or, for +submissions, to the person who submitted). + ## What admins can edit Signed in with a chapter's admin password, everything on that chapter's hub @@ -74,9 +86,8 @@ Posting an announcement can email it to the chapter ("Email this to everyone", on by default): one email per person through the Brevo API, so no one sees anyone else's address. Admins-only announcements go to the members ticked **Hub admin** on the Board tab (at first: Fiona and Will for -UCLA, Rana for Berkeley). Emails come from `BOARD_INVITE_FROM` with reply-to -set to the chapter's organizer, so they need `BREVO_API_KEY` and -`BOARD_INVITE_FROM` (its domain verified in Brevo, as for invites). +UCLA, Rana for Berkeley). Emails come from the chapter's sender (above) +with reply-to set to the chapter's organizer. They need `BREVO_API_KEY`. ## Calendar invites @@ -88,15 +99,15 @@ chapter's organizer (Fiona for UCLA, Rana for Berkeley, set in 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. It needs three Railway -variables: +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`: the address invites come from, e.g. - `board@getvindicated.org`. Its domain, **getvindicated.org**, must be - verified in Brevo (**Senders, Domains & Dedicated IPs → Domains**, then add - the DNS records Brevo shows). + +`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**). Use "Send me a test invite" in the form to check one before sending to the board. diff --git a/app/api/board/announce-email/[id]/route.ts b/app/api/board/announce-email/[id]/route.ts index fe496d2..64f658d 100644 --- a/app/api/board/announce-email/[id]/route.ts +++ b/app/api/board/announce-email/[id]/route.ts @@ -11,10 +11,10 @@ export async function POST(request: Request, ctx: { params: Promise<{ id: string if (user instanceof Response) return user; const id = idFrom((await ctx.params).id); if (!id) return notFound(); - const problem = announceSetupProblem(); + const problem = announceSetupProblem(user.chapter); if (problem) { - console.error(`[board] Announcement emails are off: ${problem}`); - return bad("Announcement emails aren't set up yet. Rana: add BREVO_API_KEY and BOARD_INVITE_FROM in Railway (see BOARD_SETUP.md).", 503); + console.error(`[board] ${problem}`); + return bad(problem, 503); } const body = await readJson(request); const kind = body?.kind; diff --git a/app/api/board/announce-test/route.ts b/app/api/board/announce-test/route.ts index 4b80eb0..ca883c2 100644 --- a/app/api/board/announce-test/route.ts +++ b/app/api/board/announce-test/route.ts @@ -7,7 +7,8 @@ import { isEmail } from "@/lib/board/submit-rules"; export async function POST(request: Request) { const user = await guard(request, ["admin"]); if (user instanceof Response) return user; - if (announceSetupProblem()) return bad("Announcement emails aren't set up yet. Rana: add BREVO_API_KEY and BOARD_INVITE_FROM in Railway (see BOARD_SETUP.md).", 503); + const problem = announceSetupProblem(user.chapter); + if (problem) return bad(problem, 503); const body = await readJson(request); const to = typeof body?.to === "string" ? body.to.trim() : ""; const title = typeof body?.title === "string" ? body.title.trim().slice(0, 300) : ""; diff --git a/app/api/board/schedule/[id]/route.ts b/app/api/board/schedule/[id]/route.ts index 0c4910f..e8c6ac2 100644 --- a/app/api/board/schedule/[id]/route.ts +++ b/app/api/board/schedule/[id]/route.ts @@ -12,7 +12,7 @@ async function load(request: Request, ctx: Ctx) { const id = idFrom((await ctx.params).id); const meeting = id ? await getScheduled(user.chapter, id) : null; if (!meeting) return notFound(); - const problem = inviteSetupProblem(); + const problem = inviteSetupProblem(user.chapter); if (problem) return notReady(problem); return { user, meeting }; } diff --git a/app/api/board/schedule/route.ts b/app/api/board/schedule/route.ts index abca3a4..af6fe96 100644 --- a/app/api/board/schedule/route.ts +++ b/app/api/board/schedule/route.ts @@ -10,7 +10,7 @@ import { isEmail } from "@/lib/board/submit-rules"; export async function POST(request: Request) { const user = await guard(request, ["admin"]); if (user instanceof Response) return user; - const problem = inviteSetupProblem(); + const problem = inviteSetupProblem(user.chapter); if (problem) return notReady(problem); const body = await readJson(request); if (!body) return bad("Check the form."); diff --git a/app/api/board/submit/route.ts b/app/api/board/submit/route.ts index 3f57443..a9140bf 100644 --- a/app/api/board/submit/route.ts +++ b/app/api/board/submit/route.ts @@ -2,7 +2,7 @@ import { sendEmail } from "@/lib/brevo"; import { bad, guard } from "@/lib/board/api"; import { db } from "@/lib/board/db"; import { chapterConfig } from "@/lib/board/chapters"; -import { CHAPTER_SERVER, submissionRecipients } from "@/lib/board/chapter-server"; +import { CHAPTER_SERVER, chapterSender, submissionRecipients } from "@/lib/board/chapter-server"; import { MAX_ATTACHMENT_BYTES, OTHER_TASK, TOO_BIG, hasLink, isEmail } from "@/lib/board/submit-rules"; const esc = (s: string) => @@ -91,7 +91,7 @@ export async function POST(request: Request) { const strip = (p: { email: string; name: string }) => ({ email: p.email, name: p.name }); try { await deliver({ - sender: { email: "getvindicated@outlook.com", name: "VINdicated" }, + sender: chapterSender(chapter, "VINdicated") ?? { email: "getvindicated@outlook.com", name: "VINdicated" }, to: to.map(strip), ...(cc.length ? { cc: cc.map(strip) } : {}), replyTo: { email: replyEmail, name: member.name }, diff --git a/lib/board/announce.ts b/lib/board/announce.ts index e947605..e3d219e 100644 --- a/lib/board/announce.ts +++ b/lib/board/announce.ts @@ -2,15 +2,15 @@ import "server-only"; import { randomUUID } from "node:crypto"; import { sendEmail } from "@/lib/brevo"; import { chapterConfig, type ChapterId } from "./chapters"; -import { CHAPTER_SERVER } from "./chapter-server"; +import { CHAPTER_SERVER, chapterSender } from "./chapter-server"; import { db } from "./db"; import { hubUrl } from "./invites"; import { isEmail } from "./submit-rules"; import type { SendSummary } from "./types"; // Announcement emails: one email per person through the Brevo API, so no -// one sees anyone else's address. From BOARD_INVITE_FROM (a verified -// getvindicated.org address), reply-to the chapter's organizer. +// one sees anyone else's address. From the chapter's sender (or +// BOARD_INVITE_FROM), reply-to the chapter's organizer. export type AnnouncementMail = { title: string; body: string; adminsOnly: boolean }; type Person = { name: string; email: string }; @@ -20,10 +20,14 @@ const esc = (s: string) => export const chapterTag = (chapter: ChapterId) => (chapter === "ucla" ? "[VINdicated UCLA]" : "[VINdicated Berkeley]"); -export function announceSetupProblem(): string | null { +// The Railway variables announcement emails still need, or null. +export function announceSetupProblem(chapter: ChapterId): string | null { if (!process.env.BREVO_API_KEY && process.env.NODE_ENV === "development") return null; - const missing = ["BREVO_API_KEY", "BOARD_INVITE_FROM"].filter((v) => !process.env[v]?.trim()); - return missing.length ? `Missing ${missing.join(", ")}.` : null; + const missing = [ + ...(process.env.BREVO_API_KEY?.trim() ? [] : ["BREVO_API_KEY"]), + ...(chapterSender(chapter, "") ? [] : ["BOARD_INVITE_FROM"]), + ]; + return missing.length ? `Announcement emails aren't set up yet. Rana: add ${missing.join(" and ")} in Railway (see BOARD_SETUP.md).` : null; } // Everyone an announcement emails: all members with an email on file, or @@ -54,12 +58,13 @@ ${a.body ? `

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

` : ""} // Sends to each person separately. Returns who it reached and who it didn't. export async function sendAnnouncement(chapter: ChapterId, a: AnnouncementMail, people: Person[], update: boolean) { const org = CHAPTER_SERVER[chapter].organizer; - const from = process.env.BOARD_INVITE_FROM?.trim() || "board@getvindicated.org"; + const title = chapterConfig(chapter).title; + const sender = chapterSender(chapter, title) ?? { name: title, email: "board@getvindicated.org" }; const results: (Person & { ok: boolean; error: string })[] = []; for (const p of people) { const m = message(chapter, a, update, p.name.split(/\s+/)[0] || p.name); const mail = { - sender: { email: from, name: chapterConfig(chapter).title }, + sender, to: [{ email: p.email, name: p.name }], replyTo: { email: org.email, name: org.name }, subject: m.subject, diff --git a/lib/board/chapter-server.ts b/lib/board/chapter-server.ts index 954c1f1..f073a61 100644 --- a/lib/board/chapter-server.ts +++ b/lib/board/chapter-server.ts @@ -4,12 +4,16 @@ import type { BoardRole } from "./roles"; // `short` is how the submit form names them: "Sent to Fiona and Will". type Person = { email: string; name: string; short: string }; +export type Sender = { name: string; email: string }; // Server-only chapter settings: which Railway variables hold each role's // password, and where deliverable submissions are emailed. Change the // recipients here; the submit route and form read them from this file. export type ChapterServer = { cookie: string; + // Who the chapter's emails come from (announcements, invites, submissions). + // Must be a verified sender in Brevo. Null falls back to BOARD_INVITE_FROM. + from: Sender | null; // Members ticked as Hub admin when first loaded (for admins-only emails). // Admins change this on the Board tab afterwards. hubAdmins: string[]; @@ -30,6 +34,7 @@ export type ChapterServer = { export const CHAPTER_SERVER: Record = { berkeley: { cookie: "board_session", + from: { name: "Rana", email: "president@getvindicated.org" }, hubAdmins: ["Rana Darwich"], organizer: { email: "ranadarwich05@gmail.com", name: "Rana Darwich", short: "Rana" }, passwords: [ @@ -47,6 +52,7 @@ export const CHAPTER_SERVER: Record = { }, ucla: { cookie: "board_session_ucla", + from: { name: "President Fiona", email: "vindicatedbruins@getvindicated.org" }, hubAdmins: ["Fiona Wangsawidjaja", "Will Prawira"], organizer: { email: "fionarhine@ucla.edu", name: "Fiona Wangsawidjaja", short: "Fiona" }, passwords: [ @@ -66,6 +72,17 @@ export const CHAPTER_SERVER: Record = { }, }; +// The chapter's sender, else BOARD_INVITE_FROM ("Name
" or a bare +// address, which gets the chapter's title as its name), else null. +export function chapterSender(chapter: ChapterId, fallbackName: string): Sender | null { + const own = CHAPTER_SERVER[chapter].from; + if (own) return own; + const env = process.env.BOARD_INVITE_FROM?.trim(); + if (!env) return null; + const m = env.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/); + return m ? { name: m[1].trim() || fallbackName, email: m[2].trim() } : { name: fallbackName, email: env }; +} + // Who a submission from someone on `team` goes to. export function submissionRecipients(chapter: ChapterId, team: string) { const s = CHAPTER_SERVER[chapter].submissions; diff --git a/lib/board/invites.ts b/lib/board/invites.ts index 54915b6..6f7856f 100644 --- a/lib/board/invites.ts +++ b/lib/board/invites.ts @@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import nodemailer, { type Transporter } from "nodemailer"; import { chapterConfig, type ChapterId } from "./chapters"; -import { CHAPTER_SERVER } from "./chapter-server"; +import { CHAPTER_SERVER, chapterSender } from "./chapter-server"; import { buildIcs } from "./ics"; import { longDate, monthDay, repeatText, shortDate, timeRange, whenShort, type Invitee, type ScheduledMeeting } from "./schedule"; @@ -13,8 +13,8 @@ import { longDate, monthDay, repeatText, shortDate, timeRange, whenShort, type I // remove the event in place. Other board email still uses the Brevo API. // // Railway variables: BREVO_SMTP_LOGIN and BREVO_SMTP_KEY (Brevo → SMTP & -// API → SMTP), and BOARD_INVITE_FROM, an address on a domain verified in -// Brevo (e.g. board@getvindicated.org). +// API → SMTP). Invites come from the chapter's sender in chapter-server.ts, +// or BOARD_INVITE_FROM for a chapter without one. // // For local testing, BOARD_INVITE_OUTBOX= writes each email there as // an .eml file instead of sending it. @@ -24,10 +24,12 @@ export type InviteKind = "new" | "update" | "cancel" | "cancel-one"; const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); -export function inviteSetupProblem(): string | null { +// The Railway variables invites still need, or null. +export function inviteSetupProblem(chapter: ChapterId): string | null { if (process.env.BOARD_INVITE_OUTBOX) return null; - const missing = ["BREVO_SMTP_LOGIN", "BREVO_SMTP_KEY", "BOARD_INVITE_FROM"].filter((v) => !process.env[v]?.trim()); - return missing.length ? `Missing ${missing.join(", ")}.` : null; + const missing = ["BREVO_SMTP_LOGIN", "BREVO_SMTP_KEY"].filter((v) => !process.env[v]?.trim()); + 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; } let transport: Transporter | null = null; @@ -117,8 +119,8 @@ export async function sendInvites(opts: { hubUrl: hub, onlyDate, }); - const fromAddress = process.env.BOARD_INVITE_FROM?.trim() || "board@getvindicated.org"; - const from = fromAddress.includes("<") ? fromAddress : { name: config.title, address: fromAddress }; + const sender = chapterSender(chapter, config.title) ?? { name: config.title, email: "board@getvindicated.org" }; + const from = { name: sender.name, address: sender.email }; const outbox = process.env.BOARD_INVITE_OUTBOX; const sent: string[] = []; diff --git a/lib/board/schedule-server.ts b/lib/board/schedule-server.ts index 7c9f1e7..147544a 100644 --- a/lib/board/schedule-server.ts +++ b/lib/board/schedule-server.ts @@ -71,6 +71,6 @@ export async function getScheduled(chapter: ChapterId, id: number): Promise { - console.error(`[board] Invites are off: ${problem} See BOARD_SETUP.md.`); - return bad("Invites aren't set up yet. Rana: add BREVO_SMTP_LOGIN, BREVO_SMTP_KEY and BOARD_INVITE_FROM in Railway (see BOARD_SETUP.md).", 503); + console.error(`[board] ${problem}`); + return bad(problem, 503); };