Skip to content

Communications module — role-gated email blasts via Resend ("on behalf of")

Reference document for sending mechanics: docs/guida-notifiche-email-resend.pdf (Italian ops guide, v1). Its decisions are binding for this design: "on behalf of" sending from our verified domain, sender identity derived server-side, one message per recipient via the batch API, webhook-driven per-recipient status, DNS/DMARC work as ops phases.

RBAC amendment (2026-08-11): the route-gating claims below that put every communications route behind communications.send are amended by 2026-08-11-communications-mailing-groups-rbac-split-design.md. The five mailing-group routes now use communications.mailing_groups; the rest of this spec stands.


1. Problem distillation

  • School actors (admin, teacher, staff, referent) need to send one email to many people at once — "communications" — without a shared mailbox or BCC lists, and without leaking recipient lists to each other.
  • Each sender role has a bounded audience (its contactable set); the backend must enforce that bound at send time regardless of how recipients were picked (stored group, inline pick, stale membership).
  • Emails must appear to come from the sender (display name + replies go to them) while actually being sent from our verified Resend domain — the guide's "on behalf of" strategy. Sender identity and Reply-To are backend-derived, never trusted from the client (admin gets a vetted select, see §8).
  • Reusable recipient lists ("mailing groups") are first-class: admin-curated shared lists + per-user private lists, storing person references so emails never go stale.
  • Senders need an archive of what they sent, with per-recipient delivery truth (delivered / bounced) fed by the existing Resend webhook.
  • Composition is wrapped in a branded Resend template the sender picks from a convention-filtered list, previewable by the FE before sending.

Success criteria (observable behavior that proves this works): - A teacher POSTs a communication to the shared "Middle school referents" group; only referents of students they teach receive it; the response reports the filtered-out count; the detail page later shows DELIVERED/BOUNCED per recipient. - An admin sends with replyTo = the secretary's email; recipients see From: "Anna Bianchi — Scuola X" <notifiche@ourdomain> and replying opens a draft to the secretary. - A 300-recipient communication with a PDF attached completes without an HTTP timeout: POST returns 202 immediately, the sweeper drains it per-recipient, status flips QUEUED→SENDING→SENT. - A referent's picker shows only: management-role holders relevant to their children's departments, admins, and their children's teachers — and nothing else at any endpoint. - GET /communications returns only the caller's own sends, for every role including admin.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No recipient-facing surface (no in-app inbox leg — "an email for now"; the notification engine stays untouched). - No suppression list (bounced addresses are visible but not auto-skipped on later sends). - No rule-based/smart groups (membership is explicit references only). - No scheduling ("send later"), no resend-to-failed action, no List-Unsubscribe header. - No DNS/DMARC/BIMI code — guide phases 2–3 are an ops checklist in the chapter, not backend work.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/attendance/attendance-notifier.service.ts + spec 2026-08-04-attendance-loop-notifications-design.md THE scheduled-job template: per-tenant withTenantGuc, CAS claims, env-configurable cadence, timer off under test. The communications sweeper is a structural clone. The attendance sweeper derives work from domain state; ours drains an explicit queue (Communication rows in QUEUED). Claim unit is a communication, not a day.
src/notifications/ + spec 2026-07-23-notification-engine-design.md Resend template mode with ${prefix}-${lang} alias convention resolved from School.primaryLanguage; explicit tags override on MailPayload so non-invitation sends don't masquerade as invitations. Engine sends are kind-templated, fire-and-forget, recipient=User. Communications are user-composed, persisted with a send state machine, recipients are person rows (often without Users).
src/invitations/ + src/mailer/resend/ (Svix guard, ResendReportMapper) The webhook return path: signature guard, tag-correlated delivery reports flipping per-row status. Mapper today only understands invitation_id tags and ignores email.delivered. Needs a second tag namespace (communication_recipient_id) and DELIVERED handling.
Eligibility-classifier pickers, spec 2026-06-16-eligibility-classifier-pickers-design.md One pure classifier consumed by every call site (picker GET, POST filter, group-write validation) so the sets can never disagree. That classifier ranks students for one entity; ours spans four person types + role-holder resolution — same shape, new content.
studentTaughtByCallerOn (src/students/, spec 2026-08-02-teacher-people-visibility-narrowing-design.md) The dated 4-leg taught-set builder, reused verbatim for the teacher tier. The referent tier needs the inverse (teachers teaching my children) — a new builder alongside it.
src/files/ (ch05 §11) Collection files with polymorphic (ownerType, ownerId), multipart upload, signed-URL reads, hard delete with owner. New owner type COMMUNICATION + usage ATTACHMENT; the sweeper additionally reads file bytes server-side to base64 into Resend — a first (files were FE-served only).
UserRole.recipientType/recipientId (src/permissions/, role pre-assignment spec) The polymorphic person-reference pattern for MailingGroupMember / CommunicationRecipient. Fits cleanly.
src/subject-groups/combined-classes/ (ch05 §1 foldering) Sub-module foldering for communications/mailing-groups/ and communications/sender/. Fits cleanly.

