Skip to content

Communications

Role-gated email blasts, sent on behalf of the sender through the school's verified Resend identity: mailing groups (shared catalog + private), a compose endpoint that resolves its audience through a single contactable-set classifier, a queued sweeper that does the actual sending, and a per-recipient delivery ledger fed by the Resend webhook. The archive is own-only for every role, admin included.

Spec: docs/superpowers/specs/2026-08-05-communications-module-design.md; RBAC split: 2026-08-11-communications-mailing-groups-rbac-split-design.md; CC audience: 2026-08-05-communications-cc-iteration-2-design.md; recipient inbox: 2026-08-05-communications-inbox-iteration-3-design.md. Binding reference: docs/guida-notifiche-email-resend.pdf — the on-behalf-of From, backend-derived Reply-To, one message per recipient, and webhook per-recipient status all come from there. Distinct from chapter 23: notifications are the platform telling users something; communications are people emailing people.


1. The on-behalf-of strategy (guide-binding)

One verified sending domain, every sender's identity dressed onto it:

  • From: "<Sender Name> — <School Name>" <MAIL_FROM address>. The address is always ours (SPF/DKIM stay valid); only the display name changes. Composed in the sweeper, rendered by the transport (fromDisplayName on MailPayload).
  • Reply-To is backend-derived, never client-chosen:
  • teacher / staff / referent tier: forced to the sender's own profile email (Teacher/Staff institutionalEmail, Referent email; User.email fallback). A payload replyTo from these tiers is tolerated when it echoes the caller's own address (profile or login email, case-insensitive) — the stored value is still the derived one; anything else → 422 COMMUNICATION_REPLY_TO_INVALID.
  • admin tier: absent → own User.email; present → must be in the vetted set (every teacher+staff institutional email ∪ own login email, case-insensitive) else 422.
  • One message per recipient — no To-lists, no Cc: headers. Replies and delivery events stay per-person. "CC" in this module means one flagged copy (iteration 2): a CC'd person gets the identical individual email through the same pipeline, and their ledger row carries kind: CC — recipients never see who was in copy (the archive does).
  • Permission-filtered people are counted, never named: filteredOut is an integer on the 202 response; rejected refs are never enumerated (sole exception: private-group member validation, where the caller supplied the refs themselves).

2. The contactable-set classifier (single authority)

src/communications/recipient-policy.ts (+ .queries.ts) is the ONLY place tier rules live. The picker (GET /communications/recipients), the POST filter, and private-group member validation all call it — never re-derive reach anywhere else.

Tier May contact
ADMIN everyone, every person type
TEACHER taught students (four-leg dated set) ∪ their referents ∪ all teachers ∪ management/admin holders
STAFF all teachers ∪ all staff ∪ management/admin holders — no students, no referents
REFERENT teachers teaching their linked children (inverse four-leg) ∪ coverage-matching management holders ∪ admins

Tier resolution reads the session's narrowed ctx.roles (view semantics): profile keys win directly; a management view resolves through the holder's assignment anchor, then their linked profile — never to ADMIN. Dated legs (taught set, placements, selections) resolve on schoolToday — every entry point derives a SenderContext via withSchoolToday. Person refs are (personType ∈ {TEACHER, STAFF, REFERENT, STUDENT, USER}, personId); USER covers bare-account management/admin holders.

The picker additionally accepts repeatable departmentId, gradeId, and homeroomId UUID filters. Values within one dimension OR (IN); the three dimensions AND, together with personType, search, and the tier predicate. Search uses the shared person-search rule: whitespace-separated terms compose in AND, while first name, last name, and type-specific email compose in OR per term; matching is case-insensitive and blank input adds no filter. Students and linked referents reuse studentCohortDisplayWhere from students.queries.ts (placement-display + effective-homeroom); teachers use department membership plus effective subject-group teaching and led homerooms. STAFF and USER return an empty page while any cohort filter is active because neither type has a modeled cohort relation. This is picker-only behavior: send-time partitionContactable receives no cohort filters and remains the authority against stale or forged selections.

