Skip to content

Platform Spec: Notifications

FieldValue
Layer_platform — global, cross-cutting. Features emit events; this service delivers.
StatusStub — created from demand across Weekly Summary (§4.4/4.5) and Weekly Goals (§6/OQ-04). Awaiting sign-off + the channel/provider decisions.
OwnerTBD (Duncan + external team)
Contract/contracts/_platform/notifications.yaml
Consumed byWeekly Summary (FR-18,19,20,23,24,25), Weekly Goals (US-007/FR-21), future features
Governsin-app + email delivery; one place, no per-feature email code

Why this exists. Two shipped specs need email + in-app delivery (manager-on-submit, employee-on-response, end-of-week reminders, Monday digests, non-setter reports). Building that per-feature means duplicated send logic, inconsistent templates, and no single place for user preferences. This is platform: features publish a notification; the service decides channels, applies the template, and delivers.

  • Event-driven. A feature publishes a notification (type + recipients + payload). It does not know or care about channels — the service resolves them.
  • Two notification kinds:
    • Transactional — fired by a domain event (summary submitted → manager). Delivered near-immediately.
    • Scheduled digest — generated on a schedule (Monday morning emails). The feature exposes the data (e.g. Weekly Summary’s weekly-digest); a scheduled job composes and dispatches it. Digest timing shares the week definition / timezone (see Weekly Summary G1).
  • Channels: in_app and email. Each notification type has a default channel set. v1: all notification types are mandatory — no user opt-out (decided 2026-06-10). The preference model below is designed but deferred post-v1.
  • Email provider: Mailgun (decided 2026-06-10, pending final confirmation). The send adapter targets Mailgun; DKIM/SPF setup required for deliverability.
  • Digest timezone is per-tenant (admin/tenant-settings), default Europe/London (GMT/BST). The Monday digest fires on the tenant’s configured timezone + week-start.
  • In-app inbox. Every recipient has a feed with unread count and mark-read.
TypeKindDefault channelsRecipient
weekly_summary.submittedtransactionalin_app, emailline manager
weekly_summary.responsetransactionalin_app, emailsummary owner
weekly_summary.reminderscheduledemailemployee (end-of-week, consolidated w/ goals)
weekly_summary.monday_digestscheduledemailmanagers / senior managers / leadership
weekly_goals.non_setterscheduledin_app, emailmanagers (scoped)

Registry grows per feature; each new type is added here + given a template.

Each type has a template (subject + body) for email and a render shape for in-app. v1 templates live in-repo alongside this spec’s implementation. Payload fields are typed per type so templates can’t reference missing data.

Email is a pluggable send adapter behind one interface — provider is per-environment config, never code. Build the adapter + the recipient guard from day one.

EnvAdapterWhy
Local devMailpit (self-hosted SMTP catch-all, Docker)captures all mail, sends nothing, free, in-house
Staging / sharedMailtrap (or Mailpit)team-visible captured inbox; API lets tests assert “email sent”
ProductionMailgunthe real send

Recipient guard (mandatory, environment-enforced) — two independent layers so no single mistake reaches a real inbox:

  1. Non-prod = redirect/allowlist mode. Even with a real adapter misconfigured, the service rewrites every recipient to a test inbox, or refuses any address not on an allowlist. A bad staging config cannot email real employees.
  2. Prod credentials live only in prod. Non-prod environments do not hold the Mailgun production key.

GDPR (PR-01/PR-03): Mailtrap is a third-party sub-processor. Weekly Summary emails carry personal data (names, wellbeing scores, manager feedback). Therefore: prefer self-hosted Mailpit for any environment holding real employee content; reserve Mailtrap for synthetic/anonymised content. Seed non-prod with synthetic employees for email testing; use real employees only where content stays in-house (Mailpit). Any real-content use of Mailtrap must be in the data-processing register.

Testing hook: Mailpit/Mailtrap both expose APIs — contract/E2E tests assert a notification was dispatched by reading the captured inbox, tagged by FR ID.

RefCriterion
NT-01Publishing a notification fans out to exactly the resolved channels for its type, honouring user preferences (except mandatory types).
NT-02GET /notifications returns the caller’s in-app feed, newest first, with an unread flag; tenant-scoped.
NT-03GET /notifications/unread-count returns the caller’s unread total.
NT-04Marking a notification read updates it; read-all clears the caller’s unread set.
NT-05A failed email delivery is recorded and retried per policy; an in-app notification is unaffected by email failure.
NT-06Publishing is tenant-scoped; a notification can only target recipients within the publisher’s tenant.
NT-07In any non-prod environment, dispatch never delivers to a real external address — recipients are redirected to a test inbox or rejected unless allowlisted.
EntityFields (indicative)Notes
Notificationid, tenantId, recipientUserId, type, payload(json), channels[], readAt?, createdAtThe in-app record; also the dispatch unit.
NotificationPreferenceuserId, type, channels[]Per-user per-type channel opt-in/out. Subject to mandatory types (§C).
DeliveryLognotificationId, channel, status(sent|failed|retrying), provider, attempts, lastError?Email observability + retry.

Tenant-scoped throughout (PR-04). Recipients resolved via _platform/identity-and-access (e.g. “the owner’s line manager”, scoped digest recipients).

Resolved 2026-06-10:

  • Email providerMailgun (pending final confirmation). DKIM/SPF setup required.
  • User preferences vs mandatoryv1 all types mandatory, no opt-out. Preferences deferred post-v1.
  • Digest timezoneper-tenant setting (admin/tenant-settings), default Europe/London. Resolves the shared week definition home (Weekly Summary G1 / Weekly Goals G2).

Non-blocking:

  • Comms audit (Weekly Summary Q4) — inventory existing emails before consolidating the reminders, so nothing valued is silently dropped.
  • Template ownership — in-repo (recommended) vs an editable CMS for non-devs.
  • Read receipts / digests of in-app — out of scope v1 unless requested.

SMS/push channels; per-user quiet hours; notification batching beyond the defined digests; an editable template CMS.