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 (fromDisplayNameonMailPayload). - Reply-To is backend-derived, never client-chosen:
- teacher / staff / referent / student tier: forced to the sender's own profile email (Teacher/Staff
institutionalEmail, Referentemail, current enrolled StudentschoolEmail;User.emailfallback). A payloadreplyTofrom 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 → 422COMMUNICATION_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 carrieskind: CC— recipients never see who was in copy (the archive does). - Permission-filtered people are counted, never named:
filteredOutis 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_groupsscope. READ browses and uses the catalog; WRITE creates and manages the caller's private groups. This scope is independent fromcommunications.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_groupsgates access whilegroupsis a field ofCommunicationDetailDto. See Permissions. - Shared (
ownerUserId NULL): school-wide catalog, visible to every caller holdingcommunications.mailing_groupsREAD; create/edit/delete additionally requires thecommunications.managementWRITE 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_CONTACTABLEwithdata.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 (nameSnapshoton the join).
4. POST /communications — resolution order¶
CommunicationsService.create, ten steps, validate-everything-then-persist:
- Resolve sender tier and capture
schoolToday. - If groups are supplied, require
communications.mailing_groupsREAD before resolving shared-or-own IDs. Unauthorized and missing groups both produce a hidden 404. - Expand To groups/references and CC groups/references. To wins every duplicate person reference or group.
- Run
partitionContactableonce over the combined audience, then retain each accepted reference's To/CC kind. Return only the rejected count. - Split recipients with an address from
SKIPPED_NO_EMAILrows. At least one sendable To recipient is required; CC alone fails with 422 before persistence. - Derive and validate Reply-To.
- Require a published school-language template alias on Resend. A template-less body is allowed on other transports.
- Enforce the total attachment byte budget and upload format limits.
- Store attachment File rows under a pre-minted communication ID, then insert the queued aggregate, recipient/group snapshots and
communication.sentaudit in the transaction. Failed aggregate persistence triggers attachment cleanup. Snapshot accepted active account IDs for receipt notifications and register the post-commit callback. - 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, orSENDINGwhoseclaimedAtis older thanCOMMUNICATIONS_SENDING_STALE_MS(crash reclaim). CASupdateManyre-checks the captured prior state;count === 1wins. - Drain: one
MailPayloadperPENDINGrecipient, 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 bysender/communication-template-render.ts:{{{body}}}/{{{senderName}}}/{{{schoolName}}}filled, any other placeholder → the template'sfallbackValue, else""— same grammar as the FE preview.templateAliasnull → body + localized footer. Shell fetch failure → transient:template_fetch_failedlogged, nothing sent, the communication staysSENDINGand the stale window reclaims it. Shell missing/unpublished (orhtmlnull) →template_missingwarn + the footer body (mail is not held hostage by a dashboard edit). AlwaysfromDisplayName,replyTo, and thecommunication_recipient_idtag. - No attachments →
sendEmailBatchin chunks of 100 (Resend batch limit). A whole-chunk failure is transient: the chunk staysPENDING, the drain stops, retry after the stale window. - Attachments → per-recipient single sends (the batch endpoint rejects attachments), ~600 ms apart; a per-recipient
MailerSendErrorflips that rowFAILEDand the drain continues. Blob bytes come fromFileStoragePort.getand ride base64. - Send-then-mark (at-least-once): rows flip
SENT(+resendEmailId) after the provider accepts;completeIfDrainedflips the communicationSENT+sentAtexactly once (theSENDINGpredicate) when nothing isPENDING.
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:
findCallerPersonRefslooks up one matching row per person type withfindFirstunder tenant/user ownership, and includesUSER:<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 terminalSKIPPED_NO_EMAIL/FAILEDrows — 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 404COMMUNICATION_INBOX_ITEM_NOT_FOUND. - Filters:
sentFrom/sentTo(YYYY-MM-DD, inclusive, UTC days) and repeatablesenderUserIdnarrow the list inside the fence; they AND with each other and withunread, values ofsenderUserIdmatch any. The day window is built bysentDayWindow(src/communications/sent-day-window.ts), shared with the archive, so both lists agree on what a day is; a communication stillSENDINGhas nosentAtand is left out while a date bound is set.meta.totalcounts the filtered set. A sender who never wrote to the caller yields an empty page. Items carrysenderUserId, 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/limitonly) returns each distinct sender of the caller's items once as{ senderUserId, senderDisplayName }, ordered bysenderDisplayNamecase-insensitively thensenderUserId;meta.totalcounts distinct senders and an empty inbox is200withdata: []. The name is the one on the sender's communication with the latestqueuedAt.findInboxSendersis raw SQL (DISTINCT ONper sender, then order and page) because Prisma cannot express "latest name per sender" with correct pagination and total; it repeats theinboxWherefence (own refs, own tenant,status != PENDING), so a change to that predicate must be mirrored there. - Sender reference:
Communication.senderUserIdhas 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, andsenderDisplayName, 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 idempotentupdateManypredicate (readAt: null);unread-countis the FE badge's poll target;read-allflips the whole visible unread set.readAtis 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.