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 +SmtpConfigurationstable) gains one additive column:Transport(string, default"Smtp"; parsed case-insensitively likeTlsMode). One EF migration; existing rows keep SMTP semantics with no data change.- Field reuse under
Transport = "Ews":Host— the full EWS endpoint URL (absolutehttps://URI, validated).AuthType— must bebasic;Credentialsstaysusername:passwordwhere username may bedomain\user(split on the first:only, as today). Same storage treatment as SMTP credentials:SmtpConfiguration.Credentialsis encrypted at rest viaEncryptedStringConverter(wired inScadaBridgeDbContext.ApplySecretColumnEncryption,ConfigurationDatabase), exactly likeSmsConfiguration.AuthToken— there is no asymmetry between them. It is additionally projected away from all read paths byManagementActor's credential-free projection, andCredentialRedactor.Scrubruns on every error message.FromAddress,MaxRetries,RetryDelay,ConnectionTimeoutSeconds— same meaning (timeout becomes theHttpClientrequest 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): unknownTransportrejected;Ewsrequires absolute httpsHost,basicauth, non-empty credentials;Smtpvalidation unchanged.
4.2 Delivery path
- New seam in the NotificationService project:
IEwsMailSenderwith one methodSendAsync(EwsSendRequest, CancellationToken)(endpoint, credential, from, bcc list, subject, plain-text body). ImplementationEwsSoapMailSender:IHttpClientFactorynamed client; explicitAuthorization: Basicheader (deterministic, proven live) rather than handler-negotiated auth. If Basic is ever disabled in a hardening pass, anEwsAuthModeknob (Negotiate/NTLM viaHttpClientHandler.Credentials) is the documented follow-on — not built now (YAGNI), and noted that NTLM-on-Linux containers would then needgss-ntlmsspin the image.- One
CreateItemSOAP envelope,RequestServerVersion Exchange2013,MessageDisposition="SendOnly"(no Sent-Items copy — theNotificationstable is the audit record of what was sent; a mailbox copy would just grow unboundedly). Recipients go inBccRecipientsonly, preserving the SMTP path's BCC semantics (recipients can't see each other). All user content XML-escaped; bodyBodyType="Text"(plain text, as today).
EmailNotificationDeliveryAdapter.DeliverAsyncbranches on the parsed transport after the existing list-resolution/config/address-validation steps (all shared):Smtp→ currentSendAsync;Ews→IEwsMailSender. Outcome mapping stays three-wayDeliveryOutcome.
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
EwsErrorClassifiertype. Classification lives insideEwsSoapMailSender, which throws typedEwsTransientException/EwsPermanentExceptionthat the adapter maps toDeliveryOutcome; the pureEwsResponseParserstays 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 throughCredentialRedactor.Scrub, plus a mask of the base64 Basic-auth value.
4.4 Management surfaces
- CLI:
notification smtp updategains--transport smtp|ews(existing--host,--auth-type,--credentials, retry flags reused).notification smtp listshows 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:
SmtpConfigurationalready travels in the encrypted SecretsBlock; the new field rides along additively (schemaVersionadditive rules). The existing round-trip guard (arch-review T8 style) is extended to coverTransportso export/import can't silently drop it. Discovered during implementation:OAuth2AuthorityandOAuth2Scope(shipped 2026-07) were already being silently dropped by bundle export/import —SmtpConfigDtonever carried them. Fixed in the same slice asTransport(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:
EwsSoapMailSenderagainst a stubbedHttpMessageHandler(in-process fake EWS endpoint) asserting the Basic header, envelope shape (BCC-only, SendOnly, escaping) and driving cannedCreateItemResponsesuccess / 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=Ewswith the dev/test credentials (from the untrackedemail_details.txt) againstwebmail.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.mdQ12 +phase-7-integrations.mdQ12 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.
- 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. - Bundle-import shape validation parity. Import applies
SmtpConfigDtofields as data and does not run the EWS shape gate thatManagementActor(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. CredentialRedactor.MinSecretLength= 12. A standalone password shorter than that is not scrubbed on its own; EWS messages are still covered by the packedusername:passwordand base64 Basic-auth scrubs, which comfortably exceed the floor. Revisit if a code path ever surfaces a bare short password.- Central UI SMTP page bypasses audit.
SmtpConfiguration.razorwrites throughINotificationRepositorydirectly (pre-existing pattern), so a UI change toTransportor the EWS credential produces no audit row, while the same change via CLI/API is audited byManagementActor. Made more consequential by EWS; route the page throughUpdateSmtpConfigCommand(or add a direct audit call) in a follow-up.