From 3716426a9b2732f7c2039b287bd8146516a649b9 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 15 Nov 2026 18:37:10 +0530 Subject: [PATCH] feat(api): send replies to a school's marketing email to its owner A school's SendLit team is provisioned with the owner's email as Reply-To, next to the school's name as the sender, so replies don't land at the shared sending address. Docs: list the organization key scopes CourseLit needs (teams:manage, teams:keys, usage:read on top of the provisioning ones) and the one-time setup of the school-team organization. --- apps/api/README.md | 13 +++++------ apps/api/src/integration-provisioning.test.ts | 4 +++- apps/api/src/integration-provisioning.ts | 9 ++++++-- apps/api/src/sendlit-sender.test.ts | 16 ++++++++++++++ apps/api/src/sendlit-sender.ts | 22 +++++++++++++++++++ apps/api/src/sendlit-service.ts | 3 ++- docs/wip/plan-limits.md | 18 +++++++++++++++ frontlit/docs/self-hosting/configuration.md | 4 +++- 8 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/sendlit-sender.test.ts create mode 100644 apps/api/src/sendlit-sender.ts diff --git a/apps/api/README.md b/apps/api/README.md index 0dd32d6c7..3977c2d14 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -93,13 +93,12 @@ they do not inherit the Compose values. Set `SENDLIT_SERVER` and `SENDLIT_ORGANIZATION_API_KEY` in `apps/api/.env` explicitly. For local use, set the organization key to the same value as `BOOTSTRAP_TEAM_PROVISIONING_API_KEY` in the repository-root `.env`. The -runtime key has only `organization:read`, `teams:provision`, and `teams:read`; -do not give the delivery setup key to CourseLit runtime processes. -Fresh provisioning returns a team key and works with these scopes. One -recovery path is not covered yet: if CourseLit has lost a previously issued -team key, its adapter calls `/provisioning/teams/:teamId/keys`, which requires -SendLit’s separate `teams:keys` scope. That path will receive `403` until the -runtime role or recovery flow is deliberately updated. +runtime key has `organization:read`, `teams:provision`, `teams:read`, +`teams:manage` (sending limits and sender details), `teams:keys` (recovering a +lost team key) and `usage:read`; do not give the delivery setup key to +CourseLit runtime processes. SendLit images older than the one that grants +these scopes register the key with only the first three, so quota updates +fail with `403` on them. Bootstrap validates the same configured secret against its existing key record on restarts. Keep both values stable while using the local database; diff --git a/apps/api/src/integration-provisioning.test.ts b/apps/api/src/integration-provisioning.test.ts index 95d4e4b5b..31949d696 100644 --- a/apps/api/src/integration-provisioning.test.ts +++ b/apps/api/src/integration-provisioning.test.ts @@ -124,7 +124,7 @@ describe("CourseLit SendLit provisioning", () => { const provisioned: Array<{ name: string; - sender?: { fromName?: string }; + sender?: { fromName?: string; replyTo?: string }; quota?: { monthlyLimit?: number | null }; }> = []; const quotas: Array<{ teamId: string; monthlyLimit: number | null }> = []; @@ -155,6 +155,8 @@ describe("CourseLit SendLit provisioning", () => { expect(processed).toBe(true); // Its email goes out under the school's name, not the shared account's. expect(provisioned[0]?.sender?.fromName).toBe("SendLit School"); + // Replies to its marketing email reach the school's owner. + expect(provisioned[0]?.sender?.replyTo).toBe(world.owner.email); // A new team never starts unlimited (S20): provisioned with the smallest // quota SendLit accepts, then set to 0 before it is marked ready. expect(provisioned[0]?.quota).toEqual({ monthlyLimit: 1 }); diff --git a/apps/api/src/integration-provisioning.ts b/apps/api/src/integration-provisioning.ts index 217241e4c..b120651fa 100644 --- a/apps/api/src/integration-provisioning.ts +++ b/apps/api/src/integration-provisioning.ts @@ -35,6 +35,7 @@ import type { SendSchoolEmailPayload } from "./school-emails.js"; import { sendSystemMail } from "./system-mail.js"; import { deleteShadowUserIfOrphaned } from "./shadow-users.js"; import { enqueuePlanFeatureSync } from "./plan-features.js"; +import { loadSchoolSender } from "./sendlit-sender.js"; import type { AppDb } from "./types.js"; import { decryptIntegrationSecret, @@ -184,8 +185,12 @@ async function provisionOneSendLitSchool( { externalId: payload.externalId, name: payload.name, - // Learners see the school, not the shared sending account, as the sender. - sender: { fromName: payload.name }, + // Learners see the school, not the shared sending account, as the + // sender, and replies reach the school's owner. + sender: await loadSchoolSender(db, { + schoolId: job.schoolId, + name: payload.name, + }), mailingAddress: payload.mailingAddress, // SendLit only accepts a positive quota here; it is set to 0 below // before the team is used, so a new team never starts unlimited (S20). diff --git a/apps/api/src/sendlit-sender.test.ts b/apps/api/src/sendlit-sender.test.ts new file mode 100644 index 000000000..49fae7d79 --- /dev/null +++ b/apps/api/src/sendlit-sender.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test"; +import { schoolSender } from "./sendlit-sender.js"; + +describe("schoolSender", () => { + it("signs with the school's name and sends replies to its owner", () => { + expect(schoolSender("Yoga with Nalini", "owner@example.com")).toEqual({ + fromName: "Yoga with Nalini", + replyTo: "owner@example.com", + }); + }); + + it("leaves reply-to unset when the owner's email is unknown", () => { + expect(schoolSender("School", null)).toEqual({ fromName: "School" }); + expect(schoolSender("School", " ")).toEqual({ fromName: "School" }); + }); +}); diff --git a/apps/api/src/sendlit-sender.ts b/apps/api/src/sendlit-sender.ts new file mode 100644 index 000000000..3189434aa --- /dev/null +++ b/apps/api/src/sendlit-sender.ts @@ -0,0 +1,22 @@ +import { ownerEmail } from "./school-lifecycle.js"; +import type { AppDb } from "./types.js"; + +/** + * How a school's marketing email is signed. It goes out from the shared + * sending address, so the sender name is the school's and replies go to its + * owner rather than to the shared address. + */ +export type SchoolSender = { fromName: string; replyTo?: string }; + +export function schoolSender(name: string, owner: string | null): SchoolSender { + const replyTo = owner?.trim(); + return replyTo ? { fromName: name, replyTo } : { fromName: name }; +} + +/** The sender for a school, with its owner's current email as reply-to. */ +export async function loadSchoolSender( + db: AppDb, + school: { schoolId: string; name: string }, +): Promise { + return schoolSender(school.name, await ownerEmail(db, school.schoolId)); +} diff --git a/apps/api/src/sendlit-service.ts b/apps/api/src/sendlit-service.ts index c31adfa76..8afbd4ff7 100644 --- a/apps/api/src/sendlit-service.ts +++ b/apps/api/src/sendlit-service.ts @@ -4,6 +4,7 @@ import { createPlatformError, uuidv7, } from "@codelitdev/platform"; +import { loadSchoolSender } from "./sendlit-sender.js"; export type Result = | { ok: true; value: T } @@ -201,7 +202,7 @@ export async function ensureSendLitTeam( { externalId: school.publicId, name: school.name, - sender: { fromName: school.name }, + sender: await loadSchoolSender(db, school), }, { config }, ); diff --git a/docs/wip/plan-limits.md b/docs/wip/plan-limits.md index 4fad56382..3d45c6bda 100644 --- a/docs/wip/plan-limits.md +++ b/docs/wip/plan-limits.md @@ -537,6 +537,24 @@ schools' marketing. - **Reply-To:** the reply-to address from the school's mailing settings, if one is set. `main` set no Reply-To on these emails. The rewrite has no reply-by-email for notifications. +- **The marketing sender** (decided 2026-11-15): each school's team sends + through the organization's shared provider, at its address. The team's + grant on that provider carries the school's name as the From name and the + owner's email as Reply-To, set when the team is provisioned + (`sendlit-sender.ts`). A school's name can't be changed after it's + created, so the name never needs re-syncing. When the mailing settings get + their own reply-to address, it should replace the owner's email here too. +- **The school-team organization on SendLit** (set up once per deployment): + - a default provider sending from a subdomain address, such as + `hello@mail.courselit.app`, so one school's spam can't damage the domain + that platform mail comes from; + - `autoGrantDefaultEsp` on, so every new team gets a grant on it; + - `teamEspEnabledByDefault` off, so schools can't switch to their own + provider until that is offered (tracker item 60a); + - CourseLit's organization key (`SENDLIT_ORGANIZATION_API_KEY`) with + `organization:read`, `teams:provision`, `teams:read`, `teams:manage`, + `teams:keys` and `usage:read`. Without `teams:manage`, the plan's quota + can't be written to the team. - **Tracking each school (S14):** this needs another generic SendLit feature, a tag on each message. CourseLit tags every platform email with its school, so bounces and complaints can be counted per school. diff --git a/frontlit/docs/self-hosting/configuration.md b/frontlit/docs/self-hosting/configuration.md index b6edfd315..8bbb6fe37 100644 --- a/frontlit/docs/self-hosting/configuration.md +++ b/frontlit/docs/self-hosting/configuration.md @@ -63,7 +63,9 @@ Email marketing, contacts and segments use the email service for each school. | Variable | Required | Default | Purpose | | --- | --- | --- | --- | | `SENDLIT_SERVER` | Needed for email marketing | none | The address of the email service | -| `SENDLIT_ORGANIZATION_API_KEY` | Needed for email marketing | none | An organization key that lets the worker provision an email team for each new school | +| `SENDLIT_ORGANIZATION_API_KEY` | Needed for email marketing | none | An organization key that lets the worker provision an email team for each new school and set its sending limits. Give it the scopes `organization:read`, `teams:provision`, `teams:read`, `teams:manage`, `teams:keys` and `usage:read`. | + +In the email service, give the organization a default email provider and turn on granting it to new teams automatically. Each school then sends its marketing email through that provider, under the school's name, with replies going to the school's owner. ### Optional integrations