Picker rows always carry homeroomNames: string[], deduplicated from relationships effective on schoolToday ([] where none apply). The enrichment is page-sized and separate from send-time resolution, so communication validation does not load classroom relations. For teacher-tier callers, a referent's names are restricted to linked students inside the caller's taught set.

Evaluation strategy (2026-08-31 performance iteration)

The tier semantics live in the shared WHERE builders — studentRelatedToTeacherOn (src/students/students.policy.ts) and teachersTeachingStudentsOn (recipient-policy.queries.ts) — but the classifier no longer correlates them per candidate row. Per request it:

  1. Consults a static support matrix first (TIER_CONTACTABLE_TYPES): an unsupported tier × personType combination answers empty before any query.
  2. Fetches only the prefetches the requested person types consume (management holders, referent linked-student scope, ACTIVE-year id) — a teacher browsing students never pays the holder scan.
  3. Projects each relationship graph at most ONCE onto ids (findTaughtStudentIds / findTeacherIdsTeachingStudents), then filters page, count, the referent link witness, homeroom-name enrichment, and send-time partition with flat id IN lists. At teacher tier the referent tier fence and the cohort filter ride one students.some witness.
  4. Fences the AY-snapshotted candidate tables (Teacher/Staff/Student) with the tenant's ACTIVE year when one exists; a tenant with no ACTIVE year stays unscoped (soft — deliberately not resolveActiveYear, whose 409 would break the surface mid-setup). Referent and User are not year-snapshotted and never take the fence.

Rule for future editors: change reach semantics ONLY in the WHERE builders. The projection functions pass the builders' trees verbatim (recipient-policy.spec.ts pins this, plus per-scenario delegate-call ceilings); adding a leg to a projection instead of the builder silently forks the picker from /students visibility. The reverse-lookup indexes on the activity/break audience selector columns (migration 20260831120000) exist for these evaluations and the record-access policies — the raw COALESCE selector-key uniques cannot serve them. A manual profile harness lives in test/communications-performance.e2e-spec.ts (COMMS_PROFILE=1).

Participation (2026-09-03). The contactable set is fenced live in the WHERE builders: a STUDENT candidate must be status = ENROLLED (participatingStudentWhere), and a REFERENT candidate's single link witness now always exists and requires at least one linked ENROLLED student in the tenant — also when no cohort filter is set (the ADMIN arm used to be an empty limb). partitionContactable rides the same WHEREs, so a mailing group that still holds a departed student, or their parent, drops them at send as non-contactable; the sweeper is untouched (frozen snapshots). Pre-enrolled families are reached once the student is flipped to ENROLLED (user decision, spec §8). Design: 2026-09-03 spec.

3. Mailing groups

src/communications/mailing-groups/. Groups store person references, not addresses — the address is resolved live at send time.

  • Gate: the five mailing-group routes require the gate-only communications.mailing_groups scope. READ browses and uses the catalog; WRITE creates and manages the caller's private groups. This scope is independent from communications.send: a role may send without groups (the referent preset), or hold groups without send. The key must stay mailing_groups — it shipped as groups on 2026-08-11 and broke GET /communications/:id within the day, because CommunicationDetailDto has a top-level groups key and every registered scope name is a forbidden top-level key on an @AggregateResponse() route (ch04 "Gate-only scopes" step 3b). Cleanup for databases seeded with the old key: tools/drop-stale-communications-groups-scope.sql.
  • Shared (ownerUserId NULL): school-wide catalog, visible to every caller holding communications.mailing_groups READ; create/edit/delete additionally requires the communications.management WRITE grant, checked service-side against compiled permissions (the gate depends on the row, so no route decorator). Membership unrestricted.
  • Private: owner-only; every member must clear the classifier (422 MAILING_GROUP_MEMBER_NOT_CONTACTABLE with data.rejected — a passthrough collection whose leaves are rendered at the throw site, {personType, personId, messages} per member, timetable violation-cards precedent) — a private group is a saved picker selection, never a reach widener.
  • Visibility is the query (shared ∪ own); a foreign private id is a hidden 404, admin included. Names are unique per namespace (two partial uniques, raw SQL — ch12 table). members[] on PATCH is replace semantics. Deleting a group never touches sent communications (nameSnapshot on the join).

