Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 20 additions & 9 deletions BOARD_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <president@getvindicated.org>
- UCLA: President Fiona <vindicatedbruins@getvindicated.org>

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
Expand All @@ -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

Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions app/api/board/announce-email/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion app/api/board/announce-test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) : "";
Expand Down
2 changes: 1 addition & 1 deletion app/api/board/schedule/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
2 changes: 1 addition & 1 deletion app/api/board/schedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
4 changes: 2 additions & 2 deletions app/api/board/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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 },
Expand Down
21 changes: 13 additions & 8 deletions lib/board/announce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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
Expand Down Expand Up @@ -54,12 +58,13 @@ ${a.body ? `<p>${esc(a.body).replace(/\n/g, "<br>")}</p>` : ""}
// 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,
Expand Down
17 changes: 17 additions & 0 deletions lib/board/chapter-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -30,6 +34,7 @@ export type ChapterServer = {
export const CHAPTER_SERVER: Record<ChapterId, ChapterServer> = {
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: [
Expand All @@ -47,6 +52,7 @@ export const CHAPTER_SERVER: Record<ChapterId, ChapterServer> = {
},
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: [
Expand All @@ -66,6 +72,17 @@ export const CHAPTER_SERVER: Record<ChapterId, ChapterServer> = {
},
};

// The chapter's sender, else BOARD_INVITE_FROM ("Name <address>" 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;
Expand Down
18 changes: 10 additions & 8 deletions lib/board/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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=<folder> writes each email there as
// an .eml file instead of sending it.
Expand All @@ -24,10 +24,12 @@ export type InviteKind = "new" | "update" | "cancel" | "cancel-one";
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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;
Expand Down Expand Up @@ -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[] = [];
Expand Down
4 changes: 2 additions & 2 deletions lib/board/schedule-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,6 @@ export async function getScheduled(chapter: ChapterId, id: number): Promise<Sche
}

export const notReady = (problem: string) => {
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);
};