187 lines
13 KiB
Markdown
187 lines
13 KiB
Markdown
# EWS Email Transport for the Notification Outbox — Design
|
|
|
|
**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
|
|
|
|
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: `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
|
|
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; 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/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`,
|
|
`--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. **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 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
|
|
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).
|
|
- 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.
|