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.

Canonical sources are src/communications/communications.service.ts, recipient-policy.ts, the sender/ and inbox/ services, and communications-notifications.service.ts. Notifications covers platform alerts; this chapter covers user-composed communications.


1. Sender identity

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 / student tier: forced to the sender's own profile email (Teacher/Staff institutionalEmail, Referent email, current enrolled Student schoolEmail; 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: 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 single audience authority. The picker (GET /communications/recipients), POST filtering, and private-group member validation all call it. The table below describes the profile baseline; active management roles can add People read reach as described below.

Profile tier Baseline audience
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
STUDENT own course teachers (including combined lessons and lesson assistance) ∪ own homeroom teachers ∪ Director/Admin ∪ department heads and curriculum coordinators covering the student's current placement/selection

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.

Management reach. For Teacher- and Staff-backed management callers, audience is existing recipient eligibility intersected with profile baseline OR qualified management People read reach. resolveAuthorizedRoleSlice qualifies each target entity's identity READ/WRITE independently, using only current assignments in the active view and rebuilding their parameters. Compose/group route gates remain separate: the role supplying People access need not itself grant send. A Teacher-only view keeps its teaching audience; a Head view can add all managed departments. SenderTier and Reply-To remain profile-derived. Admin/SEN, Student/Referent senders and bare USER rules retain their existing contracts.

Management Students, Teachers and Staff reuse their owning read policies. Director/Communications Manager and school-office roles add their school-wide People reach; coordinators and custom roles follow their actual qualified policies. Staff remains tenant-flat where its policy grants access. Department Head families are narrower than the People referent directory: the Head must independently qualify both Students and Referents identity, and one linked child must belong to that Head's taught/managed Students read set. A separate Director contribution can still admit other parents. Other management referents reuse referentsReadWhereWithStudentWitness from the Referents owner. Every child-based limb combines reach, ENROLLED status and picker cohort constraints on the same child. Managed-student witnesses/labels use the resolved ACTIVE year when present; profile reach uses its own unscoped-year relationship context.

Parent admission and child-label visibility are independent. Labels use profile student reach OR qualified management Students read reach, so managed children appear and unauthorized siblings do not. A parent-only grant can yield empty child labels. Stored group membership and picker selection confer no permanent authority: each send re-evaluates current assignments/grants. Existing queued/sent snapshots remain frozen. .

Student sending. Resolve the authenticated student by tenant, user, ACTIVE academic year and ENROLLED status. No matching student/year means no contactable recipients; historical student snapshots do not supply reach. Use effective course/homeroom membership, placement and curriculum selection on the captured schoolToday. Combined-course teachers and lesson assistants qualify; activity or lunch/break supervision alone does not. Heads/coordinators require matching parameters on their own current role assignment. Director (principal) and Admin are available school-wide. The student management allowlist is exactly principal, department_head, curriculum_coordinator, admin; other management roles do not qualify on their own. Missing placement/selection does not remove Director/Admin from an otherwise eligible student's audience. The same rule covers picker, direct references and CC, across Teacher/Staff/User representations. Students and referents are never recipients of a student send. Out-of-reach references are counted in filteredOut, and an entirely unsendable audience retains the existing 422 refusal.

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. Referent labels include only children in the caller's profile or qualified management student reach.

Picker rows also carry linkedStudentNames: string[]: sorted, deduplicated full names on REFERENT candidates, [] on every other person type or when no linked student qualifies. Names come from the same page-sized enrichment query and the same tenant/student-authority fence as homeroomNames. StudentReferentLink is not effective-dated: current enrollment is established by ENROLLED status plus a placement effective on the captured schoolToday. Departed, pre-enrolled, and future-only children are excluded; a current child without a homeroom is included. Cohort filters choose candidate rows and do not trim their other authorized children's names. The response shape is defined in src/communications/dto/recipient-candidate.dto.ts.

Relationship ownership and query composition

Profile relationships come from Student-owned relatedStudentIds and the inverse findTeacherIdsTeachingStudents query. The student projection loads the caller's Teacher facts before selecting related students; consumers receive membership IDs, not a nested foreign policy graph.

Management reach uses independently qualified owning People policies. Head-family admission projects its qualified students once, and page/count/send predicates use that membership on the same child that proves participation and cohort constraints. Label authority is independent of parent admission: it queries Students with their own read authority and selects links only to the current page's referents.

PolicyEvaluation.forYear shares request-local relationship work while preserving separate unscoped profile and active-year management contexts. A broad Students grant can avoid a redundant profile projection for Student-only requests. Referent requests retain the profile witness because its year semantics differ. Never reuse an ID set under a different tenant, actor, date, year or grant context without its owner's explicit contract.

