# stapel-notifications 0.6.3

Multi-channel user notifications: routes typed notification requests to email, SMS and push with per-user preferences and language, branded email templates, device push-token management and an in-app notification feed; delivery is logged per channel with idempotency.

Contract: axes 3 · surface 30 · extension points 5 · operations 4 · error codes 43.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- EMAIL_PROVIDER [enum, default "mock"] — Email delivery service
  Which service sends the emails: Resend, SMTP, Mailgun, a mock for development, or any custom provider class supplied by the host project.
- PUSH_PROVIDER [enum, default "fcm"] — Push notification service
  Which service delivers mobile/web push: Firebase Cloud Messaging (default), a mock for development, or any custom provider class supplied by the host project.
- SMS_PROVIDER [enum, default "mock"] — SMS delivery service
  Which service sends the text messages: GatewayAPI, Twilio, a mock for development, or any custom provider class supplied by the host project.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### template
- notifications/email/_base.html — templates/notifications/email/_base.html
  The shared email shell every packaged letter extends: header/logo, body slot, footer, and the logo_url/brand_*/company_* variables process_notification threads into every render. A host template for a new type should extend this and fill the `content` block (plus `preheader`/`footer`/`head_extra` as needed) rather than start from a blank HTML document — that is what makes a product's own letter look like the rest of the mail the deployment sends, and what makes the branding settings apply to it for free.
- notifications/email/_footer_unsubscribe.html — templates/notifications/email/_footer_unsubscribe.html
  The unsubscribe / manage-preferences footer that _base.html switches to whenever `unsubscribe_url` is present, i.e. on every non-auth group. Do not write an unsubscribe line into a custom template by hand: the URL, its signed token and the matching one-click List-Unsubscribe headers are produced together by process_notification, and a hand-written link stops working the moment the token scheme moves.
- notifications/email/_raw_content.html — templates/notifications/email/_raw_content.html
  The raw-content wrapper that puts a caller-supplied content_html/content_text inside the brand layout — what makes the request_notification(..., content_html=...) escape hatch possible for a type that is deliberately not registered. Reach for the escape hatch, not for this template directly.
- notifications/email/all_sessions_revoked.html — templates/notifications/email/all_sessions_revoked.html
  The "all sessions have been revoked" notice — notification_type `all_sessions_revoked`, auth group, email only. Send it after a global logout so an owner who did not trigger it finds out.
- notifications/email/auth_change.html — templates/notifications/email/auth_change.html
  One template serving the whole authenticator-change sequence — notification_types `auth_change_requested`, `auth_change_reminder`, `auth_change_urgent` and `auth_change_completed` (auth group; email, SMS and push). Four stages of a single flow share it, so one EMAIL_TEMPLATES remap re-skins all of them.
- notifications/email/gdpr_export_ready.html — templates/notifications/email/gdpr_export_ready.html
  "Your data export is ready" — notification_type `gdpr.export_ready`, auth group, email only: the letter a subject-access export has to end with.
- notifications/email/gdpr_inactivity_closed.html — templates/notifications/email/gdpr_inactivity_closed.html
  "Your account has been closed" — notification_type `gdpr.inactivity_closed`, auth group, email only: the terminal notice of a retention closure.
- notifications/email/gdpr_inactivity_warning.html — templates/notifications/email/gdpr_inactivity_warning.html
  "Your account will be closed in N days" — notification_type `gdpr.inactivity_warning`, auth group, email only: the warning a retention-driven closure owes the account before it happens.
- notifications/email/listing_blocked.html — templates/notifications/email/listing_blocked.html
  "Your listing has been blocked" — notification_type `listing_blocked`, system group, push and email: the moderation-block notice for the owner.
- notifications/email/listing_expiring.html — templates/notifications/email/listing_expiring.html
  "Your listing is expiring" — notification_type `listing_expiring`, system group, push and email: the marketplace lifecycle reminder.
- notifications/email/magic_link_login.html — templates/notifications/email/magic_link_login.html
  The passwordless sign-in link letter — notification_type `magic_link_login`, auth group, email only.