4. POST /communications — resolution order

CommunicationsService.create, ten steps, validate-everything-then-persist:

  1. tier + schoolToday; 2. if any groupIds or ccGroupIds were supplied, require communications.mailing_groups READ before resolving them, then require every id to be shared-or-own; either failure is the same hidden 404 on the first id, so existence never leaks (the permission lookup is skipped when no group ids are supplied; a group named in both lists feeds recipients only); 3. two raw audiences — To = groupIds members ∪ inline recipients[], CC = ccGroupIds members ∪ inline cc[] minus every To ref (To-wins dedupe, applied BEFORE the classifier); 4. ONE partitionContactable pass over the disjoint To ∪ CC union → allowed + a single merged filteredOut count; kind is attributed afterwards by raw-set membership; 5. split sendable (has email) vs SKIPPED_NO_EMAIL (kept per kind); zero sendable RECIPIENTS → 422, nothing persisted — a communication that is only CCs is a copy of nothing, even when the CC people are sendable; 6. Reply-To derivation (§1); 7. template gate: alias must be published for the school language (assertSendable); absent alias allowed only off-Resend (html fallback); 8. attachments total ≤ COMMUNICATION_ATTACHMENT_MAX_TOTAL_BYTES; 9. persist — attachment File rows first (keyed on a pre-minted communication id, so the sweeper never sees a QUEUED row whose attachments are still landing), then one tx: aggregate insert (status QUEUED, snapshots: senderDisplayName, replyTo, per-recipient email+displayName+firstName/lastName+kind, group nameSnapshot+kind) + audit (communication / communication.sent, data incl. ccCount); on tx failure the file rows are compensation-deleted; 10. → 202 { id, recipientCount, ccCount, skippedNoEmail, filteredOut } (recipientCount = sendable To; skippedNoEmail/filteredOut are merged totals).

Attachment formats (2026-08-07): the upload path (FilesService.uploadBlob, usage: ATTACHMENT) validates by filename extension — pdf, jpg/jpeg, png, txt, csv, doc/docx, xls/xlsx, ppt/pptx — because the browser-sent Content-Type is unreliable for Office/text files (a .docx can arrive as application/octet-stream, a .csv as application/vnd.ms-excel). The stored mimeType is normalized to the extension's canonical MIME (that is what signed URLs and the outbound email carry); an unlisted or missing extension is a 400 UNSUPPORTED_FILE_TYPE. Every other FileUsage (identity docs, justifications, logo) keeps the strict PDF/JPEG/PNG MIME whitelist.

202 means queued — delivery is the sweeper's job. The sweeper is kind-agnostic: CC rows are ordinary PENDING rows to it; the whole CC feature adds zero sender/transport code.

5. The sweeper

src/communications/sender/communications-sender.service.ts — the attendance-notifier scheduled-job template (per-tenant withTenantGuc, CAS claims, re-entrancy flag, dynamic SchedulerRegistry interval, 0 = off under test).

  • Claim: oldest QUEUED, or SENDING whose claimedAt is older than COMMUNICATIONS_SENDING_STALE_MS (crash reclaim). CAS updateMany re-checks the captured prior state; count === 1 wins.
  • Drain: one MailPayload per PENDING recipient, always html mode (subject + bodyHtml). Templated communication → the published shell is resolved once per sweep pass per alias (ResendTemplatesService.getTemplateByAlias, memoised in a per-sweep() map) and rendered server-side by sender/communication-template-render.ts: {{{body}}}/{{{senderName}}}/{{{schoolName}}} filled, any other placeholder → the template's fallbackValue, else "" — same grammar as the FE preview. templateAlias null → body + localized footer. Shell fetch failure → transient: template_fetch_failed logged, nothing sent, the communication stays SENDING and the stale window reclaims it. Shell missing/unpublished (or html null) → template_missing warn + the footer body (mail is not held hostage by a dashboard edit). Always fromDisplayName, replyTo, and the communication_recipient_id tag.
  • No attachmentssendEmailBatch in chunks of 100 (Resend batch limit). A whole-chunk failure is transient: the chunk stays PENDING, the drain stops, retry after the stale window.
  • Attachments → per-recipient single sends (the batch endpoint rejects attachments), ~600 ms apart; a per-recipient MailerSendError flips that row FAILED and the drain continues. Blob bytes come from FileStoragePort.get and ride base64.
  • Send-then-mark (at-least-once): rows flip SENT (+resendEmailId) after the provider accepts; completeIfDrained flips the communication SENT+sentAt exactly once (the SENDING predicate) when nothing is PENDING.

