# Customer communications

Phase 4B adds `apps.communications` without modifying authoritative workflow services,
existing tests or historical migrations. The new initial migration creates
NotificationTemplate, Notification and NotificationAttempt, all UUID records.

## Templates and snapshots

Templates belong to a Company. `(company, code, channel)` is unique; code/channel
cannot change through the update service. Templates may be edited or deactivated,
with updated actor/time recorded. Existing notifications retain the rendered subject,
body, destination, template code/reference, requesting actor, source event/reference
and company/center/customer scope. Snapshots and completed attempts cannot be edited
through supported APIs. No history-delete UI/API is provided.

The only substitution syntax is `{name}`. Supported names:

`customer_name`, `appointment_date`, `appointment_time`, `service_case_reference`,
`device_name`, `quotation_reference`, `invoice_reference`, `amount_due`,
`payment_amount`, `receipt_reference`.

Unknown names, attributes, indexing, conversions, format specifications and malformed
braces fail validation. Double braces escape literal braces. No Python evaluation or
Django template execution occurs. Missing context fails rather than inventing facts.
SMS has no subject; email requires a single-line subject. Rendered subject/body are
bounded to 200/4,000 characters. Body is plain text, including for email.

## Delivery and retry

```text
PENDING -> SENDING -> SENT
                  -> FAILED
                  -> UNKNOWN
FAILED  -> SENDING (explicit retry)
PENDING / FAILED -> CANCELLED
```

SENT means the configured provider accepted the request, **not** confirmed delivery.
There are no delivery receipts or fabricated DELIVERED states. A fake-provider SENT
is a simulation, clearly identified by the attempt's provider name and UI notice.

Each attempt has an increasing number, actor, provider identifier, start/finish time,
result, safe error code and bounded opaque provider reference where supplied.
Definitive rejection means no message was accepted and can be retried. Provider
exceptions/timeouts, malformed results or an uncertain SMTP response mean UNKNOWN,
which is not automatically retryable. Raw responses, exception strings and tracebacks
are not stored in communication records or logged by the adapter/service.

Before I/O the sender locks the notification and commits SENDING plus a STARTED
attempt. Other callers observe the claim and do not call the provider. I/O happens
outside the transaction; a second short transaction finalizes the attempt/status.
A crash after claim or an acceptance followed by database failure leaves SENDING/
STARTED. It is deliberately not resent: the provider may already have accepted it.
Operator/provider reconciliation for these states is a future explicit workflow.
No exactly-once delivery guarantee across a remote system is claimed. The service
provides duplicate-dispatch prevention, favoring no duplicate over automatic recovery
when the external outcome is unknown.

## Providers and configuration

Phase 5B adds explicit environment-based production email enablement, configuration
checks and a non-delivering test runner. See
[COMMUNICATION_PROVIDER_SETUP.md](COMMUNICATION_PROVIDER_SETUP.md) for deployment,
failure classification and the future SMS contract. Production SMS vendor:
**NOT SELECTED**; provider boundary ready; real SMS sending **DISABLED**.
Existing lifecycle and retry semantics below remain authoritative.

`providers.Provider`, `SmsProvider`, `EmailProvider` define the adapter contract.
An adapter returns `DeliveryResult(ACCEPTED|REJECTED|UNKNOWN, safe_reference)`.
REJECTED must mean definitively not accepted, never simply "request timed out."
Adapters must return only a non-secret opaque reference, not raw response text.

By default no external provider is configured; attempts record a definite failure
without sending. Configuration is a trusted Django setting, never database template
content:

```python
COMMUNICATION_PROVIDERS = {
    "SMS": "apps.communications.providers.FakeSmsProvider",
    "EMAIL": "apps.communications.providers.FakeEmailProvider",
}
```

These fake adapters are deterministic and network-free, intended only for development
and tests. Do not use simulated acceptance as customer-delivery evidence.

`apps.communications.providers.DjangoEmailProvider` optionally delegates to Django's
configured email backend. Configure `EMAIL_HOST`, `EMAIL_PORT`, TLS/SSL, credentials,
`EMAIL_TIMEOUT` and `DEFAULT_FROM_EMAIL` in deployment settings using environment or
secret configuration; no credential value belongs in these models. Use a finite
transport timeout. Tests mock that adapter or use fakes, never real external APIs.
No SMS vendor is chosen in this phase. Add a trusted vendor adapter without changing
domain models; providers receive a stable notification UUID as their idempotency key.
Additional channels require an explicit channel/contact policy and adapter, not a
redesign of notification/attempt history.

## Idempotency and authorization

Requests require a stable event key. A database unique constraint on company plus
the hashed channel/event key backs idempotency. Company locking serializes supported
first creation; it is not an unlocked exists check. Retries reuse the prior snapshot,
even after template edits, and cannot rebind a key to a different customer/center/event.
Workflow keys include event type and authoritative record UUID; quotation revisions
have distinct UUIDs. Reminder keys additionally include an explicit policy identifier.
Changing template text does not generate another copy of the same event/channel.
Manual forms carry a request UUID so reposting the same form is idempotent.

Permissions use the frozen authorization engine:

- `communications.manage_templates`: company-scoped template administration.
- `communications.view_notification`: history at the recorded center or company.
- `communications.send_notification`: queue, dispatch, retry and cancel at that scope.