- notifications/email/new_device_login.html — templates/notifications/email/new_device_login.html
  The "new sign-in to your account" security notice — notification_type `new_device_login`, auth group, email only: the letter to send when a session appears from a device the account has not used before.
- notifications/email/new_message.html — templates/notifications/email/new_message.html
  The user-to-user message letter — notification_type `new_message`, messages group (the user can switch it off per channel), routed to push first and email second.
- notifications/email/otp_code.html — templates/notifications/email/otp_code.html
  The one-time-code letter — notification_type `otp_code`, auth group (mandatory, no unsubscribe), routed to email and SMS. Request the type instead of composing a verification-code mail: the code formatting, the branding and the language resolution are already handled.
- notifications/email/report_reviewed.html — templates/notifications/email/report_reviewed.html
  "Your report has been reviewed" — notification_type `report_reviewed`, system group, push and email: the moderation-outcome letter for the person who reported.
- notifications/email/suspicious_login.html — templates/notifications/email/suspicious_login.html
  The "suspicious sign-in detected" security notice — notification_type `suspicious_login`, auth group, email only. The sharper sibling of new_device_login, for a sign-in the risk logic actually distrusts.
- notifications/email/workspace_invitation.html — templates/notifications/email/workspace_invitation.html
  The workspace invitation letter for an invitee who already has an account — notification_type `workspace.invitation`, system group, email only. stapel-workspaces requests it from create_invitation; nothing else should be minting invitation mail, because the single-use token and its TTL belong to that flow.
- notifications/email/workspace_invitation_new_user.html — templates/notifications/email/workspace_invitation_new_user.html
  The invitation letter for a recipient with no account yet — notification_type `workspace.invitation.new_user`. Its own type rather than an override of `workspace.invitation`, because the link both creates the account and joins the workspace and the copy has to say so.
- notifications/email/workspace_invitation_reminder.html — templates/notifications/email/workspace_invitation_reminder.html
  The re-delivery of a pending invitation — notification_type `workspace.invitation.reminder`. An admin resend rotates the token and restarts the TTL, so this is "you are being reminded, here is a fresh link", a different message from the original invitation rather than a repeat of it.
- notifications/email/workspace_member_password_reset.html — templates/notifications/email/workspace_member_password_reset.html
  "Your password was reset" — notification_type `workspace.member_password_reset`, auth group. It names the workspace and the admin who did it and never carries the new password: a reset performed by somebody other than the account's owner is indistinguishable from a takeover until the owner is told, which is why this one is mandatory.
- notifications/email/workspace_mfa_restored.html — templates/notifications/email/workspace_mfa_restored.html
  "Access restored" — notification_type `workspace.mfa_restored`, auth group: the counterpart sent when the member enrols or the policy is lifted.
- notifications/email/workspace_mfa_suspension.html — templates/notifications/email/workspace_mfa_suspension.html
  "Access suspended — two-factor authentication required" — notification_type `workspace.mfa_suspension`, auth group: sent when a workspace's require_mfa sweep suspends a member who has not enrolled.
- notifications/email/workspace_provisioned_account.html — templates/notifications/email/workspace_provisioned_account.html
  "Your account is ready" for a member an organization provisioned directly — notification_type `workspace.provisioned_account`, auth group: credentials mail is mandatory and carries no unsubscribe, like every other account-credential letter here.
### factory
- default_language — stapel_notifications.services.default_language
  instead of: django.conf.settings.LANGUAGE_CODE
  The project's fallback language — STAPEL_LANGUAGE["DEFAULT"], then Django's LANGUAGE_CODE — and never a hardcoded "en": a service built for a non-English market gets every English string as a defect. A module-local alias of stapel_core.language.default_language; outside this package reach for the core one directly.
- get_channels — stapel_notifications.routing.get_channels
  instead of: django.conf.settings.STAPEL_NOTIFICATIONS
  The channels a type is routed to after host overrides — read this rather than the settings dict when a caller has to know whether a type reaches SMS or push at all (an empty list means the type is unregistered).
