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
13 changes: 6 additions & 7 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/integration-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand Down Expand Up @@ -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 });
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/integration-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/sendlit-sender.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
22 changes: 22 additions & 0 deletions apps/api/src/sendlit-sender.ts
Original file line number Diff line number Diff line change
@@ -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<SchoolSender> {
return schoolSender(school.name, await ownerEmail(db, school.schoolId));
}
3 changes: 2 additions & 1 deletion apps/api/src/sendlit-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createPlatformError,
uuidv7,
} from "@codelitdev/platform";
import { loadSchoolSender } from "./sendlit-sender.js";

export type Result<T, E = PlatformError> =
| { ok: true; value: T }
Expand Down Expand Up @@ -201,7 +202,7 @@ export async function ensureSendLitTeam(
{
externalId: school.publicId,
name: school.name,
sender: { fromName: school.name },
sender: await loadSchoolSender(db, school),
},
{ config },
);
Expand Down
18 changes: 18 additions & 0 deletions docs/wip/plan-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion frontlit/docs/self-hosting/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading