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
@@ -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). |
---