- get_email_template — stapel_notifications.routing.get_email_template
  instead of: django.conf.settings.STAPEL_NOTIFICATIONS
  The email template a type actually renders with, resolving the whole precedence chain (the routing entry's "template" key → the EMAIL_TEMPLATES override → the packaged default). Reach for it instead of hardcoding a template path, which silently ignores the host's remap.
- get_group — stapel_notifications.routing.get_group
  instead of: django.conf.settings.STAPEL_NOTIFICATIONS
  The preference group of a type: "auth" is mandatory and carries no unsubscribe, "messages" and "system" are user-mutable per channel. This is the value that decides whether a user may switch the notification off, so read it before a UI promises an opt-out — and before classifying a new type, because putting a marketing message in the auth group is how a deployment loses its List-Unsubscribe compliance.
- get_routing — stapel_notifications.routing.get_routing
  instead of: django.conf.settings.STAPEL_NOTIFICATIONS
  The effective routing entry ({channels, group, template}) of a type, with STAPEL_NOTIFICATIONS["TYPES"] merged over the packaged catalog — the primitive the other readers sit on. Prefer get_channels/get_group/get_email_template when the question is narrower, and never merge the settings dict over routing.NOTIFICATION_ROUTING by hand: an override replaces a built-in wholesale, which a naive dict update gets wrong.
- process_notification — stapel_notifications.services.process_notification
  instead of: django.core.mail.EmailMultiAlternatives, django.core.mail.send_mail
  THE dispatch entry point: give it a notification_type plus variables and it resolves the recipient's language, their contacts and preferences, the translations, the branded template and every configured channel, then writes one NotificationLog row per channel with event_id idempotency. Call it in-process (it is what the notification.requested consumer calls); across a service boundary publish through stapel_core.notifications.request_notification instead, which lands here. Do not assemble an email and a log row yourself — the language chain alone (saved preference → caller's language → remembered → the request's active language → project default) is four fallbacks deep, and every level of it was added because a product shipped English OTP codes to a non-English locale.
- registered_types — stapel_notifications.routing.registered_types
  instead of: django.conf.settings.STAPEL_NOTIFICATIONS
  Every notification type this deployment knows, built-ins plus the host's TYPES registrations — ask it before requesting a type you are not sure exists, because an unregistered type without content_html/content_text is dropped with a logged error rather than delivered. `manage.py check_notifications` is the CI-time form of the same question over literal call sites.

## Extension points — what a product replaces, fork-free
- EMAIL_TEMPLATES [merge_registry]
  Remap any notification type to a host-project email template; merged over the packaged per-type template map.
- TYPES [merge_registry]
  Register new notification types (channels + group + template) or override built-ins entirely from settings — merged over the packaged routing catalog, no fork.
- channel_providers [dotted_path]
  The EMAIL/SMS/PUSH provider settings accept any dotted path to a class with the matching send() signature — add SendGrid, Postmark, APNs etc. as host-project classes, no fork.
- serializer_seams [class_override]
  Every view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.
- template_overrides [django_templates]
  Packaged email templates live under a namespaced path and follow standard Django template-loader precedence; eject_notification_templates copies them into the host project for structural edits, and LOGO_URL/BRAND_*/COMPANY_* settings rebrand every email without touching a template.

## Fits with — fleet dependencies
- stapel-auth (optional) — source of the user-contact sync (email/phone projections) and the user.deleted / user.deletion_initiated events this module consumes; decoupled via the comm bus
- stapel-core (required) — comm bus (notification.requested input, translate.resolve calls), JWT authentication, GDPR provider registry, AppSettings config layer
- stapel-profiles (optional) — source of the notification-preferences and language sync (profile-changed events consumed into UserNotificationSettings); decoupled via the comm bus
- stapel-translate (optional) — notification texts are localized through the translate.resolve comm Function with lazy caching; without it the built-in English defaults are used

## HTTP operations (4) — call by operationId, never by a typed path
Paths are relative to `/notifications/api/v1/`.
### Devices
- POST /devices/ — register_device_token
- DELETE /devices/{token}/ — unregister_device_token
### Feed
- GET /feed/ — get_notification_feed
### Translation Keys
- GET /notification-keys/ — get_notification_keys

## Error codes (43) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.invalid_platform [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.token_not_found [404] fix_input
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
