# Mail: how S7 is built

This is Atrium's own mail. Products' e-mail, their own words through Nuntius along a chain of mailers, is `notify.md` (plan NU, NU5): `ProductMail` is the one other place that touches the mailer.

What S7 built (plan CORE §3.11; TuteDesk's notification engine, 24 October 2026, with templates per language and only
account and security events) and how later slices add to it. The house rule it serves: **no hard-coded e-mail**. Code
raises events; templates, which event sends which template and to whom are data; every mail is ONE row in the e-mail
log, viewable, resendable and purgeable by date.

## The pieces

| Piece | What it does |
|---|---|
| `NotificationEvent` | What happened: `key()`, `tenantId()`, `tokens()` (token ⇒ an example), `values()`, `secrets()` (one-time tokens, sent never kept), `relations()` and `people()`, `defaults()` (its starting templates and rules), `subjectRef()` |
| `Notify::event()` | The one line of code per notification point. A failure is reported, never undoes the action (tests see it) |
| `EventCatalogue` | Every event the code raises. It feeds the `mail_event` list (Input Fields), which names each event |
| `EventBridge` | The app's own Laravel events (`LensStarted`, `LensEnded`, `TenantStatusChanged`) become the engine's |
| `Dispatcher` | Every active rule of the event fires, inside the event's workspace: one e-mail per recipients' language (each person's own, else the workspace's), each its own row in `email_message.locale` (S9). Since S14 each person is first asked `canSee`, and the rule's channels, narrowed by their choices, decide the e-mail, the bell and the digest (`notifications.md`) |
| `RecipientResolver` | A rule's lines ⇒ the people: `relation`, `permission` (the workspace's active members holding it), `role` (a standard role), `address`, `service`. Each address once (To beats CC beats BCC), the actor left out unless the rule says otherwise, the Service dropped when anyone else remains, CC moved up when To is empty |
| `TemplateRenderer` | Words ⇒ subject and HTML: a paragraph a line, `**bold**`, `*italic*`, `` `code` ``, `[links](url)`; everything else escaped. Secrets and the person's own tokens stay as `{{ token }}` in the stored body |
| `Outbox` | Writes the row (`email_message`) and queues `SendEmailMessage` after commit; `resend()` makes a new linked row, refused for a mail that carried a secret |
| `SendEmailMessage` | Encrypted, tenant-aware job: one message to everyone (`together`) or one each (`each`), up to three tries (1, 5, 15 minutes), only to those not yet reached; records each person's delivery |
| `EmailLayout` | The brand's frame around a message (`resources/views/mail/layout.blade.php`), added when sent |
| `EmailLogPartitions` / `mail:partitions` | The log is split by month; this month and the next two kept ready, plus a default partition. Daily at 00:05 |
| `NotificationSync` / `notifications:sync` | Writes each event's templates (words from `lang/<locale>/notification_template.php`) and rules; refreshes a template the Service never changed; never touches one it did (`is_customised`). Not audited. A data migration runs it |
| `NotificationCheck` / `notifications:check` | Fails when an event lacks its list value, a template with English, or a rule; when a template uses a token its event doesn't fill; when a locked rule is off |
| `TemplateEditor` | Saves words as a new version (`notification_template_version`), only the event's tokens, English required; `restore()`; `test()` sends to yourself, marked as a test |
| `RuleEditor` | On and off (never off when locked); delivery; lines checked by kind; locked lines stay |
| `LogPurger` / `logs:purge` | Preview; never inside a log's floor (its age setting's least); each entry archived to gzipped JSON lines with a SHA-256, then deleted in batches inside `AppendOnly::purging()`; a `log_purge` row kept for good; `log.purged` audited. Every log since S10 (`logs.md`), each night by its age |

## The tables