Business hooks also require the existing source-view permission: appointment,
ServiceCase, quotation or payment as applicable. There is no Group/staff shortcut or
new scope adapter. Company, region, center and department rules retain existing engine
semantics. UI querysets are scoped before resolving IDs; forms cannot select foreign
templates/customers. Company-only manual notices require a company-level grant;
workflow notices can use a center-level grant.

## Contacts and privacy

An active primary CustomerContact of type MOBILE (SMS) or EMAIL is preferred; otherwise
the existing Customer.primary_mobile/primary_email is used. Values use existing mobile
normalization/email validation. Missing or invalid destinations fail; callers cannot
provide an arbitrary destination. No second contact registry is created.

Dispatch rechecks the active customer/company and current authoritative destination
against the snapshot. A changed destination blocks sending with CONTACT_CHANGED,
rather than silently retargeting historical content. This check is at dispatch claim
time, not a distributed lock over a remote transport. Recent operational state is
also checked so cancelled appointment notices, obsolete quotation notices, reversed
payments or a case no longer ready are suppressed with CONTEXT_CHANGED.

History masks destination addresses and escapes content/provider IDs. Authorized
history users can inspect the message snapshot. Provider errors use fixed codes;
credentials and raw responses are never included. No credential should be written
into a customer-facing template. Contact-consent/preferences are not invented here;
future policies must be explicitly integrated before wider outbound automation.

## Explicit business hooks and failure isolation

`hooks.workflow_notification(actor=..., template=..., event=..., record=...)` queues
after an authoritative transaction has committed. It never sends automatically.
Supported events:

| Event | Authoritative context |
| --- | --- |
| APPOINTMENT_CONFIRMATION | Scheduled Appointment; date/time, optional device |
| APPOINTMENT_REMINDER | Future scheduled Appointment; explicit reminder policy |
| SERVICE_INTAKE | Existing ServiceCase; job reference, device |
| QUOTATION_READY | Current SUBMITTED revision; quotation revision reference |
| QUOTATION_APPROVED | Current APPROVED revision; quotation revision reference |
| READY_FOR_DELIVERY | ServiceCase currently READY_FOR_DELIVERY |
| PAYMENT_RECEIPT | POSTED payment with issued receipt; invoice, amount, current authoritative balance |

All hooks also provide customer_name; case-based hooks provide service_case_reference
and device_name. `amount_due` uses the existing authorized settlement query at request
time, not an inferred charge or payment calculation. It is a historical snapshot.
Manual company messages only receive customer_name.

Optional `after_commit_notification(**hook_arguments)` registers a Django on_commit
callback. Rollback discards it. Preparation failures are logged under `communications`
with fixed NOTIFICATION_PREPARATION_FAILED, event kind and record UUID, without raw
exception text, so they do not invalidate the committed business operation. Invalid
preparation cannot produce a valid notification snapshot; operators must review logs
and rerun the explicit hook. Ensure production logging retains these errors.

Actual dispatch must run outside a surrounding transaction; it rejects being called
inside an authoritative transaction before making any provider call. Dispatch failures
are recorded on Notification/Attempt and do not change appointments, ServiceCases,
quotations, invoices, payments or handover. No frozen workflow is automatically wired
to these hooks in this phase. Future integration points are immediately after the
corresponding successful service call/commit, using on_commit rather than network I/O
inside the transaction. The callback is not a durable background job; a process crash
before callback execution requires rerunning the deterministic event hook.

## Reminder entry point

`queue_appointment_reminders(actor=..., template=..., date=..., policy=...)` finds
future SCHEDULED appointments on the supplied date in authorized centers of the
template company. There is no invented lead time or default scheduler policy.

```powershell
python manage.py queue_appointment_reminders --actor USER_UUID --template TEMPLATE_UUID --date YYYY-MM-DD --policy day-before
```

The command only queues snapshots; repeated invocation with the same appointment,
policy and channel returns existing records. A future scheduler supplies these explicit
arguments and dispatches separately. No scheduler dependency is introduced.

## UI and operational limits

`/communications/` provides paginated history, masked destinations, snapshots and
attempt evidence. `/communications/templates/` lists accessible company templates;
new/edit forms include activation. `/communications/new/` queues an authorized manual
message. Per-record send/retry/cancel actions use confirmation forms, POST and CSRF.
Pages are not cached; the existing reporting login is reused. There is no new frontend
framework or provider response HTML rendering.

Scope is intentionally limited: no chosen production SMS vendor, background worker,
delivery callbacks, bulk campaign system, attachments, HTML email, opt-out registry,
automatic workflow patching, or automatic recovery from unknown delivery outcomes.
Template edits keep current editor/time; full template revision history is not
introduced because sent/queued content is independently snapshotted.

## Verification

Final verification on 2026-09-30:

- 34 focused Phase 4B tests passed in 165.989 seconds, with no failures or skips.
  Includes real PostgreSQL creation serialization, overlapping provider dispatch,
  crash/uncertain outcomes, contact validation, reminder deduplication, scoped UI/CSRF,
  and actual quotation/payment/ready-for-delivery fixture integrations.
- Exactly one final `audit_project --quick`: all checks and 19 smoke tests passed.
  Test runtime 52.221 seconds; overall runtime 113.128 seconds.
- `communications.0001_initial` applied; drift/unapplied-migration checks passed.
- No real external SMS/email calls were made. No full audit, frozen baseline test
  modification, historical migration change, staging, commit or push was performed.
