# EWS Email Transport for the Notification Outbox — Design

**Date:** 2026-08-10 · **Status:** Implemented, merged to main; **live gate PASS 2026-08-10** · **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 a stubbed `HttpMessageHandler` (in-process fake EWS endpoint)
  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.

### Live gate result (2026-08-10) — PASS
Run on the docker rig, rebuilt from main with the EWS image. The EWS configuration was created
through the new Central UI transport selector (its own first live exercise), pointing at the
real on-prem endpoint with the dev service mailbox. One SoakNotify tick was enabled for ~9 s;
the resulting notification delivered on the **first attempt** (`Status=Delivered`,
`RetryCount=0`, resolved target = the tester's real mailbox) through
`https://webmail.zimmer.com/ews/exchange.asmx`, and the message was received. Pre-existing
parked rows (from the no-config era) were untouched. The rig was then restored to baseline:
SMTP config row removed, notification list recipient restored, soak instance disabled.
Note: the rig's LDAP login was found broken by the SEC-36 GLAuth service-account rotation
(stale `serviceaccount123` in docker-compose); the gate ran under the Development-only
`DisableLogin` switch, left active pending the rotated credential.

## 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.
4. **Central UI SMTP page bypasses audit.** `SmtpConfiguration.razor` writes through
   `INotificationRepository` directly (pre-existing pattern), so a UI change to `Transport` or the
   EWS credential produces no audit row, while the same change via CLI/API is audited by
   `ManagementActor`. Made more consequential by EWS; route the page through
   `UpdateSmtpConfigCommand` (or add a direct audit call) in a follow-up.
