docs(notifications): EWS transport docs; close Q12 as superseded; design-doc corrections from execution reviews
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user