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.sendare amended by2026-08-11-communications-mailing-groups-rbac-split-design.md. The five mailing-group routes now usecommunications.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 }—USERcovers role-anchored recipients with no person row (e.g. the provisioned first admin); email/name resolve fromUser. - 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 onSKIPPED_NO_EMAILrows),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+FileUsageenum 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), plusrls-coverage.tsandtenanted-models.tsentries — 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 beforemigrate 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) andUNIQUE (tenant_id, owner_user_id, name) WHERE owner_user_id IS NOT NULL(per-owner namespace). Service raisesMAILING_GROUP_NAME_TAKENon 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 bycommunication_recipient_idtag.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
@ApiBodyschema (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
filteredOutsemantics, and the admin-onlyreplyTo— 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_PORTwhen 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_e2eisolation perfeedback_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 →
filteredOutcorrect, 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.