docs(notifications): EWS transport docs; close Q12 as superseded; design-doc corrections from execution reviews

This commit is contained in:
Joseph Doherty
2026-08-10 06:56:56 -04:00
parent 82f52e81ce
commit 00d8a923af
11 changed files with 102 additions and 32 deletions
+4 -1
View File
@@ -44,6 +44,8 @@
- [ ] Windows Service account has minimum necessary permissions
- [ ] Log directory permissions restrict access to service account and administrators
- [ ] SMTP credentials use OAuth2 Client Credentials (preferred) or secure Basic Auth
- [ ] EWS transport (`Transport=Ews`): endpoint is an absolute `https://` URL and auth mode is Basic — Basic requires TLS and this is enforced at the write gate, the delivery adapter, and the sender
- [ ] EWS transport: the Exchange service-account password is rotated on the account-owner's schedule, and the SMTP configuration row is updated in the same change
- [ ] API keys for Inbound API are generated with sufficient entropy (32+ chars)
### Network
@@ -51,7 +53,8 @@
- [ ] DNS resolution works between all cluster nodes
- [ ] Firewall rules permit Akka.NET remoting (TCP 8081)
- [ ] Firewall rules permit LDAP (TCP 636 for LDAPS)
- [ ] Firewall rules permit SMTP (TCP 587 for TLS)
- [ ] Firewall rules permit SMTP (TCP 587 for TLS) — SMTP transport only
- [ ] EWS transport (`Transport=Ews`): firewall rules permit outbound HTTPS (TCP 443) from central nodes to the Exchange CAS instead of SMTP 587
- [ ] Firewall rules permit SQL Server (TCP 1433) from central nodes only
- [ ] Load balancer health check configured against `/health/ready`
@@ -1,6 +1,6 @@
# EWS Email Transport for the Notification Outbox — Design
**Date:** 2026-08-10 · **Status:** Designed, not implemented · **Owner decisions captured below.**
**Date:** 2026-08-10 · **Status:** Implemented on `feature/ews-email-transport`; manual live gate pending · **Owner decisions captured below.**
## 1. Context — the pending task this supersedes
@@ -71,10 +71,11 @@ call (~200 lines including classification).
- `Host` — the **full EWS endpoint URL** (absolute `https://` URI, validated).
- `AuthType` — must be `basic`; `Credentials` stays `username:password` where username may be
`domain\user` (split on the **first** `:` only, as today). Same storage treatment as SMTP
credentials (persisted server-side, projected away from all read paths by
`ManagementActor`'s credential-free projection; `CredentialRedactor.Scrub` on every error
message). The pre-existing asymmetry with `SmsConfiguration`'s Data-Protection-encrypted
`AuthToken` is noted but out of scope here.
credentials: `SmtpConfiguration.Credentials` is **encrypted at rest** via
`EncryptedStringConverter` (wired in `ScadaBridgeDbContext.ApplySecretColumnEncryption`,
`ConfigurationDatabase`), exactly like `SmsConfiguration.AuthToken` — there is no asymmetry
between them. It is additionally projected away from all read paths by `ManagementActor`'s
credential-free projection, and `CredentialRedactor.Scrub` runs on every error message.
- `FromAddress`, `MaxRetries`, `RetryDelay`, `ConnectionTimeoutSeconds` — same meaning
(timeout becomes the `HttpClient` request timeout).
- `Port`, `TlsMode`, `OAuth2Authority`, `OAuth2Scope`, `MaxConcurrentConnections` — unused for
@@ -103,15 +104,21 @@ call (~200 lines including classification).
### 4.3 Error classification (mirrors `SmtpErrorClassifier` / ESG conventions)
- **Transient** (retry → park after `MaxRetries`): connect/DNS/socket/timeout failures; HTTP
5xx/408/429; SOAP fault codes that are load/availability-shaped (`ErrorServerBusy`,
`ErrorInternalServerTransientError`, `ErrorTimeoutExpired`, `ErrorMailboxStoreUnavailable`).
5xx/408/429; response/fault codes that are load/availability-shaped (`ErrorServerBusy`,
`ErrorInternalServerTransientError`, `ErrorTimeoutExpired`, `ErrorMailboxStoreUnavailable`,
`ErrorInsufficientResources`).
- **Permanent** (park immediately): HTTP 401/403 (credential/authorization — retrying burns
lockout budget on a domain account), 404 (wrong URL), SOAP schema faults, recipient-shaped
response codes (`ErrorInvalidRecipients`, `ErrorMessageSizeExceeded`), malformed-config
findings (bad URL, bad credential form) — same "unclassified defaults to permanent" stance the
SMTP adapter takes.
- New pure `EwsErrorClassifier` alongside `SmtpErrorClassifier`; every surfaced message runs
through `CredentialRedactor.Scrub`.
lockout budget on a domain account), 404/410/405 (wrong URL or endpoint), SOAP schema faults,
recipient-shaped response codes (`ErrorInvalidRecipients`, `ErrorMessageSizeExceeded`),
malformed-config findings (bad URL, bad credential form) — same "unclassified defaults to
permanent" stance the SMTP adapter takes.
- **As built:** there is no standalone `EwsErrorClassifier` type. Classification lives inside
`EwsSoapMailSender`, which throws typed `EwsTransientException` / `EwsPermanentException` that
the adapter maps to `DeliveryOutcome`; the pure `EwsResponseParser` stays judgement-free,
reporting only the response shape and code (and parsing with DTD processing prohibited). A
parsed response code beats the HTTP status — only an unparseable body falls back to the status
code. Every surfaced message runs through `CredentialRedactor.Scrub`, plus a mask of the
base64 Basic-auth value.
### 4.4 Management surfaces
- **CLI:** `notification smtp update` gains `--transport smtp|ews` (existing `--host`,
@@ -123,12 +130,17 @@ call (~200 lines including classification).
- **Transport bundles:** `SmtpConfiguration` already travels in the encrypted SecretsBlock; the
new field rides along additively (`schemaVersion` additive rules). The existing round-trip
guard (arch-review T8 style) is extended to cover `Transport` so export/import can't silently
drop it.
drop it. **Discovered during implementation:** `OAuth2Authority` and `OAuth2Scope` (shipped
2026-07) were **already** being silently dropped by bundle export/import — `SmtpConfigDto`
never carried them. Fixed in the same slice as `Transport` (DTO, serializer,
`BundleImporter.ApplySmtpFields`, `ArtifactDiff`), and the round-trip guard reseeded so all
three fields are now pinned.
### 4.5 Testing (owner decision: fake stub + live gate)
- **Unit:** `EwsSoapMailSender` against an in-process fake EWS endpoint (Kestrel `TestServer`)
asserting the Basic header, envelope shape (BCC-only, SendOnly, escaping) and driving canned
`CreateItemResponse` success / SOAP-fault / HTTP-error bodies through `EwsErrorClassifier`;
`CreateItemResponse` success / SOAP-fault / HTTP-error bodies through the sender's
classification (see §4.3 — no standalone classifier type);
adapter-level tests for the transport branch and outcome mapping; validator tests for the new
config rules. All macOS-runnable — no NTLM, no network.
- **Live gate (manual, one-off):** from the dev Mac, configure `Transport=Ews` with the
@@ -153,7 +165,22 @@ call (~200 lines including classification).
- Removing the SMTP/OAuth2 path (stays config-selectable; Approach "additional transport" was
the owner's choice).
- NTLM/Negotiate auth mode (documented follow-on trigger: Basic disabled on the EWS vdir).
- Encrypting `SmtpConfiguration.Credentials` at rest (pre-existing posture, tracked separately
if desired).
- HTML bodies, attachments, per-recipient sends, Sent-Items copies.
- Site-side anything — notification delivery remains central-only.
## 6. Follow-ups (from execution reviews)
Raised while implementing; none blocking, none scheduled here.
1. **Catch-chain duplication in `EmailNotificationDeliveryAdapter`.** The SMTP and EWS branches
each carry a near-identical permanent / caller-cancel / transient / unclassified catch chain
differing only in exception types and log wording. Extract a shared helper **before** a third
transport is added.
2. **Bundle-import shape validation parity.** Import applies `SmtpConfigDto` fields as data and
does not run the EWS shape gate that `ManagementActor` (CLI/API) and the Central UI apply. The
delivery adapter is authoritative and parks an unusable row with a clear reason, so this is
accepted for now rather than duplicating the gate a fourth time.
3. **`CredentialRedactor.MinSecretLength` = 12.** A standalone password shorter than that is not
scrubbed on its own; EWS messages are still covered by the packed `username:password` and
base64 Basic-auth scrubs, which comfortably exceed the floor. Revisit if a code path ever
surfaces a bare short password.
@@ -1,16 +1,16 @@
{
"planPath": "docs/plans/2026-08-10-ews-email-transport.md",
"tasks": [
{"id": 1, "subject": "Task 1: EmailTransport enum + parser", "status": "pending"},
{"id": 2, "subject": "Task 2: SmtpConfiguration.Transport entity + EF mapping + migration", "status": "pending"},
{"id": 3, "subject": "Task 3: EWS SOAP envelope builder + response parser", "status": "pending"},
{"id": 4, "subject": "Task 4: EwsSoapMailSender + DI registration", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: EmailNotificationDeliveryAdapter transport branch", "status": "pending", "blockedBy": [1, 2, 4]},
{"id": 6, "subject": "Task 6: Management command + validation + public shape", "status": "pending", "blockedBy": [2]},
{"id": 7, "subject": "Task 7: CLI --transport flag", "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 8: Central UI transport selector", "status": "pending", "blockedBy": [6]},
{"id": 9, "subject": "Task 9: Transport bundle carriage (Transport + OAuth2 drop fix)", "status": "pending", "blockedBy": [2]},
{"id": 10, "subject": "Task 10: Docs sweep", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9]},
{"id": 1, "subject": "Task 1: EmailTransport enum + parser", "status": "completed"},
{"id": 2, "subject": "Task 2: SmtpConfiguration.Transport entity + EF mapping + migration", "status": "completed"},
{"id": 3, "subject": "Task 3: EWS SOAP envelope builder + response parser", "status": "completed"},
{"id": 4, "subject": "Task 4: EwsSoapMailSender + DI registration", "status": "completed", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: EmailNotificationDeliveryAdapter transport branch", "status": "completed", "blockedBy": [1, 2, 4]},
{"id": 6, "subject": "Task 6: Management command + validation + public shape", "status": "completed", "blockedBy": [2]},
{"id": 7, "subject": "Task 7: CLI --transport flag", "status": "completed", "blockedBy": [6]},
{"id": 8, "subject": "Task 8: Central UI transport selector", "status": "completed", "blockedBy": [6]},
{"id": 9, "subject": "Task 9: Transport bundle carriage (Transport + OAuth2 drop fix)", "status": "completed", "blockedBy": [2]},
{"id": 10, "subject": "Task 10: Docs sweep", "status": "in_progress", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9]},
{"id": 11, "subject": "Task 11: Integration verify + merge back", "status": "pending", "blockedBy": [10]}
],
"lastUpdated": "2026-08-10"
+1 -1
View File
@@ -463,7 +463,7 @@ Phase 7 is complete when:
| # | Question | Context | Impact | Status |
|---|----------|---------|--------|--------|
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Deferred — implement against Basic Auth first; OAuth2 tested when tenant available. |
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Closed 2026-08-10 as superseded — production mail is on-prem Exchange EWS (see [`docs/plans/2026-08-10-ews-email-transport-design.md`](2026-08-10-ews-email-transport-design.md)); OAuth2 SMTP path remains config-selectable but untested against a live M365 tenant (accepted). |
(Existing question from questions.md — no new questions discovered.)
+1 -1
View File
@@ -10,7 +10,7 @@
| # | Question | Context | Impact | Status |
|---|----------|---------|--------|--------|
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Deferred — won't be known during development. Implement against Basic Auth first; OAuth2 tested when tenant available. |
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Closed 2026-08-10 as superseded — production mail is on-prem Exchange EWS (see [`docs/plans/2026-08-10-ews-email-transport-design.md`](2026-08-10-ews-email-transport-design.md)); OAuth2 SMTP path remains config-selectable but untested against a live M365 tenant (accepted). |
---
@@ -53,6 +53,37 @@ The SMTP configuration is defined centrally and used by the central Email delive
- **Connection timeout**: Maximum time to wait for SMTP connection (default: 30 seconds).
- **Max concurrent connections**: Maximum simultaneous SMTP connections from the central cluster (default: 5).
- **Retry settings**: Max retry count, fixed time between retries. The Notification Outbox reuses these for delivery retry of transient failures.
- **Transport**: The submission mechanism — `Smtp` (default) or `Ews`. Null/empty means `Smtp`, so every pre-existing row keeps its behavior. See EWS Transport below.
### EWS Transport (on-prem Exchange)
Email can be submitted through **Exchange Web Services** (SOAP over HTTPS) instead of SMTP, for networks where authenticated SMTP submission is not offered. The transport is chosen per configuration row via **Transport** = `Ews`; the SMTP/MailKit path is untouched and remains the default. Nothing changes for sites or scripts — delivery stays central-only and `Notify.To(...).Send(...)` behaves identically. Design rationale: [`docs/plans/2026-08-10-ews-email-transport-design.md`](../plans/2026-08-10-ews-email-transport-design.md).
**Configuration model (field reuse).** No new entity or table — the same `SmtpConfiguration` row carries both transports:
- **Host** — under `Ews`, the **full EWS endpoint URL**, an absolute `https://` URI (e.g. `https://mail.example.com/ews/exchange.asmx`), not a mail-server hostname.
- **Authentication mode** — must be `basic`. There is no OAuth2 or NTLM/Negotiate path on the EWS transport.
- **Credentials** — `username:password`, split on the **first** `:` only so a password may contain colons; the username may be domain-qualified (`domain\user`). Stored encrypted at rest via `EncryptedStringConverter`, as on the SMTP path, and projected away from every read path (`notification smtp list`, management responses, audit afterState).
- **From address**, **Connection timeout**, **Retry settings** — same meaning; the timeout becomes the per-request HTTP timeout.
- **Port**, **TLS mode**, **OAuth2 authority/scope**, **Max concurrent connections****unused** under `Ews`. The Central UI hides them and the CLI ignores them for this transport.
**Auth posture.** HTTP Basic over HTTPS only, sent as an explicit per-request `Authorization` header (never set on the shared, pooled `HttpClient`, which would leak the credential between callers). HTTPS is enforced in **three** places — the management write gate, the delivery adapter, and again inside the sender as defense in depth — so a Basic credential can never leave the process over cleartext even if a row is edited around a higher layer. **Revisit trigger:** if Basic is disabled on the EWS virtual directory, an auth-mode knob (Negotiate/NTLM) becomes the documented follow-on; Linux containers would then also need `gss-ntlmssp` in the image.
**Message semantics.** One `CreateItem` SOAP request per notification, `RequestServerVersion Exchange2013`, `MessageDisposition="SendOnly"`, recipients in **`BccRecipients` only**, body `BodyType="Text"`. BCC-only preserves the SMTP path's recipient handling (recipients cannot see each other — see Recipient Handling (Email) below). `SendOnly` deliberately leaves no Sent-Items copy: the central `Notifications` table is the audit record of what was sent, and a mailbox copy would grow unboundedly. All user content is XML-escaped. No SDK is used — a hand-rolled envelope over `HttpClient`, matching the no-SDK house pattern set by the SMS adapter.
**Error classification (EWS).** Classification lives inside the EWS sender, which throws typed transient/permanent exceptions that the Email adapter maps to the same three-way `DeliveryOutcome` as SMTP (there is no standalone classifier type). **What Exchange said beats what HTTP reported**: a parsed response code decides the outcome even on an HTTP 500, and only an unparseable body falls back to the status code.
- **Transient** (retry, then park after `MaxRetries`): connect/DNS/socket failures and request timeouts; HTTP 408/429/5xx on an unparseable body; and the availability-shaped response codes `ErrorServerBusy`, `ErrorInternalServerTransientError`, `ErrorTimeoutExpired`, `ErrorMailboxStoreUnavailable`, `ErrorInsufficientResources`.
- **Permanent** (park immediately): HTTP 401/403 (retrying a bad credential burns a domain account's lockout budget), 404/410/405 (wrong URL or endpoint), SOAP faults, recipient-shaped and schema response codes, and configuration defects found before the send (non-https or relative endpoint, non-Basic auth type, credentials not in `username:password` form). **Unclassified defaults to permanent**, matching the SMTP adapter's stance.
- Every surfaced message runs through `CredentialRedactor`, which masks both the packed `username:password` and its base64 Basic-auth encoding; attempts log the endpoint host and a recipient **count** only.
**Response parsing.** EWS responses are parsed with DTD processing **prohibited** and no external entity resolver, so a hostile or malfunctioning endpoint cannot drive entity expansion or external-entity retrieval from the delivery path. A malformed body is reported as unparseable rather than throwing.
**Validation layering.** The delivery adapter is the **authoritative** check — it re-validates the EWS shape at delivery time and parks the notification with a clear reason if the row is unusable, so no path can produce a silently broken send:
- **Management write gate** (`ManagementActor`, covering the CLI and the management API) rejects an unknown transport, and for `Ews` requires an absolute https `Host`, `basic` auth mode, and `username:password` credentials. This catches the common mistake of flipping an existing SMTP row — whose credentials are a bare password — to `Ews`.
- **Central UI** `/notifications/smtp` writes through the notification repository directly rather than through the management gate, so the page mirrors the same https + Basic + credential-form rules client-side before saving.
- **Transport bundle import** deliberately does **not** gate EWS shape: an imported row is applied as data, and an unusable one surfaces as a parked notification with the adapter's error. Accepted — import is an environment-migration path where the operator remaps values anyway.
## SMS Configuration
@@ -117,6 +148,7 @@ Each `Deliver(...)` call returns one of `success | transient failure | permanent
- **Permanent failures** (SMTP 5xx permanent errors, e.g., mailbox not found): The Notification Outbox moves the row to `Parked` with the error in `LastError`. The notification will never deliver, and an operator can review or discard it on the Central UI Notification Outbox page.
- Retries exhausted on a transient failure also result in a `Parked` row.
- A script observes failures only by calling `Notify.Status(id)` and seeing a `Parked` status — not as a synchronous exception.
- The bullets above describe the SMTP transport. The EWS transport produces the same three outcomes from its own classification rules — see EWS Transport above.
### No Rate Limiting
- No application-level rate limiting. If the delivery endpoint enforces sending limits (e.g., Microsoft 365 throttling or Twilio rate limits), those manifest as transient failures and are retried naturally by the Notification Outbox.
+2
View File
@@ -123,6 +123,8 @@ Use `--host` and `--port` to override SMTP defaults (localhost:1025), `--api` fo
## Notes
- Mailpit does **not** support OAuth2 Client Credentials authentication. To test the OAuth2 code path, use a real Microsoft 365 tenant (see Q12 in `docs/plans/questions.md`).
- That OAuth2 tenant gap is **superseded for on-prem deployments** by the EWS email transport (`SmtpConfiguration.Transport = Ews`), which submits via Exchange Web Services over HTTPS with Basic auth and needs no tenant — see `docs/plans/2026-08-10-ews-email-transport-design.md`. Q12 is closed as superseded; the OAuth2 SMTP path stays config-selectable but untested against a live tenant.
- EWS has **no local test container**: its unit tests drive an in-process stub endpoint (envelope shape, Basic header, canned success / SOAP-fault / HTTP-error bodies), and the live gate against a real Exchange server is a **manual, one-off** exercise. Mailpit remains the SMTP-path harness and is unaffected.
- To simulate SMTP failures for store-and-forward testing, stop the container: `docker compose stop smtp`. Restart with `docker compose start smtp`.
- The web UI at `http://localhost:8025` provides real-time message inspection, search, and message source viewing.
- No data persistence — messages are stored in a temporary database inside the container and lost on container removal.