| Table | What it holds |
|---|---|
| `notification_template` ● | `key`, `event`, `name`, `subject`, `heading`, `body`, `action_label`, `footer_note`, `in_app` (S14: the bell's and the digest's line) (jsonb per language), `action_url`, `version` (its latest version row's number: one writer, `NotificationTemplate::recordVersion()`, checked by `derived:rebuild`, plan SN D7), `is_customised` |
| `notification_template_version` | Each save's words in every language |
| `notification_rule` ● | the template (whose `event` is the rule's: `$rule->event` reads it, `NotificationRule::forEvent()` finds rules through it; plan SN D6), `delivery` (`together`, `each`), `to_app`, `to_mail`, `to_digest` (S14, read as the list `channels`; plan SN D5), `is_active`, `is_locked`, `exclude_actor`, the last skip and why |
| `notification_recipient` | A rule's lines: `as` (to, cc, bcc), `kind`, `value`, `is_locked` |
| `email_message` ● | One row per mail: workspace (null for platform mail), `locale`, `event`, rule, template and version, `sender`, `subject`, `body`, `recipients` (`[{n, e, as}]`), `secret_tokens`, `actor_name`, `resend_of`, `delivery` (per address), `status` (`queued`, `sent`, `partly_sent`, `failed`: from `delivery`, by `SendEmailMessage`; `derived:rebuild` checks it, plan SN D7), `attempts`, `sent_at`. Partitioned by month on `created_at`; the trigger `email_message_frozen()` lets only the delivery facts change and deletes only with `app.log_purge` |
| `log_purge` ● | Every purge: the day, each log's count, archive, bytes, checksum, removed; how it ended |
| `invitation` ● | A workspace's invitation to choose a password: the e-mail (what the link proves: the account it makes signs in with it), the token's SHA-256, `expires_at`, `accepted_at`, `replaced_at`. The database closes an open one when its membership's e-mail changes, whatever changed it (`membership_email_closes_invitation`, plan SN D1), so the link can't make an account the workspace won't let in; `Invitation::stillAddressed()` says so where the link is opened |
| `password_reset_tokens` | Laravel's, for the reset |

## The events

| Key | Raised by | Tokens | Relations | Rule as shipped |
|---|---|---|---|---|
| `membership.invited` | `InvitePerson` | `person.first_name`, `workspace.name`, `invited_by.name`, `invitation.url` (secret), `invitation.expires` | `invitee` | To the person invited; locked |
| `sign_up.code` | `SignUps` | `sign_up.code` (secret), `sign_up.minutes` | `applicant` | Mail only, locked on |
| `sign_up.already` | `SignUps`, when the address has an account | `event.*` | `account` | Locked on |
| `workspace.signed_up` | `SignUps` | `workspace.name` | the Service | Its name, and nothing of who |
| `email_change.link` | `RequestEmailChange`, unless the address is taken | `email_change.new`, `email_change.url` (secret), `email_change.until` | `new_address` | Mail only, locked on |
| `email_change.requested` | `RequestEmailChange` | `email_change.masked`, `event.*` | `account` | To the current address, locked on |
| `email_change.done` | `ConfirmEmailChange` | `email_change.masked`, `event.*` | `account`, `previous` | One e-mail each, locked on |
| `password.changed` | `PasswordChange`: Your account, a reset from the link, `service:reset` | `event.at`, `event.ip`, `event.browser` | `account` | To the account's owner, locked on |
| `password.reset_requested` | `User::sendPasswordResetNotification` (Fortify); none for the Service | `reset.url` (secret), `reset.minutes` | `account` | To the account's owner; locked; platform mail |
| `lens.started`, `lens.ended` | `LensStarted`, `LensEnded` | `workspace.name`, `lens.mode`, `lens.reason`, `lens.until`, `lens.as` | `seen_as` (started) | To everyone with `lens.notice` |
| `workspace.owner_added` | `CreateWorkspace`, when the Owner already has an account | `workspace.name`, `role.title` | |
| `tenant.status_changed` | `TenantStatusChanged` | `workspace.name`, `workspace.status`, `status.reason` | | To everyone with `workspace.ownership` |
| `sign_in.code` (CL2) | `SignInCodes`, e-mail codes | `person.first_name`, `sign_in.code` (secret), `sign_in.minutes` | `account` | To the address typed; locked; platform mail |
| `membership.ended`, `account.signed_out` (CL3) | `RemoveMembership`, `SetMembershipActive`; `ProductSessions` | | | Webhook only: no person is told |
| `entitlement.changed` (TB1) | `EntitlementChanges` (from `SaveOffering`, `SetBillingTerm`, `entitlements:roll`) | | | Webhook only, to the product's own servers, with the whole entitlement view: no person is told |
| `billing.charged` (TB1, TB-16) | `SetBillingTerm`, charged from today | `workspace.name`, `product.name`, `billing.choose_by` | — | To whoever sees the organisation's bills (`billing.view`: owners and the billing role); workspace |
| `billing.paid` (TB2) | `Settlement`, a payment for a plan settled | `workspace.name`, `product.name`, `offering.name`, `billing.amount`, `entitlement.ends` | — | To whoever sees the organisation's bills (`billing.view`: owners and the billing role); workspace |
| `billing.renewal_due` (TB3) | `Renewals`, 14, 7 and 1 days before a period paid by hand ends | `workspace.name`, `product.name`, `offering.name`, `billing.days_left`, `billing.due_on`, `billing.amount` | — | To whoever sees the organisation's bills (`billing.view`); workspace |
| `billing.unpaid` (TB3) | `Renewals`, a period ended unpaid and its grace days began | `workspace.name`, `product.name`, `offering.name`, `billing.due_on`, `billing.free_on`, `billing.reason` | — | To whoever sees the organisation's bills (`billing.view`); workspace |
| `billing.moved_to_free` (TB3) | `Renewals`, the grace days ended unpaid, or no plan was chosen by `choose_by` | `workspace.name`, `product.name`, `offering.name`, `billing.reason` | — | To whoever sees the organisation's bills (`billing.view`); workspace |
| `billing.evat_failing` (TB4) | `Clearance`, an invoice still not cleared after `billing.evat_alert_tries` (5) | `invoice.number`, `workspace.name`, `billing.tries`, `billing.error`, `console.url` | — | The Service; platform, locked on |
| `billing.certificate_received` (TB4) | A school sent a withholding certificate | `invoice.number`, `workspace.name`, `billing.withheld`, `billing.due_on`, `console.url` | — | The Service; platform |
| `billing.certificate_lapsed` (TB4) | `billing:certificates`, a certificate past its day | as above | — | The Service; platform |
| `billing.invoice_sent` (TB4) | An owner or billing person sends an invoice to an address | `workspace.name`, `invoice.number`, `invoice.kind`, `invoice.date`, `invoice.url` (a signed link to the PDF, `billing.shared_days`), `invoice.until` | — | The address typed (relation `recipient`); mail only, locked, nobody's preferences apply |
| `billing.launched` (TB5) | `AnnounceLaunch`, a product's billing switched on | `workspace.name`, `product.name`, `billing.charges_from`, `billing.founding` (a sentence, or nothing) | — | To whoever sees the organisation's bills (`billing.view`), once per organisation; not to those complimentary for good |
| `billing.payment_unapplied` (TB6) | `Settlement`, a payment taken on a quote that no longer held | `workspace.name`, `billing.reference`, `billing.amount`, `console.url` | — | The Service; platform, locked on |
| `billing.refunded` (TB7) | `Refunds`, once Paystack has sent the money back, or the Service recorded sending it by hand | `workspace.name`, `product.name`, `billing.amount`, `billing.where`, `billing.plan_now`, `billing.credit_note` (TB8) | — | Permission `billing.view` (owners and billing people); never before the money has gone |
| `billing.refund_stuck` (TB7) | `Refunds`, a refund that needs the payer's details or failed, once | `workspace.name`, `billing.amount`, `billing.why`, `console.url` | — | The Service; platform, locked on |
| `billing.refund_found` (TB7) | `Refunds`, a refund made in Paystack's dashboard, found by the webhook or the check, once | `workspace.name`, `billing.amount`, `console.url` | — | The Service; platform, locked on |
| `billing.dispute_opened` (TB9) | `Disputes`, a bank disputes a payment, once | `workspace.name`, `billing.amount`, `billing.due`, `billing.reason`, `console.url` | — | The Service; platform, locked on |
| `billing.dispute_lost` (TB9) | `Disputes`, a chargeback lost (accepted, decided by the bank, or left to run out), once | `workspace.name`, `product.name`, `billing.amount`, `billing.where`, `billing.plan_now` | — | Permission `billing.view` (owners and billing people); never while it's open, never if won |
| `notify.credit_bought` (NU2) | `Settlement::textsPaid`, once a pack is paid; also a webhook to the products | `workspace.name`, `sms.bought`, `billing.amount` | — | Permission `billing.view` |
| `notify.credit_low` (NU1) | `Credit::tell`, credit below `notify.credit_low`, once per crossing; also a webhook to the organisation's products | `workspace.name`, `sms.left` | — | Permission `billing.view` |
| `notify.sender_asked` (NU4) | `SenderNames::ask`, a name asked for with a provider it wasn't already asked or held with | `workspace.name`, `sender.name`, `console.url` | — | The Service; platform, locked on |
| `notify.sender_changed` (NU4) | `SenderNames::move`, a provider registered or refused a client's name; also a webhook to its products (`name`, `state`, `sends_as`) | `workspace.name`, `sender.name`, `sender.outcome` | — | Permission `setting.manage` |
| `notify.email_finished` (NU5) | `SendProductEmail`, every recipient of a product's e-mail has an outcome, once | `email.status` | — | Nobody: a webhook to the server that sent it, with how many were sent and failed |
| `notify.batch_finished` (NU3) | `Batches`, a bulk send with nothing left waiting, or cancelled, once | `batch.state` | — | Nobody: a webhook to the server that asked for it, with the counts |
| `notify.credit_out` (NU1) | `Credit::tell`, credit run out (or a text refused for it), once until bought; also a webhook | `workspace.name` | — | Permission `billing.view` |
| `billing.withholding_owed` (TB10) | `Withholdings`, a certificate rejected or none by its day, once each time | `workspace.name`, `invoice.number`, `billing.amount`, `billing.why` | — | Permission `billing.view` (owners and billing people) |
| `role.changed` | `RoleEditor::assign` | `person.first_name`, `workspace.name`, `role.now` | `person` | To the person |
| `readiness.failed` (S13) | `Readiness::record` | `checks.failing`, `checks.at`, `operations.url` | | To the Service; locked; platform mail |
| `import.finished` (S15) | `Imports::import` | `person.first_name`, `import.file`, `import.summary`, `import.url` | `importer` | To whoever ran it; work; the import's workspace |
| `setup.reminder` (S18) | `Setup::remind` (the Service, from Workspaces) | `workspace.name`, `setup.left`, `setup.url` | — | To whoever may hand the workspace over (`workspace.ownership`); workspace |
| `privacy.requested`, `privacy.due` (S17) | `Requests::log`, `privacy:due` | `workspace.name`, `privacy.kind`, `privacy.due`, `privacy.url` | — | To whoever may answer requests (`privacy.manage`); workspace; never names the person |
| `privacy.copy`, `privacy.code`, `privacy.refused` (S17) | `Requests::sendCopy`, `sendCode`, `refuse` | `person.first_name`, `workspace.name`, and the link (secret), the code (secret), or the reason | `asker` | To the person, e-mail only, locked; the code is security |
| `notification.digest` (S14) | `Digests` | `person.first_name`, `digest.count`, `digest.items`, `digest.url` | `person` | To the person; locked; mail only; platform mail |
| `export.ready` (S10) | `ExportAuditLog` | `person.first_name`, `export.what`, `export.rows`, `export.until`, `export.url` | `requester` | To whoever asked; locked; the export's workspace, or platform mail for the Service's |
| `two_factor.enabled`, `two_factor.disabled`, `recovery_code.used`, `account.locked`, `service.new_device` (S8) | `TwoFactor`, Fortify's recovery-code event, `AccountLock`, `KnownDevices` | `person.first_name`, `event.at`, `event.ip`, `event.browser`; `codes.left`; `lock.attempts`, `lock.until` | `account` | To the account's owner; locked; platform mail |

Every template may also say `recipient.name`, `recipient.first_name`, `recipient.email` (filled per person),
`app.name`, `app.url` and `company.name`.

## Adding an event

1. A class in `app/Support/Notifications/Events` extending `NotificationEvent`, registered in `EventCatalogue::EVENTS`,
   with its `category()` and, when it is about a record not everyone may open, `canSee()` (S14, `notifications.md`).
2. Its name in `lang/en/lookup.php` under `mail_event.values`; each token's words in `lang/en/notification.php`
   (`token`), each relation's (`relation`).
3. Its templates' words in `lang/en/notification_template.php`, under each template key (name, subject, in_app,
   heading, body, action_label, footer_note), using only its tokens; `in_app` never a one-time secret.
4. `Notify::event(new YourEvent(...))` where it happens, never the mailer.
5. Deploy runs `lookups:sync`, `notifications:sync`, `notifications:check`. `MailGuardTest` holds the rest.

## Pages

| Page | Route | Permission |
|---|---|---|
| A workspace's e-mail log, one e-mail, resend | `/email`, `/email/{email}`, `POST …/resend` | `email.view`, `email.resend` (Admin standard role) |
| The Service's e-mail log | `/console/email…` | `platform_email.view`, `platform_email.resend` |
| Notifications: each event's rule and template | `/console/notification`, `…/rule/{rule}/edit`, `…/template/{template}/edit` | `notification.view`, `notification.manage` |
| Invitation (guest) | `/invitation/{token}` | none: the token |
| Forgot and reset password (guest) | Fortify's `/forgot-password`, `/reset-password/{token}` | none |

An e-mail is shown in a sandboxed frame (`allow-same-origin`, no scripts), its one-time links masked.

## The guards

| Test | Proves |
|---|---|
| `Guards/NoHardcodedMailTest` (S0) | the mailer only inside `app/Support/Notifications` |
| `Guards/MailGuardTest` | every event class registered; named in `mail_event`; each token and relation worded; each template with English name, subject and message, using only its event's tokens; `notifications:check` clean; its self-test |
| `Mail/EngineTest` | the done-when: one row per mail (together and each); a resend a new linked row; the log frozen (subject, recipients, delete refused; delivery moves); who a rule reaches; a secret never stored, its mail never resent; partitions; locked rules; template versions and restore; the purge |
| `Mail/AccountMailTest` | invitations (sent, accepted, replaced, expired, refused, closed when the e-mail changes); the reset (one reply for all, none to the Service, completed into the sign-in log); the lens and a pause told to the workspace, the Service named as the company |
| `Mail/EmailPagesTest` | a workspace's log holds only its own; the Service's every workspace's and the platform's; resend from the page; Notifications' rules and templates, test sends |
