Files
ScadaBridge/docs/plans/2026-08-10-ews-email-transport-design.md
T
Joseph Doherty b2ea9c6c74 docs(notifications): design EWS email transport for the outbox; close Q12 as superseded
On-prem Exchange 2013 EWS (Basic over HTTPS, live-probed) becomes a second
selectable email transport beside SMTP: additive SmtpConfiguration.Transport
discriminator, hand-rolled CreateItem SOAP sender (no SDK, BCC-only,
SendOnly), EwsErrorClassifier mirroring the SMTP transient/permanent split,
CLI/UI transport selector, fake-EWS unit stub + one-off live gate. The
pending O365 SMTP-OAuth2 verification (Q12) is superseded — real mail infra
is on-prem EWS. Design only; no implementation. Also gitignore the untracked
dev-credential file email_details.txt.
2026-08-10 05:56:41 -04:00

160 lines
11 KiB
Markdown

# EWS Email Transport for the Notification Outbox — Design
**Date:** 2026-08-10 · **Status:** Designed, not implemented · **Owner decisions captured below.**
## 1. Context — the pending task this supersedes
The long-deferred "Office 365 / Exchange" email work is **Q12** (`docs/plans/questions.md:13`,
`docs/plans/phase-7-integrations.md:466`): *"What Microsoft 365 tenant/app registration is
available for SMTP OAuth2 testing?"* The OAuth2 Client-Credentials SMTP path
(`OAuth2TokenService`, XOAUTH2 in `MailKitSmtpClientWrapper`) shipped in Phase 7 but was never
verified against a real tenant — Mailpit cannot speak OAuth2 (`docs/test_infra/test_infra_smtp.md:125`),
and `docs/deployment/production-checklist.md:46` still assumes OAuth2-over-587 as the production
posture.
**Q12's premise is now obsolete.** The real mail infrastructure on the network where ScadaBridge
runs is an **on-prem Exchange 2013** server exposing **EWS** (SOAP over HTTPS) at
`https://webmail.zimmer.com/ews/exchange.asmx`, with a service mailbox
(`ww_notify@zimmerbiomet.com`, domain account `nam\ww_notify`). Dev/test credentials live in the
**untracked, git-ignored** `email_details.txt` — they must never enter the repo, this doc, or a
Transport bundle exported for another org.
### Decision on Q12 (owner, 2026-08-10)
**Close Q12 as superseded.** The OAuth2 SMTP code path stays in place, config-selectable and
unit-tested, but "untested against a real M365 tenant" becomes an accepted, documented state
rather than a pending task. If email ever moves to Exchange Online, Q12's question revives.
## 2. Live evidence (probed 2026-08-10 from the dev Mac)
| Probe | Result | Consequence |
|---|---|---|
| Unauthenticated `GET /ews/exchange.asmx` | `401`, `WWW-Authenticate: Negotiate, NTLM, Basic` — IIS 8.5, OWA 15.0.1497 (Exchange 2013) | Basic auth is enabled on the EWS vdir; **NTLM is not required** |
| Authenticated GET with HTTPS Basic (`nam\ww_notify`) | `200` | Basic works for the service account; the client is a plain `HttpClient` — cross-platform, macOS-testable, no GSSAPI/NTLM machinery |
| SMTP ports on the same host | 25 open; **587/465 closed** | Authenticated SMTP submission is not offered. Port 25 relay would be unauthenticated + receive-connector-IP-scoped — brittle, needs Exchange admin action per environment. Rejected as the transport (see Approach C) |
## 3. Approaches considered
### A. Transport mode inside the existing email pipeline — **CHOSEN**
Add a `Transport` discriminator (`Smtp` default / `Ews`) to the existing `SmtpConfiguration`
row. `EmailNotificationDeliveryAdapter` branches after loading the config: the SMTP/MailKit path
is untouched; the EWS path sends one SOAP `CreateItem` via a new seam. Everything around
delivery — outbox lifecycle, retry/park, recipient resolution, KPIs, UI/CLI surface, bundle
SecretsBlock travel — is reused unchanged.
*Trade-offs:* smallest diff; one additive EF column; slight impurity of "SmtpConfiguration"
naming now covering two transports (accepted — it is THE email config row, and renaming the
entity/table would ripple through bundles and repos for cosmetics).
### B. Separate `EwsConfiguration` entity + separate delivery adapter — rejected
A parallel entity/table/UI page/CLI noun and a second Email-type adapter. The outbox dispatcher
keys adapters by `NotificationType`, so two Email adapters need a dispatcher change plus a
which-config-wins rule; Transport bundles need a new artifact kind. Roughly double the surface
for no behavioural gain.
### C. No-code: point the existing SMTP adapter at Exchange — rejected on evidence
Would be zero code, but the probe shows authenticated submission (587/465) is closed. Port-25
relay is anonymous, scoped by source IP on a receive connector, and an Exchange admin change per
environment — an unauthenticated, brittle dependency the moment containers move hosts.
### EWS client style (owner decision)
**Hand-rolled SOAP envelope over `HttpClient` — no SDK**, matching the house pattern set by
`SmsNotificationDeliveryAdapter` (Twilio REST, no SDK). The Microsoft EWS Managed API is
archived; the community fork is a heavyweight dependency for what is one fixed `CreateItem`
call (~200 lines including classification).
## 4. Design
### 4.1 Configuration model
- `SmtpConfiguration` (Commons POCO + `SmtpConfigurations` table) gains one **additive** column:
`Transport` (`string`, default `"Smtp"`; parsed case-insensitively like `TlsMode`). One EF
migration; existing rows keep SMTP semantics with no data change.
- Field reuse under `Transport = "Ews"`:
- `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.
- `FromAddress`, `MaxRetries`, `RetryDelay`, `ConnectionTimeoutSeconds` — same meaning
(timeout becomes the `HttpClient` request timeout).
- `Port`, `TlsMode`, `OAuth2Authority`, `OAuth2Scope`, `MaxConcurrentConnections` — unused for
EWS; validation requires them unset/ignored and the UI hides them.
- Validation (in `HandleUpdateSmtpConfig` + options validator, eager per §1.5 conventions):
unknown `Transport` rejected; `Ews` requires absolute https `Host`, `basic` auth, non-empty
credentials; `Smtp` validation unchanged.
### 4.2 Delivery path
- New seam in the NotificationService project: `IEwsMailSender` with one method
`SendAsync(EwsSendRequest, CancellationToken)` (endpoint, credential, from, bcc list, subject,
plain-text body). Implementation `EwsSoapMailSender`:
- `IHttpClientFactory` named client; **explicit `Authorization: Basic` header** (deterministic,
proven live) rather than handler-negotiated auth. If Basic is ever disabled in a hardening
pass, an `EwsAuthMode` knob (Negotiate/NTLM via `HttpClientHandler.Credentials`) is the
documented follow-on — not built now (YAGNI), and noted that NTLM-on-Linux containers would
then need `gss-ntlmssp` in the image.
- One `CreateItem` SOAP envelope, `RequestServerVersion Exchange2013`,
`MessageDisposition="SendOnly"` (no Sent-Items copy — the `Notifications` table is the audit
record of what was sent; a mailbox copy would just grow unboundedly). Recipients go in
`BccRecipients` only, preserving the SMTP path's BCC semantics (recipients can't see each
other). All user content XML-escaped; body `BodyType="Text"` (plain text, as today).
- `EmailNotificationDeliveryAdapter.DeliverAsync` branches on the parsed transport after the
existing list-resolution/config/address-validation steps (all shared): `Smtp` → current
`SendAsync`; `Ews``IEwsMailSender`. Outcome mapping stays three-way `DeliveryOutcome`.
### 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`).
- **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`.
### 4.4 Management surfaces
- **CLI:** `notification smtp update` gains `--transport smtp|ews` (existing `--host`,
`--auth-type`, `--credentials`, retry flags reused). `notification smtp list` shows the
transport; credential-free projection unchanged.
- **Central UI `/notifications/smtp`:** a Transport selector; EWS mode shows URL + username +
password inputs and hides Port/TLS/OAuth2 inputs (which remain SMTP-only, OAuth2 inputs
remaining auth-type-gated as today). Admin-only, as today.
- **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.
### 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`;
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
dev/test credentials (from the untracked `email_details.txt`) against
`webmail.zimmer.com`, send to a single-recipient test list, verify Delivered status +
received mail + a permanent-classification case (bad password on a throwaway config —
mindful of domain lockout policy, one attempt only). Recorded as a PASS note in this doc when
run.
- Mailpit-based SMTP tests are untouched (the SMTP path is untouched).
### 4.6 Documentation updates that travel with the implementation
- `Component-NotificationService.md`: EWS transport section (config model, classification,
BCC/SendOnly semantics, auth posture).
- `docs/plans/questions.md` Q12 + `phase-7-integrations.md` Q12 row: closed as superseded,
pointing here.
- `docs/deployment/production-checklist.md`: EWS variant (outbound HTTPS 443 to the Exchange
CAS; service-account password rotation note) alongside the SMTP items.
- `docs/test_infra/test_infra_smtp.md`: note that the OAuth2 gap is superseded by EWS for
on-prem deployments.
## 5. Explicitly out of scope
- 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.