On-axis / off-axis (ch16): new entity + new scope/action + CRUD surface are paved paths. Two off-axis inventions, called out per the gate: (1) the recipient-policy classifier spanning four person types + role-holder resolution (§8) — precedent is the eligibility classifier, but the role-tier content is new; (2) the queue-draining sweeper — the attendance loop paved per-tenant scheduled jobs, but explicit-queue claim/drain with a per-recipient send ledger is new. Both patterns live entirely inside src/communications/ and are documented in the new chapter.


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes All 5 new models carry required tenantId; RLS policies in the migration + src/prisma/rls-coverage.ts + src/prisma/tenanted-models.ts entries (full ch12 new-tenant-bearing-model checklist). Sweeper runs per tenant under withTenantGuc. Non-negotiable invariant.
Academic-year scope no n/a — communications are point-in-time sends, not AY-scoped data. Dated recipient resolution uses the school-clock today (placement-effective, teaching episodes), not an AY filter. Same posture as notifications/audit-log.
RBAC entity key new: communications Add to src/common/constants/entity-keys.ts + rbac catalogue. New domain concept.
Scopes new: communications.management (gate-only, zero field mappings) WRITE → admin only; gates shared-group create/update/delete. Follows the curriculum_selection gate-only precedent (ch04 §Gate-only scopes). Shared lists are tenant config; private lists are not.
Actions new: communications.send Gates POST /communications, own archive reads, private-group CRUD, recipient picker, templates read. Seeded to admin, teacher, staff, referent. read/update stay implicit. One capability = one action key.
Service base custom services Not BaseTenantedCrudService — no scoped-people field mapping; flat aggregate DTOs. Both new entities must be added to FLAT_DTO_ENTITIES (flat-DTO gotcha, reference_flat_dto_entities_gotcha). Groups and communications are aggregates, not scope-grouped person records.
queries.ts shape per sub-module communications.queries.ts (list/detail includes, recipient upserts, CAS claim), mailing-groups.queries.ts (group + member sync), recipient-policy.queries.ts (per-type WHERE builders, role-holder resolution, inverse-taught builder). Named functions only. House convention.
Error codes new COMMUNICATION_NOT_FOUND, COMMUNICATION_NO_RECIPIENTS, COMMUNICATION_REPLY_TO_INVALID, COMMUNICATION_TEMPLATE_UNKNOWN, COMMUNICATION_ATTACHMENT_TOO_LARGE, MAILING_GROUP_NOT_FOUND, MAILING_GROUP_NAME_TAKEN, MAILING_GROUP_MEMBER_NOT_CONTACTABLE — all with en/it catalog entries + error examples. ch06 discipline.
DTO conventions flat DTOs + list-query DTO dto/: create/list/detail DTOs per sub-module; CommunicationsListQueryDto (pagination + filters); multipart create DTO (see §5). ch05/ch13.
File-backed sub-resources collection New FileOwnerType.COMMUNICATION + FileUsage.ATTACHMENT. Uploaded on the multipart POST inside the same tx; detail returns SignedFileUrlDto[]; hard-delete cascades with the communication. Sweeper reads bytes from the bucket and base64s into Resend (batch endpoint doesn't take attachments — single sends in that case). Reuse beats a bespoke attachment table.
Custom fields no n/a — no user-extensible fields on communications or groups. Not a people record.
Profile completeness no n/a — nothing here feeds completion-required-fields.ts.

4. Data model plan

Schema deltas

  • Enum CommunicationPersonType { TEACHER, STAFF, REFERENT, STUDENT, USER }USER covers role-anchored recipients with no person row (e.g. the provisioned first admin); email/name resolve from User.
  • Enum CommunicationStatus { QUEUED, SENDING, SENT }.
  • Enum CommunicationRecipientStatus { PENDING, SENT, FAILED, DELIVERED, BOUNCED, COMPLAINED, SKIPPED_NO_EMAIL }.
  • Model MailingGroup: id, tenantId, name, description?, ownerUserId? (FK → User, NULL = shared/admin-curated, set = private to owner), timestamps.
  • Model MailingGroupMember: id, tenantId, groupId (FK cascade), personType, personId.
  • Model Communication: id, tenantId, senderUserId (FK → User), senderDisplayName (snapshot), replyTo, subject, bodyHtml (TEXT), templateAlias? (null = html-mode fallback on log-transport envs), status, queuedAt, claimedAt? (stale-SENDING reclaim), sentAt?, timestamps.
  • Model CommunicationRecipient: id, tenantId, communicationId (FK cascade), personType, personId, email? (snapshot; NULL only on SKIPPED_NO_EMAIL rows), displayName (snapshot), status, failReason?, resendEmailId?, sentAt?, statusChangedAt?.
  • Model CommunicationMailingGroup: id, tenantId, communicationId (FK cascade), mailingGroupId? (FK SET NULL — group deletable without orphaning the archive), nameSnapshot.
  • FileOwnerType + FileUsage enum extensions: COMMUNICATION / ATTACHMENT.

Migration shape

  • Additive: new enums, five new tables, two enum-value additions on existing File enums. No column drops, no renames, no backfill.
  • RLS: tenant policy per new table in the same migration.sql (ch12 checklist), plus rls-coverage.ts and tenanted-models.ts entries — drift specs fail otherwise.
  • Hazards from ch12 checklist: enum value additions are append-only (safe); partial unique indexes are raw SQL (below) — Prisma schema carries them as @@index + comment, the migration owns the real constraint. Check for an uncommitted migration and ask about folding before migrate dev (ch12 Rule 1).

Indexes and uniqueness

  • MailingGroup: partial uniques in raw SQL — UNIQUE (tenant_id, name) WHERE owner_user_id IS NULL (shared namespace) and UNIQUE (tenant_id, owner_user_id, name) WHERE owner_user_id IS NOT NULL (per-owner namespace). Service raises MAILING_GROUP_NAME_TAKEN on conflict.
  • MailingGroupMember: @@unique([groupId, personType, personId]); index (tenantId).
  • Communication: index (tenantId, senderUserId, sentAt) (own-list + date filter); index (tenantId, status) (sweeper claim scan).
  • CommunicationRecipient: @@unique([communicationId, personType, personId]) (dedupes group ∪ group ∪ inline); index (tenantId, status); index (id) is PK — webhook lookups come in by communication_recipient_id tag.
  • CommunicationMailingGroup: @@unique([communicationId, mailingGroupId]) (Postgres treats NULLs as distinct, so SET-NULLed rows don't collide); index (mailingGroupId) (list filter by group).

5. API surface

All routes @ProtectedResource(). "send-action" below = @RequireAction('communications.send').

Verb Path Decorators Request DTO Response DTO
GET /mailing-groups send-action ?search= bare { data: MailingGroupListItemDto[] } — shared + caller's private, isShared, memberCount
POST /mailing-groups send-action (service enforces communications.management WRITE when shared: true) CreateMailingGroupDto { name, description?, shared?, members[{personType, personId}] } MailingGroupDetailDto
PATCH /mailing-groups/:id send-action (same service gate for shared rows) scalars + authoritative members[] (family-sync convention) MailingGroupDetailDto
DELETE /mailing-groups/:id send-action (same service gate) 204
GET /communications/recipients send-action ?personType=<required>&search=&page=&pageSize= paginated { data: RecipientCandidateDto[], meta } — contactable-set-filtered picker
GET /communications send-action CommunicationsListQueryDto { mailingGroupId?, subject?, replyTo?, sentFrom?, sentTo?, page, pageSize } { data: CommunicationListItemDto[], meta }own sends only, every role
GET /communications/:id send-action CommunicationDetailDto — composition + group snapshots + status counts + recipients[] + attachment SignedFileUrlDto[]; own-only, hidden 404 otherwise
POST /communications send-action multipart: scalar fields subject, bodyHtml, templateAlias?, replyTo? (admin only); JSON-encoded parts groupIds[], recipients[]; file parts attachments[] 202 { id, recipientCount, skippedNoEmail, filteredOut }
GET /communications/templates send-action { data: TemplateListItemDto[] { alias, name } }
GET /communications/templates/:alias send-action TemplatePreviewDto { alias, name, subject, html, variables[] }

Behavioral notes (contract-level): - Group write authority: a private group is writable/deletable by its owner only (admin included — no override, consistent with the own-only philosophy); a shared group (ownerUserId NULL) is writable/deletable only by holders of communications.management WRITE. Every sender reads shared groups + their own private ones; nobody lists another user's private groups. - POST resolution order: union stored-group members ∪ inline recipients[] → dedupe → intersect with the caller's contactable set (§8) → resolve emails. Permission-filtered people become a filteredOut count only (never enumerated, never persisted). Email-less students persist as SKIPPED_NO_EMAIL rows (name-picked recipients deserve an explanation). Zero sendable recipients → 422 COMMUNICATION_NO_RECIPIENTS, nothing persisted. - Reply-To: teacher/staff/referent — server-forced to own email (Teacher/Staff institutionalEmail, Referent email); a supplied replyTo is rejected for these roles (422 COMMUNICATION_REPLY_TO_INVALID). Admin — optional replyTo validated against the vetted set (all teacher+staff institutional emails ∪ own User.email); default own User.email. - From: built by the transport as "${senderDisplayName} — ${schoolName}" <MAIL_FROM>; senderDisplayName = sender's User.firstName + lastName snapshot; schoolName = School.operationalName. - Templates: list = Resend templates.list filtered to status=published AND alias matching communication-*-<lang> where <lang> = School.primaryLanguage (invitation ${prefix}-${lang} convention). Preview = templates.get passthrough. Unknown/unpublished alias on POST → 422 COMMUNICATION_TEMPLATE_UNKNOWN. Template variables contract: {{{body}}}, {{{senderName}}}, {{{schoolName}}}, optional {{{schoolLogoUrl}}} (presigned, invitation pattern); subject passes explicitly alongside the template ref (transport already supports it). On MAIL_TRANSPORT=log envs the list returns [] and POST accepts a null templateAlias → html mode with a hardcoded signature footer ("Inviato da X tramite …", per the guide). - Attachments: total raw size capped at COMMUNICATION_ATTACHMENT_MAX_TOTAL_BYTES (default 25 MB — Resend's 40 MB cap minus base64 overhead) → 422 COMMUNICATION_ATTACHMENT_TOO_LARGE.

Swagger considerations

  • Multipart create body needs a hand-written @ApiBody schema (Scalar can't infer JSON-encoded parts).
  • Error examples for all eight new codes in error-examples.ts.
  • Controller JSDoc is public FE copy: document own-only visibility, the filteredOut semantics, and the admin-only replyTo — no sweeper/transaction internals.

Config deltas (env.validation.ts + communications-timing.config.ts)

  • COMMUNICATIONS_SWEEP_INTERVAL_MS (default 30 000; sweeper disabled under test — attendance-timing precedent).
  • COMMUNICATIONS_SENDING_STALE_MS (default 10 min — stale-SENDING reclaim threshold).
  • COMMUNICATION_ATTACHMENT_MAX_TOTAL_BYTES (default 26 214 400).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) communications.management — gate-only, zero field mappings
PermissionAction (rbac-catalogue.ts) communications.send
ScopeFieldMapping (rbac-catalogue.ts) none (gate-only scope)
Role grants (roles.ts) admin: communications.management WRITE + communications.send. teacher / staff / referent: communications.send. student: nothing. Other management presets: nothing extra (they send via their holder's profile role — §8 tier rule).
*_SCOPES runtime constant scope-fields.ts: entity entry with the gate-only scope. Add communications to the others-scopes exclusion sets in roles.ts (teacher ALL_READ etc. must not admit it) and check every *_EXCLUDED_SCOPE_KEYS FE-coordination list.

NEEDS PROD RESEED on deploy (new catalogue rows + grants), same batch as the other pending reseeds.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Admin-sees-all list surfaces (audit-log, dashboards) Archive is own-only for every role, admin included User decision: it's a personal outbox, not an oversight register Tenant oversight rides the audit-log envelope written at POST, not this API
Guide's golden rule ("Reply-To dal token, mai dal client") Admin may choose a Reply-To from a payload field Real need: "replies go to the office, not to me"; the choice is validated against a backend-built vetted set (teacher+staff emails ∪ own) — never free text A compromised admin session can route replies to any staff member's mailbox; accepted, admin is already all-powerful in-tenant
Guide's "resolve segment at send time" Emails + membership snapshot at POST time; sweeper sends the snapshot The policy filter must run at POST to return filteredOut/counts synchronously; POST→sweep gap is seconds An email edited in that gap sends to the old address
Mailer is the only Resend seam ResendTemplatesService (inside communications/) calls resend.templates.* directly Template listing is admin-plane metadata, not sending; forcing it through MailerPort would pollute a transport-agnostic port with Resend-only concepts Two Resend clients in the codebase; both read RESEND_API_KEY from the same config
BaseTenantedCrudService for new entities Custom flat services No scope-field mapping, aggregate DTOs Must remember FLAT_DTO_ENTITIES registration
Per-recipient rows persist everything Permission-filtered people are not persisted (count only) Privacy: a sender must not enumerate people outside their contactable set The archive can't show who was filtered, only how many

8. Pushback log

US says Conflicts with Proposed instead Status
"reply to" is a POST input field Guide golden rule: backend-derived, never client Role-split: teacher/staff/referent forced to own email; admin picks from vetted select (all staff+teacher emails) Resolved (user chose the split)
Groups "store once the list of emails" Raw emails go stale, can't be permission-intersected, invite external spoofing Person references, emails resolved live at POST Resolved (user picked references)
Guide phase-1 includes suppression list v1 scope budget Per-recipient status only; suppression deferred Resolved (user picked status-only)
"admin-like roles" undefined Six management presets + custom roles, two of them parametric Admin-like = any management-role holder; tenant-wide ones reach every department for the referent rule, parametric ones only where the parameter covers the child's department/curriculum Resolved
Sender tier for principal/hr/secretary views unstated Could imply admin-tier reach Profile tier for all management views — the holder's anchored Teacher/Staff tier; "linked-to" shape gives a principal what they need without admin reach Resolved (user chose b)

Contactable-set policy (normative)

One pure classifier resolveContactableSet(ctx) in communications/recipient-policy.ts (+ recipient-policy.queries.ts), consumed by exactly three call sites: the recipients picker, POST resolve-and-filter, private-group member validation. Tier from the session's activeRole; management-role views resolve to the holder's anchored profile tier (Teacher → teacher tier, Staff → staff tier).

Tier Contactable set
admin Everyone: all teachers, staff, referents, students-with-email, all users
teacher Taught students (studentTaughtByCallerOn, dated at school-clock today) ∪ referents of those students (StudentReferentLink) ∪ all teachers tenant-wide ∪ all management-role holders (admins included, via the USER fallback for bare-User admins)
staff All teachers ∪ all staff ∪ all management-role holders and admins. No referents, no students
referent Tenant-wide management-role holders (always) ∪ parametric holders whose parameter covers a linked child's department/curriculum ∪ all admins ∪ teachers with a dated teaching episode over a linked child (new inverse builder of the taught set; children's departments via placement-effective-today)

Management-role holders resolve UserRole ACTIVE → recipientType/recipientId → person row → institutionalEmail, falling back to USER-type recipients (User.email) for bare-User admins. Student addressability = schoolEmail present; platform-access flags are irrelevant (email ≠ login).


9. Deferrals

  • Suppression list (auto-skip hard bounces) — scope budget; statuses already recorded — follow-up: next iteration of this spec.
  • Rule-based smart groups (criteria + evaluator) — explicit refs cover v1; FE pickers can bulk-fill — follow-up: future iteration spec.
  • Guardians as recipients — user excluded them — follow-up: revisit on demand.
  • In-app inbox leg for recipients — "email for now"; would ride NOTIFICATION_PORT when wanted — follow-up: future iteration.
  • Scheduled send (sendAt) — not requested; sweeper architecture makes it cheap later — follow-up: future iteration.
  • Resend-to-failed / retry endpoint — not requested — follow-up: future iteration.
  • List-Unsubscribe header — guide flags it for mass external comms; internal school mail exempt for now — follow-up: revisit if volumes grow.
  • DMARC enforcement + BIMI/VMC (guide phases 2–3) — DNS/ops, not backend — follow-up: ops checklist in docs/24-communications.md.
  • Multiple verified sender addresses per user — guide's "raro" case — follow-up: none planned.
  • Admin tenant-wide archive — user chose own-only — follow-up: audit log covers oversight; revisit on demand.

10. Open questions

None — all resolved in chat 2026-08-05 (see §8 pushback log for the resolutions).


11. Verification plan

  • Unit specs:
  • recipient-policy.spec.ts — per-tier WHERE composition: teacher 4-leg reuse, referent inverse builder + parametric matching, staff exclusions, USER fallback.
  • communications.service.spec.ts — POST resolution: union/dedupe, filter counts vs persisted rows, SKIPPED_NO_EMAIL, zero-recipient 422, replyTo role rules, attachment cap.
  • communications-sender.service.spec.ts — CAS claim, stale-SENDING reclaim, batch chunking (100) vs per-recipient path when attachments, PENDING retry on transient failure, terminal flip to SENT.
  • resend-templates.service.spec.ts — alias-convention + language filtering, published-only, log-transport empty list.
  • mailing-groups.service.spec.ts — shared-vs-private gates, member validation against the classifier, name-conflict mapping.
  • Webhook consumer spec — tag-namespace fan-out, DELIVERED admission, monotonic status (bounce beats delivered, nothing downgrades).
  • E2E specs (test/communications.e2e-spec.ts, sis_e2e isolation per feedback_e2e_isolation_patterns):
  • Group CRUD: private group invisible cross-user; non-admin shared: true → 403; member outside contactable set → 422.
  • Send flow: teacher POST to a shared group → filteredOut correct, recipients = taught subset; sweeper driven manually (memory transports) → per-recipient SENT; webhook POST (Svix-signed fixture) → DELIVERED/BOUNCED flips; detail shows the ledger.
  • Own-only: admin cannot read another sender's communication (hidden 404).
  • Referent tier: reaches dept-relevant management holders + child's teachers, nothing else.
  • Manual verification: dev env with MAIL_TRANSPORT=log — full compose→sweep cycle in logs; stage with real Resend — template list + preview + one real send with attachment.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-08-05
  • Chat reference: "approved, sign off" in chat 2026-08-05, after section-by-section design walkthrough (groups model, roles/tiers, reply-to split, queued sweeper, templates convention)

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.