6. Delivery ledger + webhook

CommunicationRecipient is the per-recipient ledger: PENDING → SENT → DELIVERED/BOUNCED/COMPLAINED/FAILED, plus queue-time SKIPPED_NO_EMAIL. Every row carries kind: TO | CC (CommunicationRecipientKind, also on the CommunicationMailingGroup snapshot) — a response-only enum, deliberately absent from VALUE_LABELS (FE owns the badge copy). Alongside the displayName snapshot the row snapshots bare firstName/lastName (nullable — null on rows sent before 2026-08-07 and for name-less referents; readers fall back to displayName). The row also carries readAt — the recipient-side read marker (§7), never shown to the sender. The row id travels as the communication_recipient_id provider tag; the Resend webhook brings it back, kind-blind.

7. The recipient inbox

src/communications/inbox/ — the received half of the split surface (GET /communications stays the sent archive). Auth-only, self-scoped (JwtAuthGuard, no permission gate — the notification-center precedent): receivers include students and referents who never hold communications.send.

  • Caller resolution: the inbox is keyed on the caller's person refs — their Teacher/Staff/Referent/Student rows by userId plus the bare USER:<userId> ref — resolved fresh per request and deliberately session-view-agnostic: receipts are personal mail, not role-scoped authority, so a teacher-view session still sees mail addressed to its holder's referent profile.
  • Visibility = status != PENDING: one predicate that admits SENT/DELIVERED/… AND the terminal SKIPPED_NO_EMAIL/FAILED rows — the in-app leg delivers even where the email leg couldn't (a person with no address still finds the message here).
  • Privacy-reduced projection: items are keyed on the recipient-row id; the detail shows subject, body, sender display name, Reply-To, own kind, and signed attachment URLs — never the ledger, the counts, or the other recipients. Misses (unknown id, foreign row, still-PENDING row) are one hidden 404 COMMUNICATION_INBOX_ITEM_NOT_FOUND.
  • Read state: readAt, set once by an idempotent updateMany predicate (readAt: null); unread-count is the FE badge's poll target; read-all flips the whole visible unread set. readAt is the recipient's private state — no sender-side "seen" indicator exists on purpose.

ResendReportMapper dispatches on tags: communication_recipient_idkind: 'communication' (delivered→DELIVERED, bounced→BOUNCED, failed→FAILED, complained→COMPLAINED); otherwise the invitation branch, unchanged, stamped kind: 'invitation'. Both subscribers early-return on foreign kinds. CommunicationsDeliveryService applies monotonic transitions (DELIVERED only from SENT; negatives overwrite SENT/DELIVERED; nothing overwrites a negative), Svix-replay-safe via processed_webhook_events, on the sanctioned AdminPrismaService bypass (tenant unknown until the row is read).

8. Templates

Convention: alias communication-<kind>-<lang> (first kind: communication-general-{en,it}), published in the Resend dashboard — publishing IS the deploy step; the list endpoint crawls the published catalog, so a new template appears without a release. Variables contract (authored as {{{key}}}): body, senderName, schoolName, optional schoolLogoUrl. ResendTemplatesService owns the read side (list for the school language, detail for FE preview, assertSendable for POST, getTemplateByAlias for the sweeper); it builds its own Resend client only when MAIL_TRANSPORT=resend — elsewhere the list is empty and composing goes template-less (html fallback + footer).