Only needed person-type prefetches run. Page label enrichment is absent from send-time classification. Teacher/Staff/Student candidate rows use the ACTIVE year when one exists; absence of an ACTIVE year leaves those candidate reads unscoped for non-student senders. Student senders always require a current enrolled identity.

For performance investigation, test/policy-query-performance.e2e-spec.ts contains an opt-in recipient role/profile/view matrix. Its measurements must retain expected audience assertions and representative statistics. Do not change year or same-child semantics merely to shorten a query.

Participation and teaching relationships

Student candidates require ENROLLED. Every referent candidate, including one reached by admin, requires an enrolled linked child; relationship authority and cohort filters apply to the same child. partitionContactable uses these predicates at composition, so a saved group cannot keep a departed student or ineligible family contact sendable. Queued recipient snapshots remain frozen.

The taught set includes date-bound lesson assistance. An assistance relationship lasts through the governing version and course window rather than only the shift weekday. Combined-course relationships deduplicate students shared by several contributing courses. Relationship-impact previews do not themselves grant recipient authority.

SEN Manager uses an independent school-wide recipient flag. Sender identity and Reply-To remain tied to the holder's Teacher/Staff profile; tenant, candidate-year, participation, inbox/archive ownership and group-management fences still apply.

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 scope key must not collide with an aggregate response key: mailing_groups gates access while groups is a field of CommunicationDetailDto. See Permissions.
  • 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. Resolve sender tier and capture schoolToday.
  2. If groups are supplied, require communications.mailing_groups READ before resolving shared-or-own IDs. Unauthorized and missing groups both produce a hidden 404.
  3. Expand To groups/references and CC groups/references. To wins every duplicate person reference or group.
  4. Run partitionContactable once over the combined audience, then retain each accepted reference's To/CC kind. Return only the rejected count.
  5. Split recipients with an address from SKIPPED_NO_EMAIL rows. At least one sendable To recipient is required; CC alone fails with 422 before persistence.
  6. Derive and validate Reply-To.
  7. Require a published school-language template alias on Resend. A template-less body is allowed on other transports.
  8. Enforce the total attachment byte budget and upload format limits.
  9. Store attachment File rows under a pre-minted communication ID, then insert the queued aggregate, recipient/group snapshots and communication.sent audit in the transaction. Failed aggregate persistence triggers attachment cleanup. Snapshot accepted active account IDs for receipt notifications and register the post-commit callback.
  10. Return 202 { id, recipientCount, ccCount, skippedNoEmail, filteredOut }. Recipient counts are sendable To/CC counts; skipped and filtered counts combine both audiences.

Attachment formats: 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. Identity documents, justifications and logos use the PDF/JPEG/PNG MIME whitelist. ASSIGNMENT_ATTACHMENT has its own extension map and size budget; see Assignments.

202 means queued — delivery is the sweeper's job. The sweeper is kind-agnostic: CC rows are ordinary PENDING rows to it.

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 fetched 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 attachments → sendEmailBatch 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 when no name snapshot is available; 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): receiving never requires communications.send.

  • Caller resolution: findCallerPersonRefs looks up one matching row per person type with findFirst under tenant/user ownership, and includes USER:<userId>. It does not select every annual snapshot or choose an academic year. Inbox access is session-view-agnostic: switching between an employee profile and its management views does not change the ownership predicate.
  • 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.
  • Filters: sentFrom / sentTo (YYYY-MM-DD, inclusive, UTC days) and repeatable senderUserId narrow the list inside the fence; they AND with each other and with unread, values of senderUserId match any. The day window is built by sentDayWindow (src/communications/sent-day-window.ts), shared with the archive, so both lists agree on what a day is; a communication still SENDING has no sentAt and is left out while a date bound is set. meta.total counts the filtered set. A sender who never wrote to the caller yields an empty page. Items carry senderUserId, the value to pass back as the filter. There is no recipient filter here: the caller is always a recipient, and filtering by other recipients would reveal who else received a message.
  • Senders list: GET /communications/inbox/senders (page/limit only) returns each distinct sender of the caller's items once as { senderUserId, senderDisplayName }, ordered by senderDisplayName case-insensitively then senderUserId; meta.total counts distinct senders and an empty inbox is 200 with data: []. The name is the one on the sender's communication with the latest queuedAt. findInboxSenders is raw SQL (DISTINCT ON per sender, then order and page) because Prisma cannot express "latest name per sender" with correct pagination and total; it repeats the inboxWhere fence (own refs, own tenant, status != PENDING), so a change to that predicate must be mirrored there.
  • Sender reference: Communication.senderUserId has no foreign key. When the sender's membership ends with their last person row (membership lifecycle), the communication and its recipients' items stay, the id keeps naming the ended account, and senderDisplayName, captured at compose, remains what every surface shows. The own-archive view is keyed on the caller being the sender, so nobody reads that communication as an archive any more.
  • 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_id → kind: 'communication' (delivered→DELIVERED, bounced→BOUNCED, failed→FAILED, complained→COMPLAINED); otherwise the invitation branch, 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).

