Files
ScadaBridge/docs/plans/2026-08-10-ews-email-transport-design.md
T

14 KiB

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; EwsIEwsMailSender. 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.