Rendered locally, not by Resend (2026-09-02). Resend caps every string template variable at 2,000 characters, and body is user-composed HTML that routinely exceeds it — so the sweeper never sends template mode. The template is still authored and published on Resend and still gates POST, but at drain the shell's html is fetched and rendered here (sender/communication-template-render.ts, COMMUNICATION_TEMPLATE_VARIABLES = the three supplied keys; every other placeholder resolves through the template's fallbackValue). Observable consequences: no body length ceiling beyond the 1 MB request limit; the Resend dashboard no longer attributes these sends to a template; the template's own subject is ignored (the explicit subject always was the one sent). Invitations, notifications and attendance mail keep template mode — their variables are short by construction.

9. API surface

There are three route tiers. Compose, recipient picker, templates, and the own archive use @ProtectedResource() + @RequireAction(EntityKey.COMMUNICATIONS, 'send') (teacher, referent, and every management preset; students get nothing). The five mailing-group routes use the singular @RequireScope(EntityKey.COMMUNICATIONS, 'mailing_groups', ...): READ for list/detail and WRITE for create/update/delete, with communications.management WRITE layered on in-service for shared rows. The inbox is auth-only (§7). Everything is a flat DTO (FLAT_DTO_ENTITIES).

Management sessions carry communications.mailing_groups: WRITE plus the communications.send action. Migration 20260826121000_backfill_management_communications_grants reconciles that baseline into the global presets and every existing tenant clone; ordinary seed runs do not propagate additions to editable management clones. This grant only opens the routes: the contactable-set classifier in §2 still resolves the non-admin holder's Teacher/Staff profile tier, so a non-admin management role does not gain admin recipient reach. The admin preset retains its existing administrator tier; communications.management stays admin-only and students stay ungranted.

Method + path Purpose
GET/POST /mailing-groups, GET/PATCH/DELETE /mailing-groups/:id communications.mailing_groups scope: READ for list/detail, WRITE for create/patch/delete; list is bare {data}, detail resolves members (displayName + email) (§3)
GET /communications/templates, GET …/templates/:alias published template list (school language, server-side) + preview detail
GET /communications/recipients the compose picker: one personType per page, classifier-filtered, {data, meta}; repeatable departmentId/gradeId/homeroomId values union within a dimension and intersect across dimensions (§2); rows carry displayName, bare firstName/lastName (null only for name-less referents), and homeroomNames[], ordered lastName, firstName
POST /communications multipart send (§4): fields + JSON-encoded groupIds/recipients/ccGroupIds/cc parts + attachments files → 202
GET /communications, GET /communications/:id own archive onlysenderUserId = caller in every WHERE; foreign id → hidden 404. Detail = composition + groups (each with kind) + signed attachment URLs + the recipient ledger (kind per row); recipientCounts adds a cc tally next to the status tallies
GET /communications/inbox, GET …/inbox/unread-count, GET …/inbox/:id, PATCH …/inbox/:id/read, POST …/inbox/read-all auth-only (§7): the caller's received communications, keyed on recipient-row ids; privacy-reduced detail; idempotent read state

10. Env

Var Default Meaning
COMMUNICATION_ATTACHMENT_MAX_TOTAL_BYTES 26214400 (25 MB) raw-bytes budget per send (base64 ~4/3 under Resend's 40 MB message cap; hard ceiling 30 MB)
COMMUNICATIONS_SWEEP_INTERVAL_MS 30000, 0 under test sweeper tick; 0 = timer off, sweep() stays invocable
COMMUNICATIONS_SENDING_STALE_MS 600000 SENDING-claim age before another sweep may reclaim

11. Ops checklist (guide phases 2–3)

Deliverability hardening, when ready — sequential, each step observed before the next:

  1. Publish DMARC p=none with an rua= reporting address; monitor 1–2 weeks.
  2. Raise to p=quarantine, then p=reject once reports are clean.
  3. Optional: BIMI + VMC for logo-in-inbox.

Plus the standing deploy-external step: publish communication-general-{en,it} (and any new kind) in Resend before senders compose with them.