Receipt notifications

Communications produce communication.received through the notification engine when a recipient row first qualifies for this inbox. This adds a notification-center entry and a push attempt; ordinary communication email remains the sender's responsibility and no extra notification email is sent.

During the composition transaction, the accepted To/CC audience snapshots each exact person reference's active same-tenant User into the nullable internal notificationRecipientUserId. There is no email/LoginIdentity matching, family expansion or additional academic-year filter. A null snapshot stays ineligible, including historical rows and later account activations. The sender is included only when explicitly addressed in the accepted audience.

communications-notifications.queries.ts locks the owning communication by tenant and ID before examining notification state. For each snapshotted User with a readable unclaimed row, it rechecks active account and current inbox ownership using findCallerPersonRefs and the inbox-owned metadata query. It selects a readable To item before CC, then UUID order; pending To cannot hide readable CC. It stamps notificationClaimedAt on every alias for that communication/User, including pending aliases. Ineligible or previously claimed groups are consumed without a new intent. These fields never appear in DTOs and do not change email progress or either read marker.

CommunicationsNotificationsService commits claims before port delivery, with at most ten sends in flight. Composition registers its initial callback with PostCommitCoordinator, so outer rollback discards it. Sender callbacks run after SENT batch and SENT/FAILED single mark commits, outside email error classification. Webhook state changes do not schedule another alert.

Before each tenant's existing email sweep, independent recovery scans readable, snapshotted, unclaimed rows through a SQL partial index: distinct communication IDs in keyset pages of 25, at most 100 IDs per pass including failed candidates. Recovery runs even with no queued email, or before school/template lookup fails. The existing COMMUNICATIONS_SWEEP_INTERVAL_MS controls cadence. A crash before claim is recoverable; a crash or notification failure after claim commit can lose the alert permanently. Claims are never cleared for replay.

The exact payload is { communicationId, inboxItemId } and the target is communication.inbox. Notification read and communications-inbox read remain separate explicit API writes. See Notifications for the engine and payload mapping.

8. Templates

Templates use aliases communication-<kind>-<lang>, such as communication-general-{en,it}. The list endpoint reads the published provider catalogue. The renderer supplies exactly body, senderName and schoolName through {{{key}}} placeholders; other keys use their stored fallback or an empty string. 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).

Server-side rendering. The sweeper sends rendered HTML rather than passing the composed body as a provider template variable. The template is authored and published on Resend and 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; these sends are not attributed to a provider template; the template's own subject is ignored (the explicitly composed subject is 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, student, and every management preset). 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. Students receive neither mailing-group scope: individual sending, templates, picker and own archive are available through the send action alone. The inbox is auth-only (§7). Everything is a flat DTO (FLAT_DTO_ENTITIES).

Preset grants are defined in prisma/seed/roles.ts and reconciled through the permission catalogue machinery. Management roles receive the send action and private-group access; shared-group management remains admin authority. Students receive send without mailing-group scopes. Route admission does not define the audience: the classifier combines profile reach with independently qualified management People access. See Permissions for catalogue ownership and reconciliation.

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), homeroomNames[], and linkedStudentNames[] (REFERENT only), 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 only — senderUserId = caller in every WHERE; foreign id → hidden 404. The list filters by search, mailingGroupId, sentFrom/sentTo (inclusive UTC days) and repeatable recipientPersonId (a TO or CC recipient row whose personId is any of the values; takes the personId of the picker and the detail ledger); filters AND, and recipientCounts always tallies the whole communication. 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 (filters unread, sentFrom/sentTo, repeatable senderUserId), GET …/inbox/senders, 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, plus the distinct senders behind them; 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. Operating boundaries

Publish the school-language communication templates before clients compose with them. Configure a verified mail identity and provider webhook; the sender ledger tracks acceptance/delivery outcomes, while read state belongs only to the recipient inbox.

Email delivery uses send-then-mark and may duplicate a message after a process failure. Receipt notifications use claim-then-send and may lose an alert after a claim commits. These are separate delivery guarantees; neither grants a sender access to the recipient's read marker.