Compare commits
22 Commits
e9c412e528
...
b6f383a225
| Author | SHA1 | Date | |
|---|---|---|---|
| b6f383a225 | |||
| ba994a59c5 | |||
| 05d0631cdd | |||
| 00d8a923af | |||
| 82f52e81ce | |||
| 3e490f2a7d | |||
| acbb9eafa5 | |||
| 8df15b34b8 | |||
| 2f217f5742 | |||
| 6f8b9c755d | |||
| 0fe1972960 | |||
| 4e633b1e64 | |||
| 8657fae14f | |||
| 08957cc907 | |||
| 2ee5586406 | |||
| 6864890e5f | |||
| abc58e6394 | |||
| abb1581d45 | |||
| 3da84825fc | |||
| d21b7a5a48 | |||
| 9f4d7d4bcb | |||
| b2ea9c6c74 |
@@ -55,3 +55,4 @@ docker-env2/*/data/
|
||||
|
||||
# Sister-project deployment artifacts (not part of this solution)
|
||||
/deploy/
|
||||
email_details.txt
|
||||
|
||||
@@ -106,6 +106,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- Dual call modes: `ExternalSystem.Call()` (synchronous) and `ExternalSystem.CachedCall()` (store-and-forward on transient failure).
|
||||
- Error classification: HTTP 5xx/408/429/connection errors = transient; other 4xx = permanent (returned to script).
|
||||
- Notification Service: SMTP with OAuth2 Client Credentials (Microsoft 365) or Basic Auth. BCC delivery, plain text.
|
||||
- Email delivery has **two transports**, selected per config row by `SmtpConfiguration.Transport` (null/`Smtp` = default): SMTP (MailKit, Basic/OAuth2) or on-prem Exchange **EWS** (no-SDK `CreateItem` SOAP over `HttpClient`, Basic-over-HTTPS only — https enforced at write gate, adapter AND sender — BCC-only recipients, `SendOnly` so no Sent-Items copy). Under `Ews`, `Host` is the full EWS URL, `Credentials` is `username:password`, and Port/TlsMode/OAuth2*/MaxConcurrentConnections are unused. Site-facing behavior is unchanged (delivery stays central-only). Q12 (M365 OAuth2 tenant) is closed as **superseded** — the OAuth2 SMTP path stays config-selectable but untested against a live tenant. Design: `docs/plans/2026-08-10-ews-email-transport-design.md`.
|
||||
- Notification delivery is central-only: sites store-and-forward notifications to the central cluster (target = central, not SMTP); sites never talk to SMTP. Notification lists and SMTP config are no longer deployed to sites; recipient resolution happens at central, at delivery time.
|
||||
- Notification lists carry a `Type` discriminator (`Email` and `Sms`). `Notify.To("list")` is type-agnostic; delivery is via per-type `INotificationDeliveryAdapter` (Email via SMTP; Sms via Twilio REST — `SmsNotificationDeliveryAdapter`, no SDK, one POST per recipient, per-recipient rollup). List Type is fixed after creation.
|
||||
- `Notify.Send` is async and **enqueue-only** — it buffers the notification into the local SQLite S&F store and returns a `NotificationId` (GUID, idempotency key) status handle immediately; it never runs the forwarder's central Ask inline on the script thread (`deferToSweep: true` buffers due-immediately + kicks a background sweep), so its worst-case latency is the local insert whether central is up or down. It enqueues with `maxRetries: 0` (the "no limit" escape hatch), so notifications retry until central acks and are **never parked for retry exhaustion** — only a corrupt payload parks them (arch-review 02, Tasks 13/14). `Notify.Status(notificationId)` returns a status record (status, retry count, last error, key timestamps); answered site-locally as `Forwarding` while still in the site S&F buffer, otherwise round-trips to central.
|
||||
|
||||
@@ -16,7 +16,7 @@ This repository is the full **implementation** project for ScadaBridge — the C
|
||||
| Central Database | MS SQL Server, Entity Framework Core |
|
||||
| Site Storage | SQLite (deployed configs, S&F buffer, event logs) |
|
||||
| Authentication | Direct LDAP/AD bind (LDAPS/StartTLS), JWT sessions |
|
||||
| Notifications | Delivered from the central cluster (Email via SMTP/OAuth2-M365; SMS via Twilio REST); store-and-forwarded from sites |
|
||||
| Notifications | Delivered from the central cluster (Email via SMTP/OAuth2-M365 or on-prem Exchange EWS; SMS via Twilio REST); store-and-forwarded from sites |
|
||||
| Hosting | Windows Server, Windows Service |
|
||||
| Cluster | Akka.NET Cluster (active/standby, keep-oldest SBR) |
|
||||
| Logging | Serilog (structured) |
|
||||
@@ -83,7 +83,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
|
||||
| 5 | Central–Site Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 9 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. The gRPC stream additively carries the read-only native alarm mirror (computed + native OPC UA / MxAccess) via the enriched `AlarmStateUpdate`. |
|
||||
| 6 | Store-and-Forward Engine | [docs/requirements/Component-StoreAndForward.md](docs/requirements/Component-StoreAndForward.md) | Buffering (transient failures only), fixed-interval retry, parking, async best-effort replication, SQLite persistence at sites. |
|
||||
| 7 | External System Gateway | [docs/requirements/Component-ExternalSystemGateway.md](docs/requirements/Component-ExternalSystemGateway.md) | HTTP/REST + JSON, API key/Basic Auth, per-system timeout, dual call modes (Call/CachedCall), transient/permanent error classification, dedicated blocking I/O dispatcher, ADO.NET connection pooling. |
|
||||
| 8 | Notification Service | [docs/requirements/Component-NotificationService.md](docs/requirements/Component-NotificationService.md) | Central-only — manages typed notification-list, SMTP, and SMS definitions; supplies per-type delivery adapters (Email via SMTP with OAuth2 (M365) or Basic Auth, BCC, plain text; SMS via Twilio REST, per-recipient, outbound-only); delivery performed by the Notification Outbox. |
|
||||
| 8 | Notification Service | [docs/requirements/Component-NotificationService.md](docs/requirements/Component-NotificationService.md) | Central-only — manages typed notification-list, SMTP, and SMS definitions; supplies per-type delivery adapters (Email via SMTP with OAuth2 (M365) or Basic Auth, or on-prem Exchange EWS, BCC, plain text; SMS via Twilio REST, per-recipient, outbound-only); delivery performed by the Notification Outbox. |
|
||||
| 9 | Central UI | [docs/requirements/Component-CentralUI.md](docs/requirements/Component-CentralUI.md) | Blazor Server with SignalR real-time push, load balancer failover with JWT, all management workflows. Custom-content modal host (`DialogService.ShowAsync<T>`) with focus-trap/restore; dark-mode CSS-variable token layer (`[data-bs-theme="dark"]` overriding `ZB.MOM.WW.Theme` tokens in `site.css`, `localStorage`-persisted, SSR no-flash); reusable presentational components `OffsetPager`, `KeysetPager`, and `DateTimeRangeFilter` adopted across report/audit pages. |
|
||||
| 10 | Security & Auth | [docs/requirements/Component-Security.md](docs/requirements/Component-Security.md) | Direct LDAP bind (LDAPS/StartTLS), JWT sessions (HMAC-SHA256, 15-min refresh, 30-min idle), role-based authorization (incl. the `Operator`/`Verifier` two-person secured-write roles + policies), site-scoped permissions. |
|
||||
| 11 | Health Monitoring | [docs/requirements/Component-HealthMonitoring.md](docs/requirements/Component-HealthMonitoring.md) | 30s report interval, 60s offline threshold, monotonic sequence numbers, raw error counts, tag resolution counts, dead letter monitoring. |
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
- [ ] Windows Service account has minimum necessary permissions
|
||||
- [ ] Log directory permissions restrict access to service account and administrators
|
||||
- [ ] SMTP credentials use OAuth2 Client Credentials (preferred) or secure Basic Auth
|
||||
- [ ] EWS transport (`Transport=Ews`): endpoint is an absolute `https://` URL and auth mode is Basic — Basic requires TLS and this is enforced at the write gate, the delivery adapter, and the sender
|
||||
- [ ] EWS transport: the Exchange service-account password is rotated on the account-owner's schedule, and the SMTP configuration row is updated in the same change
|
||||
- [ ] API keys for Inbound API are generated with sufficient entropy (32+ chars)
|
||||
|
||||
### Network
|
||||
@@ -51,7 +53,8 @@
|
||||
- [ ] DNS resolution works between all cluster nodes
|
||||
- [ ] Firewall rules permit Akka.NET remoting (TCP 8081)
|
||||
- [ ] Firewall rules permit LDAP (TCP 636 for LDAPS)
|
||||
- [ ] Firewall rules permit SMTP (TCP 587 for TLS)
|
||||
- [ ] Firewall rules permit SMTP (TCP 587 for TLS) — SMTP transport only
|
||||
- [ ] EWS transport (`Transport=Ews`): firewall rules permit outbound HTTPS (TCP 443) from central nodes to the Exchange CAS instead of SMTP 587
|
||||
- [ ] Firewall rules permit SQL Server (TCP 1433) from central nodes only
|
||||
- [ ] Load balancer health check configured against `/health/ready`
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,438 @@
|
||||
# EWS Email Transport Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development in-session) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add on-prem Exchange EWS (SOAP over HTTPS, Basic auth) as a second selectable email transport beside SMTP in the central Notification Outbox, per `docs/plans/2026-08-10-ews-email-transport-design.md`.
|
||||
|
||||
**Architecture:** One additive `Transport` discriminator (`Smtp` default / `Ews`) on the existing `SmtpConfiguration` row; `EmailNotificationDeliveryAdapter` branches after config load to a new no-SDK `EwsSoapMailSender` (one `CreateItem` SOAP POST, BCC-only, `SendOnly`, explicit Basic header). Everything else — outbox lifecycle, retry/park, recipient resolution, KPIs, UI/CLI surface, bundle SecretsBlock — is reused.
|
||||
|
||||
**Tech Stack:** .NET 10, xUnit, EF Core (MS SQL, one additive migration), `IHttpClientFactory`, hand-rolled SOAP (no EWS SDK). Solution: `ZB.MOM.WW.ScadaBridge.slnx`.
|
||||
|
||||
**Execution environment:** Work in a dedicated worktree (the `EnterWorktree` tool is broken here — create manually):
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/ScadaBridge worktree add /Users/dohertj2/Desktop/ScadaBridge-wt-ews -b feature/ews-email-transport main
|
||||
cd /Users/dohertj2/Desktop/ScadaBridge-wt-ews # ALWAYS use absolute paths; Bash cwd can drift
|
||||
```
|
||||
All file paths below are worktree-relative. Run tests with explicit `--filter` (targeted-tests rule); full `dotnet build ZB.MOM.WW.ScadaBridge.slnx` at the end, not per task.
|
||||
|
||||
**Never** put the contents of `email_details.txt` (untracked, git-ignored) in code, tests, or docs. Test fixtures use fake hosts/credentials only.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: EmailTransport enum + parser (NotificationService)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 2, Task 3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/EmailTransport.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/EmailTransportParserTests.cs`
|
||||
|
||||
Mirror the existing `SmtpTlsMode`/`SmtpTlsModeParser` pattern (`src/ZB.MOM.WW.ScadaBridge.NotificationService/SmtpTlsMode.cs`) exactly — read it first.
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
public class EmailTransportParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, EmailTransport.Smtp)] // null/empty = legacy rows = SMTP
|
||||
[InlineData("", EmailTransport.Smtp)]
|
||||
[InlineData("Smtp", EmailTransport.Smtp)]
|
||||
[InlineData("smtp", EmailTransport.Smtp)]
|
||||
[InlineData("SMTP", EmailTransport.Smtp)]
|
||||
[InlineData("Ews", EmailTransport.Ews)]
|
||||
[InlineData("ews", EmailTransport.Ews)]
|
||||
[InlineData("EWS", EmailTransport.Ews)]
|
||||
public void Parse_recognizes_transports_case_insensitively(string? input, EmailTransport expected)
|
||||
=> Assert.Equal(expected, EmailTransportParser.Parse(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Graph")]
|
||||
[InlineData("smtps")]
|
||||
public void Parse_rejects_unknown_transport(string input)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => EmailTransportParser.Parse(input));
|
||||
Assert.Contains(input, ex.Message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
`dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests --filter EmailTransportParserTests -v minimal` → compile error (type missing).
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
|
||||
/// <summary>Email delivery transport selected by <c>SmtpConfiguration.Transport</c>.</summary>
|
||||
public enum EmailTransport
|
||||
{
|
||||
/// <summary>Direct SMTP submission via MailKit (the original path).</summary>
|
||||
Smtp,
|
||||
/// <summary>On-prem Exchange Web Services — one CreateItem SOAP call over HTTPS.</summary>
|
||||
Ews,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the stored transport string. Null/empty maps to <see cref="EmailTransport.Smtp"/>
|
||||
/// (every pre-EWS configuration row is an SMTP row); an unknown value throws
|
||||
/// <see cref="ArgumentException"/> — a configuration error retrying cannot fix, which the
|
||||
/// delivery adapter surfaces as a permanent failure (mirrors <c>SmtpTlsModeParser</c>).
|
||||
/// </summary>
|
||||
public static class EmailTransportParser
|
||||
{
|
||||
public static EmailTransport Parse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return EmailTransport.Smtp;
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"smtp" => EmailTransport.Smtp,
|
||||
"ews" => EmailTransport.Ews,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown email transport '{value}'. Expected one of: Smtp, Ews."),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests** → PASS.
|
||||
|
||||
**Step 5: Commit** — `git add` the two files; `git commit -m "feat(notifications): EmailTransport enum + parser for the EWS transport discriminator"`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SmtpConfiguration.Transport entity field + EF mapping + migration
|
||||
|
||||
**Classification:** high-risk (EF migration)
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1, Task 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/SmtpConfiguration.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationConfiguration.cs` (class `SmtpConfigurationConfiguration`, ~line 103)
|
||||
- Create: migration via CLI (see Step 3 — **build first**, never `--no-build`)
|
||||
|
||||
**Step 1: Add the property** (after `TlsMode`, before `FromAddress`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Gets or sets the email delivery transport ("Smtp" or "Ews"). Null/empty means Smtp
|
||||
/// (legacy rows). Under Ews, <see cref="Host"/> holds the full EWS endpoint URL,
|
||||
/// <see cref="AuthType"/> must be Basic, and Port/TlsMode/OAuth2* are unused.
|
||||
/// </summary>
|
||||
public string? Transport { get; set; }
|
||||
```
|
||||
|
||||
**Step 2: EF mapping** — in `SmtpConfigurationConfiguration.Configure`, after the `TlsMode` property block:
|
||||
|
||||
```csharp
|
||||
// Email transport discriminator ("Smtp"/"Ews"); null = Smtp for pre-EWS rows.
|
||||
builder.Property(s => s.Transport)
|
||||
.HasMaxLength(50);
|
||||
```
|
||||
|
||||
**Step 3: Migration** — from the worktree root:
|
||||
```bash
|
||||
dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase # NEVER scaffold off a stale DLL
|
||||
dotnet ef migrations add AddSmtpConfigurationTransport \
|
||||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||||
```
|
||||
Verify the generated migration adds exactly one nullable `nvarchar(50)` column `Transport` to `SmtpConfigurations` — if it is empty, the build was stale: delete the files and redo (see `ef-migrations-add-no-build-stale` gotcha).
|
||||
|
||||
**Step 4: Build + existing ConfigurationDatabase tests**
|
||||
`dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests -v minimal` → PASS.
|
||||
|
||||
**Step 5: Commit** — `feat(notifications): additive Transport column on SmtpConfigurations`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: EWS SOAP envelope builder + response parser (pure)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 1, Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapEnvelope.cs`
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsResponseParser.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs`
|
||||
|
||||
**Step 1: Failing tests.** Envelope: builds `CreateItem` with `MessageDisposition="SendOnly"`, `RequestServerVersion Version="Exchange2013"`, subject/body XML-escaped (`<`, `&`, `"` cases), plain-text `BodyType="Text"`, all recipients under `BccRecipients` (none in `ToRecipients` — assert the element is absent), `From` mailbox present, element order Subject → Body → BccRecipients → From (EWS `MessageType` is a schema `sequence`; wrong order = `ErrorSchemaValidation`). Parser: `(Success, "NoError")` for a success body; `(Error, "ErrorServerBusy")` from a `ResponseClass="Error"` message; SOAP 1.1 `<soap:Fault>` body → fault detail `ResponseCode` extracted (`ErrorSchemaValidation`); garbage input → `EwsParseResult.Unparseable`.
|
||||
|
||||
Use small inline XML literals for canned responses; namespaces: `soap=http://schemas.xmlsoap.org/soap/envelope/`, `m=…/2006/messages`, `t=…/2006/types`.
|
||||
|
||||
**Step 2: Run to verify failure.**
|
||||
|
||||
**Step 3: Implement.** `EwsSoapEnvelope.BuildCreateItem(string from, IReadOnlyList<string> bcc, string subject, string body)` returning `string` — build with `XDocument`/`XElement` (escaping for free), structure:
|
||||
|
||||
```xml
|
||||
<soap:Envelope xmlns:soap="…" xmlns:t="…/types" xmlns:m="…/messages">
|
||||
<soap:Header><t:RequestServerVersion Version="Exchange2013"/></soap:Header>
|
||||
<soap:Body>
|
||||
<m:CreateItem MessageDisposition="SendOnly">
|
||||
<m:Items><t:Message>
|
||||
<t:Subject>…</t:Subject>
|
||||
<t:Body BodyType="Text">…</t:Body>
|
||||
<t:BccRecipients><t:Mailbox><t:EmailAddress>…</t:EmailAddress></t:Mailbox>…</t:BccRecipients>
|
||||
<t:From><t:Mailbox><t:EmailAddress>…</t:EmailAddress></t:Mailbox></t:From>
|
||||
</t:Message></m:Items>
|
||||
</m:CreateItem>
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
```
|
||||
|
||||
`EwsResponseParser.Parse(string responseBody)` returning `EwsParseResult` record: `Kind` (`Success` | `Error` | `Fault` | `Unparseable`) + `ResponseCode` (string?) + `MessageText` (string?). Wrap `XDocument.Parse` in try/catch → `Unparseable`. Look for `CreateItemResponseMessage` `ResponseClass` attribute + `m:ResponseCode`; else `soap:Fault` → `detail//t:ResponseCode` (fall back to `faultstring`).
|
||||
|
||||
**Step 4: Run tests** → PASS. **Step 5: Commit** — `feat(notifications): EWS CreateItem envelope builder + response parser`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: EwsSoapMailSender + DI registration
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (needs Task 3)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/IEwsMailSender.cs` (+ `EwsSendRequest` record, `EwsTransientException`, `EwsPermanentException`)
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapMailSender.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs`
|
||||
|
||||
**Contract:**
|
||||
|
||||
```csharp
|
||||
/// <summary>One-shot EWS mail submission (CreateItem, SendOnly, BCC-only).</summary>
|
||||
public interface IEwsMailSender
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends via EWS. Throws <see cref="EwsPermanentException"/> for failures retrying cannot
|
||||
/// fix (auth 401/403, bad URL 404, schema faults, recipient rejections) and
|
||||
/// <see cref="EwsTransientException"/> for availability-shaped failures (network/DNS/timeout,
|
||||
/// HTTP 5xx/408/429, ErrorServerBusy-class response codes).
|
||||
/// </summary>
|
||||
Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record EwsSendRequest(
|
||||
Uri Endpoint, // absolute https EWS URL (from SmtpConfiguration.Host)
|
||||
string Username, // may be "domain\\user"
|
||||
string Password,
|
||||
string FromAddress,
|
||||
IReadOnlyList<string> BccRecipients,
|
||||
string Subject,
|
||||
string Body,
|
||||
int TimeoutSeconds); // non-positive = HttpClient default
|
||||
```
|
||||
|
||||
**Implementation notes (Step 3):**
|
||||
- `public const string HttpClientName = "EwsMail";` on the sender; ctor takes `IHttpClientFactory` + `ILogger<EwsSoapMailSender>`.
|
||||
- **Explicit Basic header** (deterministic — the live probe proved Basic enabled): `new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}")))` per request message — never on the shared client (pooled handler).
|
||||
- POST `StringContent(envelope, Encoding.UTF8, "text/xml")` (SOAP 1.1). Timeout via linked CTS (mirror `SmsNotificationDeliveryAdapter`, which leaves `HttpClient.Timeout` at default).
|
||||
- Classification (transient set chosen in design §4.3):
|
||||
- `HttpRequestException`, `TaskCanceledException` when NOT the caller's token → wrap in `EwsTransientException`. Caller-cancelled `OperationCanceledException` propagates unchanged.
|
||||
- HTTP 401/403/404/405/410 → `EwsPermanentException`; 408/429/5xx with **unparseable** body → `EwsTransientException`; other unparseable non-success → permanent.
|
||||
- Parsed body (any HTTP status): `ResponseCode` in `{ErrorServerBusy, ErrorInternalServerTransientError, ErrorTimeoutExpired, ErrorMailboxStoreUnavailable, ErrorInsufficientResources}` → transient; `Success`/`NoError` → return; any other `Error`/`Fault` code → permanent (message includes the code).
|
||||
- **Never log or embed the password**: exception messages carry status/response code/`MessageText` only; the Authorization header value must not appear in any message (add a test asserting the thrown message does not contain the base64 credential).
|
||||
|
||||
**Tests (Step 1, failing first):** stub `HttpMessageHandler` (capture request; canned responses). Assert: Basic header matches expected base64 of `user:pass`; content type `text/xml`; envelope contains the BCC recipient; success body → no throw; `ErrorServerBusy` body → `EwsTransientException`; 401 → `EwsPermanentException`; `ErrorSchemaValidation` fault (HTTP 500) → **permanent** (the critical case distinguishing parsed-fault-500 from bare-500); bare 503 → transient; socket failure (handler throws `HttpRequestException`) → transient; credential never in exception message.
|
||||
|
||||
**DI (in `AddNotificationService`):** `services.AddHttpClient(EwsSoapMailSender.HttpClientName);` + `services.TryAddSingleton<IEwsMailSender, EwsSoapMailSender>();` (stateless; factory-per-request handler).
|
||||
|
||||
**Steps 4/5:** filtered test run PASS → commit `feat(notifications): no-SDK EWS SOAP mail sender with typed transient/permanent classification`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: EmailNotificationDeliveryAdapter transport branch
|
||||
|
||||
**Classification:** high-risk (delivery path + outcome contract)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (needs Tasks 1, 2, 4)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs` (doc-comment only: note the EWS seam also comes from `AddNotificationService`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs` (new file; leave the existing SMTP adapter tests untouched — they must stay green unmodified, proving the SMTP path is untouched)
|
||||
|
||||
**Step 1 (failing tests):** with a config row `Transport="Ews"`, `Host="https://ews.example.test/ews/exchange.asmx"`, `AuthType="basic"`, `Credentials=@"dom\svc:pw"`:
|
||||
- happy path → `IEwsMailSender` (mock) receives endpoint/user `dom\svc`/pw/from/bcc/subject/body; outcome `Success`.
|
||||
- sender throws `EwsTransientException` → `Transient`; `EwsPermanentException` → `Permanent`.
|
||||
- config errors → `Permanent` WITHOUT calling the sender: non-absolute/non-https `Host`; `AuthType="oauth2"`; missing/`:`-less `Credentials`; unknown `Transport="Graph"`.
|
||||
- `Transport=null` and `Transport="Smtp"` still drive the SMTP path (existing `_smtpClientFactory` invoked, sender mock untouched).
|
||||
|
||||
**Step 2:** run new test file → fails.
|
||||
|
||||
**Step 3:** Adapter changes — ctor gains `IEwsMailSender? ewsMailSender = null` (optional, matching the `OAuth2TokenService?` pattern so existing test ctor calls compile — but UPDATE the DI doc comment). In `DeliverAsync`, after the SMTP-config load + multiple-row warning and BEFORE the TLS-mode parse, branch:
|
||||
|
||||
```csharp
|
||||
EmailTransport transport;
|
||||
try { transport = EmailTransportParser.Parse(smtpConfig.Transport); }
|
||||
catch (ArgumentException ex) { return DeliveryOutcome.Permanent(ex.Message); }
|
||||
|
||||
if (transport == EmailTransport.Ews)
|
||||
{
|
||||
return await DeliverViaEwsAsync(smtpConfig, recipients, notification, cancellationToken);
|
||||
}
|
||||
// existing SMTP path continues unchanged below
|
||||
```
|
||||
|
||||
`DeliverViaEwsAsync` (private): validate addresses via the existing `EmailAddressValidator.ValidateAddresses`; validate endpoint (`Uri.TryCreate` absolute + `https`), auth type `basic`, credentials split on FIRST `':'` (password may contain `:`? split-limit 2 like the SMTP path); null `_ewsMailSender` → `Permanent("EWS transport configured but no EWS sender is registered")`; call `SendAsync`; catch `EwsPermanentException` → `Permanent`, `EwsTransientException` → `Transient`, caller-cancelled OCE rethrow, anything else → `Permanent` (default-permanent stance) — every message through `CredentialRedactor.Scrub(msg, smtpConfig.Credentials)`.
|
||||
|
||||
**Step 4:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests --filter EmailNotificationDeliveryAdapter -v minimal` → ALL pass (new + pre-existing SMTP tests).
|
||||
|
||||
**Step 5:** Commit — `feat(notifications): EWS branch in the email delivery adapter`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Management command + validation + public shape
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 9
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs:11`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`SmtpConfigPublicShape` ~2114, `HandleUpdateSmtpConfig` ~2142)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs` (extend existing UpdateSmtpConfig coverage)
|
||||
|
||||
**Changes:**
|
||||
- `UpdateSmtpConfigCommand` gains a TRAILING optional param `string? Transport = null` (additive-only message evolution; Newtonsoft deserializes missing → null → preserve-if-null. Do NOT reorder existing params).
|
||||
- `HandleUpdateSmtpConfig`: `if (cmd.Transport is not null) config.Transport = cmd.Transport;` plus validation BEFORE save: parse via `EmailTransportParser.Parse(cmd.Transport ?? config.Transport)` → `ManagementCommandException` on `ArgumentException`; when the effective transport is Ews, require `config.Host` (post-assignment) to be an absolute `https` URI and effective `AuthType` to be `basic` — else `ManagementCommandException` with a message naming the offending field. (NotificationService is already referenced? **Check**: if `ManagementService.csproj` does not reference `ZB.MOM.WW.ScadaBridge.NotificationService`, inline the parse — a local `switch` on the lowercased string — instead of adding a project reference; keeping the dependency graph flat wins over DRY here.)
|
||||
- `SmtpConfigPublicShape` adds `c.Transport`.
|
||||
- Tests (failing first): update-with-`Transport="Ews"`+https host+basic → persisted, response shape carries Transport; `Transport="Graph"` → `ManagementCommandException`; Ews with `http://` host → rejected; omitted Transport preserves stored value. RequiredRoleMatrix is unchanged (no new command — do not touch the frozen matrix).
|
||||
|
||||
**Run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter UpdateSmtpConfig -v minimal` → PASS. Commit — `feat(management): Transport on UpdateSmtpConfigCommand with EWS shape validation`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: CLI --transport flag
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 8 (after Task 6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs` (BuildSmtp ~127, options block ~160, `BuildUpdateSmtpConfigCommand` ~207)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SmtpUpdateCommandTests.cs`
|
||||
|
||||
Add static `SmtpTransportOption`:
|
||||
|
||||
```csharp
|
||||
private static readonly Option<string?> SmtpTransportOption = CreateTransportOption();
|
||||
|
||||
private static Option<string?> CreateTransportOption()
|
||||
{
|
||||
var option = new Option<string?>("--transport")
|
||||
{
|
||||
Description = "Email transport: Smtp or Ews (optional; preserves existing if omitted). " +
|
||||
"Under Ews, --server is the full EWS URL (https://…/ews/exchange.asmx), " +
|
||||
"--auth-mode must be basic, and --port/--tls-mode are unused.",
|
||||
};
|
||||
option.AcceptOnlyFromAmong("Smtp", "Ews");
|
||||
return option;
|
||||
}
|
||||
```
|
||||
|
||||
Wire into `updateCmd.Add(...)` and `BuildUpdateSmtpConfigCommand` (pass as the new trailing arg). Tests failing-first in `SmtpUpdateCommandTests` (mirror existing option tests): `--transport Ews` maps through; omitted → null; `--transport Graph` → parse error.
|
||||
|
||||
**Run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter SmtpUpdate -v minimal` → PASS. Commit — `feat(cli): notification smtp update --transport smtp|ews`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Central UI transport selector
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 7 (after Task 6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor`
|
||||
|
||||
Read the whole page first (272 lines). Changes, following its existing patterns exactly (Bootstrap, `@bind`, no component libs):
|
||||
- Read view: a "Transport" row (badge, `@(smtp.Transport ?? "Smtp")`) above Host; when transport is Ews, relabel the Host row "EWS URL" and hide the Port/TLS rows; OAuth2 rows stay gated on AuthType as today. (The management list response now carries `Transport` — the page's row model needs the property added where `OAuth2Authority` etc. are declared.)
|
||||
- Edit form: `<select @bind="_transport">` with `Smtp`/`Ews` options, default from the loaded row (`?? "Smtp"`); when `_transport == "Ews"` hide Port + TLS + auth-type select (force-display `basic`, set `_authType = "basic"` on switch to Ews), show Host input labelled "EWS URL" with placeholder `https://mail.example.com/ews/exchange.asmx`; credentials placeholder `domain\username:password`. Pass `_transport` as the new `UpdateSmtpConfigCommand` trailing arg where the page builds the command (~line 237 area).
|
||||
- Playwright: `SmtpConfigTests` exist but are env-gated (live cluster) — do NOT add new Playwright coverage in this task; server-side validation is covered by Task 6.
|
||||
|
||||
**Verify:** `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI` → 0 errors. Commit — `feat(ui): EWS transport selector on /notifications/smtp`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Transport bundle carriage (Transport + the pre-existing OAuth2 drop)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs` (`SmtpConfigDto` ~288)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs` (serialize ~168–189, deserialize ~478–490)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs`
|
||||
|
||||
**Discovered defect this task also fixes:** `OAuth2Authority`/`OAuth2Scope` are NOT carried by `SmtpConfigDto` today — an exported bundle silently drops them on import (exactly the T8 silent-data-loss class). Fix alongside `Transport`.
|
||||
|
||||
- `SmtpConfigDto`: add THREE trailing nullable params (additive — old bundles deserialize them as null; serializer's WhenWritingNull keeps them out of old-shaped JSON): `string? OAuth2Authority = null, string? OAuth2Scope = null, string? Transport = null`.
|
||||
- Serializer: map all three from the entity. Deserializer: restore all three in the object initializer.
|
||||
- Tests failing-first (extend the existing SMTP round-trip test at ~line 53): a config with `Transport="Ews"`, `OAuth2Authority`/`OAuth2Scope` set survives export→import with all three intact; and a JSON payload WITHOUT the new properties (old bundle) deserializes with all three null.
|
||||
- `schemaVersion` stays (additive rule); do not bump `bundleFormatVersion`.
|
||||
|
||||
**Run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter EntitySerializer -v minimal` → PASS. Commit — `fix(transport): carry Transport + OAuth2 authority/scope on SmtpConfigDto (OAuth2 fields were silently dropped)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Docs sweep
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (last code-adjacent task; needs all prior merged)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/requirements/Component-NotificationService.md` — EWS transport section under the SMTP config section (~line 44): config model (field reuse table), Basic-over-HTTPS posture, BCC-only + SendOnly semantics, transient/permanent classification, the no-NTLM decision + its revisit trigger (Basic disabled on the EWS vdir).
|
||||
- Modify: `docs/plans/questions.md:13` + `docs/plans/phase-7-integrations.md:466` — Q12 status → "Closed 2026-08-10 as superseded — production mail is on-prem Exchange EWS; see docs/plans/2026-08-10-ews-email-transport-design.md".
|
||||
- Modify: `docs/deployment/production-checklist.md:46` — add the EWS variant lines (outbound HTTPS 443 to the Exchange CAS; service-account password rotation; Basic requires TLS) alongside the SMTP items.
|
||||
- Modify: `docs/test_infra/test_infra_smtp.md:125` — note the OAuth2 gap is superseded by EWS for on-prem deployments (pointer to the design doc).
|
||||
- Modify: `docs/plans/2026-08-10-ews-email-transport-design.md` §4.1 — correct "same storage treatment" note: Credentials ARE encrypted at rest via `EncryptedStringConverter` (ConfigurationDatabase), so no asymmetry with SmsConfiguration exists; and note the Task 9 OAuth2-drop fix.
|
||||
- Modify: `CLAUDE.md` — External Integrations bullet: one line stating email delivery supports SMTP or on-prem Exchange EWS via `SmtpConfiguration.Transport`, EWS = no-SDK CreateItem/Basic/BCC/SendOnly.
|
||||
- Modify: `README.md` component table row for Notification Service ONLY IF it names SMTP specifically (check first).
|
||||
|
||||
Commit — `docs(notifications): EWS transport docs; close Q12 as superseded`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Integration verify + merge back
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (plus build/test wall time)
|
||||
**Parallelizable with:** none
|
||||
|
||||
From the worktree root:
|
||||
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 errors / 0 warnings.
|
||||
2. Targeted suites (full-suite is a milestone-end exception — this is the milestone end for the feature, but per the targeted-tests rule run the impacted + consumer set, not the world):
|
||||
`dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests tests/ZB.MOM.WW.ScadaBridge.CLI.Tests tests/ZB.MOM.WW.ScadaBridge.Transport.Tests tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests -v minimal` → green (known env-only Playwright exclusions don't run here).
|
||||
3. Verify every task's commit is on the branch (`git log --oneline main..HEAD` — expect ~10 commits).
|
||||
4. **Do not merge to main or push** — report done; merging is the finishing-a-development-branch flow with the user.
|
||||
|
||||
**Known live-gate residue (manual, user-run, after merge):** configure `Transport=Ews` against `webmail.zimmer.com` with the dev/test credentials from the untracked `email_details.txt`, send to a single-recipient test list, verify Delivered + received mail. Not automatable from CI (real server, real creds).
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
T1 ─┬────────────► T5 ─► T11
|
||||
T2 ─┤ ▲
|
||||
T3 ─► T4 ────────┘
|
||||
T2 ─► T6 ─► T7 ─► T11
|
||||
└► T8 ─► T11
|
||||
T2 ─► T9 ─► T11
|
||||
T10 after T1–T9
|
||||
```
|
||||
Wave 1: T1, T2, T3 (parallel — file-disjoint). Wave 2: T4, T6, T9. Wave 3: T5, T7, T8. Wave 4: T10, then T11.
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-10-ews-email-transport.md",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: EmailTransport enum + parser",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: SmtpConfiguration.Transport entity + EF mapping + migration",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: EWS SOAP envelope builder + response parser",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: EwsSoapMailSender + DI registration",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: EmailNotificationDeliveryAdapter transport branch",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
2,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Management command + validation + public shape",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: CLI --transport flag",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: Central UI transport selector",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Transport bundle carriage (Transport + OAuth2 drop fix)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: Docs sweep",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: Integration verify + merge back",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-08-10"
|
||||
}
|
||||
@@ -463,7 +463,7 @@ Phase 7 is complete when:
|
||||
|
||||
| # | Question | Context | Impact | Status |
|
||||
|---|----------|---------|--------|--------|
|
||||
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Deferred — implement against Basic Auth first; OAuth2 tested when tenant available. |
|
||||
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Closed 2026-08-10 as superseded — production mail is on-prem Exchange EWS (see [`docs/plans/2026-08-10-ews-email-transport-design.md`](2026-08-10-ews-email-transport-design.md)); OAuth2 SMTP path remains config-selectable but untested against a live M365 tenant (accepted). |
|
||||
|
||||
(Existing question from questions.md — no new questions discovered.)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
| # | Question | Context | Impact | Status |
|
||||
|---|----------|---------|--------|--------|
|
||||
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Deferred — won't be known during development. Implement against Basic Auth first; OAuth2 tested when tenant available. |
|
||||
| Q12 | What Microsoft 365 tenant/app registration is available for SMTP OAuth2 testing? | Affects Notification Service OAuth2 implementation. | Phase 7. | Closed 2026-08-10 as superseded — production mail is on-prem Exchange EWS (see [`docs/plans/2026-08-10-ews-email-transport-design.md`](2026-08-10-ews-email-transport-design.md)); OAuth2 SMTP path remains config-selectable but untested against a live M365 tenant (accepted). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -53,6 +53,37 @@ The SMTP configuration is defined centrally and used by the central Email delive
|
||||
- **Connection timeout**: Maximum time to wait for SMTP connection (default: 30 seconds).
|
||||
- **Max concurrent connections**: Maximum simultaneous SMTP connections from the central cluster (default: 5).
|
||||
- **Retry settings**: Max retry count, fixed time between retries. The Notification Outbox reuses these for delivery retry of transient failures.
|
||||
- **Transport**: The submission mechanism — `Smtp` (default) or `Ews`. Null/empty means `Smtp`, so every pre-existing row keeps its behavior. See EWS Transport below.
|
||||
|
||||
### EWS Transport (on-prem Exchange)
|
||||
|
||||
Email can be submitted through **Exchange Web Services** (SOAP over HTTPS) instead of SMTP, for networks where authenticated SMTP submission is not offered. The transport is chosen per configuration row via **Transport** = `Ews`; the SMTP/MailKit path is untouched and remains the default. Nothing changes for sites or scripts — delivery stays central-only and `Notify.To(...).Send(...)` behaves identically. Design rationale: [`docs/plans/2026-08-10-ews-email-transport-design.md`](../plans/2026-08-10-ews-email-transport-design.md).
|
||||
|
||||
**Configuration model (field reuse).** No new entity or table — the same `SmtpConfiguration` row carries both transports:
|
||||
|
||||
- **Host** — under `Ews`, the **full EWS endpoint URL**, an absolute `https://` URI (e.g. `https://mail.example.com/ews/exchange.asmx`), not a mail-server hostname.
|
||||
- **Authentication mode** — must be `basic`. There is no OAuth2 or NTLM/Negotiate path on the EWS transport.
|
||||
- **Credentials** — `username:password`, split on the **first** `:` only so a password may contain colons; the username may be domain-qualified (`domain\user`). Stored encrypted at rest via `EncryptedStringConverter`, as on the SMTP path, and projected away from every read path (`notification smtp list`, management responses, audit afterState).
|
||||
- **From address**, **Connection timeout**, **Retry settings** — same meaning; the timeout becomes the per-request HTTP timeout.
|
||||
- **Port**, **TLS mode**, **OAuth2 authority/scope**, **Max concurrent connections** — **unused** under `Ews`. The Central UI hides them and the CLI ignores them for this transport.
|
||||
|
||||
**Auth posture.** HTTP Basic over HTTPS only, sent as an explicit per-request `Authorization` header (never set on the shared, pooled `HttpClient`, which would leak the credential between callers). HTTPS is enforced in **three** places — the management write gate, the delivery adapter, and again inside the sender as defense in depth — so a Basic credential can never leave the process over cleartext even if a row is edited around a higher layer. **Revisit trigger:** if Basic is disabled on the EWS virtual directory, an auth-mode knob (Negotiate/NTLM) becomes the documented follow-on; Linux containers would then also need `gss-ntlmssp` in the image.
|
||||
|
||||
**Message semantics.** One `CreateItem` SOAP request per notification, `RequestServerVersion Exchange2013`, `MessageDisposition="SendOnly"`, recipients in **`BccRecipients` only**, body `BodyType="Text"`. BCC-only preserves the SMTP path's recipient handling (recipients cannot see each other — see Recipient Handling (Email) below). `SendOnly` deliberately leaves no Sent-Items copy: the central `Notifications` table is the audit record of what was sent, and a mailbox copy would grow unboundedly. All user content is XML-escaped. No SDK is used — a hand-rolled envelope over `HttpClient`, matching the no-SDK house pattern set by the SMS adapter.
|
||||
|
||||
**Error classification (EWS).** Classification lives inside the EWS sender, which throws typed transient/permanent exceptions that the Email adapter maps to the same three-way `DeliveryOutcome` as SMTP (there is no standalone classifier type). **What Exchange said beats what HTTP reported**: a parsed response code decides the outcome even on an HTTP 500, and only an unparseable body falls back to the status code.
|
||||
|
||||
- **Transient** (retry, then park after `MaxRetries`): connect/DNS/socket failures and request timeouts; HTTP 408/429/5xx on an unparseable body; and the availability-shaped response codes `ErrorServerBusy`, `ErrorInternalServerTransientError`, `ErrorTimeoutExpired`, `ErrorMailboxStoreUnavailable`, `ErrorInsufficientResources`.
|
||||
- **Permanent** (park immediately): HTTP 401/403 (retrying a bad credential burns a domain account's lockout budget), 404/410/405 (wrong URL or endpoint), SOAP faults, recipient-shaped and schema response codes, and configuration defects found before the send (non-https or relative endpoint, non-Basic auth type, credentials not in `username:password` form). **Unclassified defaults to permanent**, matching the SMTP adapter's stance.
|
||||
- Every surfaced message runs through `CredentialRedactor`, which masks both the packed `username:password` and its base64 Basic-auth encoding; attempts log the endpoint host and a recipient **count** only.
|
||||
|
||||
**Response parsing.** EWS responses are parsed with DTD processing **prohibited** and no external entity resolver, so a hostile or malfunctioning endpoint cannot drive entity expansion or external-entity retrieval from the delivery path. A malformed body is reported as unparseable rather than throwing.
|
||||
|
||||
**Validation layering.** The delivery adapter is the **authoritative** check — it re-validates the EWS shape at delivery time and parks the notification with a clear reason if the row is unusable, so no path can produce a silently broken send:
|
||||
|
||||
- **Management write gate** (`ManagementActor`, covering the CLI and the management API) rejects an unknown transport, and for `Ews` requires an absolute https `Host`, `basic` auth mode, and `username:password` credentials. This catches the common mistake of flipping an existing SMTP row — whose credentials are a bare password — to `Ews`.
|
||||
- **Central UI** `/notifications/smtp` writes through the notification repository directly rather than through the management gate, so the page mirrors the same https + Basic + credential-form rules client-side before saving.
|
||||
- **Transport bundle import** deliberately does **not** gate EWS shape: an imported row is applied as data, and an unusable one surfaces as a parked notification with the adapter's error. Accepted — import is an environment-migration path where the operator remaps values anyway.
|
||||
|
||||
## SMS Configuration
|
||||
|
||||
@@ -117,6 +148,7 @@ Each `Deliver(...)` call returns one of `success | transient failure | permanent
|
||||
- **Permanent failures** (SMTP 5xx permanent errors, e.g., mailbox not found): The Notification Outbox moves the row to `Parked` with the error in `LastError`. The notification will never deliver, and an operator can review or discard it on the Central UI Notification Outbox page.
|
||||
- Retries exhausted on a transient failure also result in a `Parked` row.
|
||||
- A script observes failures only by calling `Notify.Status(id)` and seeing a `Parked` status — not as a synchronous exception.
|
||||
- The bullets above describe the SMTP transport. The EWS transport produces the same three outcomes from its own classification rules — see EWS Transport above.
|
||||
|
||||
### No Rate Limiting
|
||||
- No application-level rate limiting. If the delivery endpoint enforces sending limits (e.g., Microsoft 365 throttling or Twilio rate limits), those manifest as transient failures and are retried naturally by the Notification Outbox.
|
||||
|
||||
@@ -123,6 +123,8 @@ Use `--host` and `--port` to override SMTP defaults (localhost:1025), `--api` fo
|
||||
## Notes
|
||||
|
||||
- Mailpit does **not** support OAuth2 Client Credentials authentication. To test the OAuth2 code path, use a real Microsoft 365 tenant (see Q12 in `docs/plans/questions.md`).
|
||||
- That OAuth2 tenant gap is **superseded for on-prem deployments** by the EWS email transport (`SmtpConfiguration.Transport = Ews`), which submits via Exchange Web Services over HTTPS with Basic auth and needs no tenant — see `docs/plans/2026-08-10-ews-email-transport-design.md`. Q12 is closed as superseded; the OAuth2 SMTP path stays config-selectable but untested against a live tenant.
|
||||
- EWS has **no local test container**: its unit tests drive an in-process stub endpoint (envelope shape, Basic header, canned success / SOAP-fault / HTTP-error bodies), and the live gate against a real Exchange server is a **manual, one-off** exercise. Mailpit remains the SMTP-path harness and is unaffected.
|
||||
- To simulate SMTP failures for store-and-forward testing, stop the container: `docker compose stop smtp`. Restart with `docker compose start smtp`.
|
||||
- The web UI at `http://localhost:8025` provides real-time message inspection, search, and message source viewing.
|
||||
- No data persistence — messages are stored in a temporary database inside the container and lost on container removal.
|
||||
|
||||
@@ -144,6 +144,7 @@ public static class NotificationCommands
|
||||
updateCmd.Add(SmtpCredentialsOption);
|
||||
updateCmd.Add(SmtpOAuth2AuthorityOption);
|
||||
updateCmd.Add(SmtpOAuth2ScopeOption);
|
||||
updateCmd.Add(SmtpTransportOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
@@ -187,6 +188,20 @@ public static class NotificationCommands
|
||||
"(optional; preserves existing / M365 default if omitted)",
|
||||
};
|
||||
|
||||
private static readonly Option<string?> SmtpTransportOption = CreateTransportOption();
|
||||
|
||||
private static Option<string?> CreateTransportOption()
|
||||
{
|
||||
var option = new Option<string?>("--transport")
|
||||
{
|
||||
Description = "Email transport: Smtp or Ews (optional; preserves existing if omitted). " +
|
||||
"Under Ews, --server is the full EWS URL (https://…/ews/exchange.asmx), " +
|
||||
"--auth-mode must be basic, and --port/--tls-mode are unused.",
|
||||
};
|
||||
option.AcceptOnlyFromAmong("Smtp", "Ews");
|
||||
return option;
|
||||
}
|
||||
|
||||
private static Option<string?> CreateTlsModeOption()
|
||||
{
|
||||
var option = new Option<string?>("--tls-mode")
|
||||
@@ -200,8 +215,8 @@ public static class NotificationCommands
|
||||
/// <summary>
|
||||
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
|
||||
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> /
|
||||
/// <c>--oauth2-authority</c> / <c>--oauth2-scope</c> flags map to null when omitted
|
||||
/// so the server-side handler preserves the existing values.
|
||||
/// <c>--oauth2-authority</c> / <c>--oauth2-scope</c> / <c>--transport</c> flags map to
|
||||
/// null when omitted so the server-side handler preserves the existing values.
|
||||
/// </summary>
|
||||
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
|
||||
/// <returns>An <see cref="UpdateSmtpConfigCommand"/> populated from the parsed result.</returns>
|
||||
@@ -216,7 +231,8 @@ public static class NotificationCommands
|
||||
var credentials = result.GetValue(SmtpCredentialsOption);
|
||||
var oauth2Authority = result.GetValue(SmtpOAuth2AuthorityOption);
|
||||
var oauth2Scope = result.GetValue(SmtpOAuth2ScopeOption);
|
||||
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials, oauth2Authority, oauth2Scope);
|
||||
var transport = result.GetValue(SmtpTransportOption);
|
||||
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials, oauth2Authority, oauth2Scope, transport);
|
||||
}
|
||||
|
||||
private static Command BuildSms(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
|
||||
@@ -1302,18 +1302,24 @@ scadabridge --url <url> notification smtp list
|
||||
Update the SMTP configuration.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> notification smtp update --id <int> --server <string> --port <int> --auth-mode <string> --from-address <string> [--tls-mode <string>] [--credentials <string>]
|
||||
scadabridge --url <url> notification smtp update --id <int> --server <string> --port <int> --auth-mode <string> --from-address <string> [--tls-mode <string>] [--credentials <string>] [--oauth2-authority <string>] [--oauth2-scope <string>] [--transport <string>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | SMTP config ID |
|
||||
| `--server` | yes | SMTP server hostname |
|
||||
| `--server` | yes | SMTP server hostname (under `--transport Ews`, the full EWS URL — `https://…/ews/exchange.asmx`) |
|
||||
| `--port` | yes | SMTP server port |
|
||||
| `--auth-mode` | yes | Authentication mode (e.g. `OAuth2ClientCredentials`, `Basic`) |
|
||||
| `--from-address` | yes | Sender email address |
|
||||
| `--tls-mode` | no | TLS mode: `None`, `StartTLS`, or `SSL` (preserves existing if omitted) |
|
||||
| `--credentials` | no | SMTP credentials — `username:password` for Basic, or client secret for OAuth2 (preserves existing if omitted) |
|
||||
| `--oauth2-authority` | no | OAuth2 token-endpoint URL — a `{tenant}` placeholder is substituted from the credential (preserves existing / Microsoft 365 default if omitted; OAuth2 auth mode only) |
|
||||
| `--oauth2-scope` | no | OAuth2 scope requested from the token endpoint (preserves existing / Microsoft 365 default if omitted; OAuth2 auth mode only) |
|
||||
| `--transport` | no | Email transport: `Smtp` or `Ews` (preserves existing if omitted) |
|
||||
|
||||
Under `--transport Ews`, `--server` carries the full EWS URL, `--auth-mode` must be `Basic`,
|
||||
and `--port` / `--tls-mode` are unused.
|
||||
|
||||
#### `notification sms list`
|
||||
|
||||
|
||||
+126
-26
@@ -46,12 +46,25 @@
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4 text-muted">Host</div>
|
||||
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
|
||||
<div class="col-md-4 text-muted">Transport</div>
|
||||
<div class="col-md-8"><span class="badge bg-secondary">@(smtp.Transport ?? "Smtp")</span></div>
|
||||
@if (IsEws(smtp.Transport))
|
||||
{
|
||||
<div class="col-md-4 text-muted">EWS URL</div>
|
||||
<div class="col-md-8">@smtp.Host</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4 text-muted">Host</div>
|
||||
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
|
||||
}
|
||||
<div class="col-md-4 text-muted">Auth Type</div>
|
||||
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
|
||||
<div class="col-md-4 text-muted">TLS Mode</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
|
||||
@if (!IsEws(smtp.Transport))
|
||||
{
|
||||
<div class="col-md-4 text-muted">TLS Mode</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
|
||||
}
|
||||
<div class="col-md-4 text-muted">From Address</div>
|
||||
<div class="col-md-8">@smtp.FromAddress</div>
|
||||
<div class="col-md-4 text-muted">Credentials</div>
|
||||
@@ -74,33 +87,44 @@
|
||||
<div class="card-header">@(_editingSmtp != null ? "Edit SMTP Configuration" : "Add SMTP Configuration")</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Transport</label>
|
||||
<select class="form-select" @bind="Transport">
|
||||
<option>Smtp</option>
|
||||
<option>Ews</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" class="form-control" @bind="_host" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Auth Type</label>
|
||||
<select class="form-select" @bind="_authType">
|
||||
<option>OAuth2</option>
|
||||
<option>Basic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">TLS Mode</label>
|
||||
<select class="form-select" @bind="_tlsMode">
|
||||
<option>None</option>
|
||||
<option>StartTLS</option>
|
||||
<option>SSL</option>
|
||||
</select>
|
||||
<label class="form-label">@(IsEwsForm ? "EWS URL" : "Host")</label>
|
||||
<input type="text" class="form-control" @bind="_host"
|
||||
placeholder="@(IsEwsForm ? "https://mail.example.com/ews/exchange.asmx" : "smtp.example.com")" />
|
||||
</div>
|
||||
@if (!IsEwsForm)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Auth Type</label>
|
||||
<select class="form-select" @bind="_authType">
|
||||
<option>OAuth2</option>
|
||||
<option>Basic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">TLS Mode</label>
|
||||
<select class="form-select" @bind="_tlsMode">
|
||||
<option>None</option>
|
||||
<option>StartTLS</option>
|
||||
<option>SSL</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<label class="form-label">Credentials</label>
|
||||
<input type="password" class="form-control" @bind="_credentials"
|
||||
placeholder="OAuth2 client secret or SMTP password" />
|
||||
placeholder="@(IsEwsForm ? @"domain\username:password" : "OAuth2 client secret or SMTP password")" />
|
||||
<div class="form-text">Treat as sensitive — visible to admins only.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@@ -153,6 +177,10 @@
|
||||
|
||||
private string _host = string.Empty;
|
||||
private int _port = 587;
|
||||
private string _transport = "Smtp";
|
||||
// AuthType as it was before the operator switched to Ews, so switching back to Smtp
|
||||
// restores their choice rather than leaving the forced "Basic" behind.
|
||||
private string _authTypeBeforeEws = "OAuth2";
|
||||
private string _authType = "OAuth2";
|
||||
private string? _tlsMode;
|
||||
private string? _credentials;
|
||||
@@ -163,6 +191,42 @@
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Bound by the Transport select. The setter is where the transport's shape rules are
|
||||
/// applied: EWS authenticates with HTTP Basic only, so selecting it forces the auth type
|
||||
/// (which also hides the OAuth2 inputs, gated on AuthType as before). Switching back to
|
||||
/// Smtp restores the auth type the operator had chosen.
|
||||
/// </summary>
|
||||
private string Transport
|
||||
{
|
||||
get => _transport;
|
||||
set
|
||||
{
|
||||
if (string.Equals(_transport, value, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsEws(value))
|
||||
{
|
||||
_authTypeBeforeEws = _authType;
|
||||
_authType = "Basic";
|
||||
}
|
||||
else
|
||||
{
|
||||
_authType = _authTypeBeforeEws;
|
||||
}
|
||||
_transport = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the edit form is currently in EWS mode.</summary>
|
||||
private bool IsEwsForm => IsEws(_transport);
|
||||
|
||||
/// <summary>Null-safe, case-insensitive test for the EWS transport; null/blank means Smtp.</summary>
|
||||
private static bool IsEws(string? transport)
|
||||
=> string.Equals(transport?.Trim(), "Ews", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
@@ -188,6 +252,8 @@
|
||||
_editingSmtp = null;
|
||||
_host = string.Empty;
|
||||
_port = 587;
|
||||
_transport = "Smtp";
|
||||
_authTypeBeforeEws = "OAuth2";
|
||||
_authType = "OAuth2";
|
||||
_tlsMode = "None";
|
||||
_credentials = null;
|
||||
@@ -203,6 +269,8 @@
|
||||
_editingSmtp = smtp;
|
||||
_host = smtp.Host;
|
||||
_port = smtp.Port;
|
||||
_transport = smtp.Transport ?? "Smtp";
|
||||
_authTypeBeforeEws = smtp.AuthType;
|
||||
_authType = smtp.AuthType;
|
||||
_tlsMode = smtp.TlsMode;
|
||||
_credentials = smtp.Credentials;
|
||||
@@ -228,12 +296,43 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// This page writes through the repository, so the ManagementActor's EWS shape gate
|
||||
// is not on this path — mirror its Host rule here (auth type is already forced to
|
||||
// Basic by the Transport setter) so an unusable row cannot be saved from the UI.
|
||||
if (IsEwsForm
|
||||
&& (!Uri.TryCreate(_host.Trim(), UriKind.Absolute, out var ewsEndpoint)
|
||||
|| !string.Equals(ewsEndpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)))
|
||||
{
|
||||
_formError = "EWS transport requires an absolute https:// EWS endpoint URL "
|
||||
+ "(e.g. https://mail.example.com/ews/exchange.asmx).";
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror of the same gate's credentials rule, with the identical FIRST-colon-only
|
||||
// split: a domain-qualified user name contains no colon but a password may. This
|
||||
// page has no preserve-if-blank behaviour — the field's value IS what gets
|
||||
// persisted, and a blank field clears the stored credential — so the rule applies
|
||||
// on edit as well as add, exactly as the management gate validates the effective row.
|
||||
if (IsEwsForm)
|
||||
{
|
||||
var credentialParts = _credentials?.Trim().Split(':', 2) ?? [];
|
||||
if (credentialParts.Length != 2
|
||||
|| credentialParts[0].Length == 0
|
||||
|| credentialParts[1].Length == 0)
|
||||
{
|
||||
_formError = "EWS credentials must be in 'username:password' form "
|
||||
+ @"(username may be 'domain\username').";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingSmtp != null)
|
||||
{
|
||||
_editingSmtp.Host = _host.Trim();
|
||||
_editingSmtp.Port = _port;
|
||||
_editingSmtp.Transport = _transport;
|
||||
_editingSmtp.AuthType = _authType;
|
||||
_editingSmtp.TlsMode = _tlsMode;
|
||||
_editingSmtp.Credentials = _credentials?.Trim();
|
||||
@@ -247,6 +346,7 @@
|
||||
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
|
||||
{
|
||||
Port = _port,
|
||||
Transport = _transport,
|
||||
TlsMode = _tlsMode,
|
||||
Credentials = _credentials?.Trim(),
|
||||
OAuth2Authority = NormalizeOptional(_oauth2Authority),
|
||||
|
||||
@@ -14,6 +14,14 @@ public class SmtpConfiguration
|
||||
public string? Credentials { get; set; }
|
||||
/// <summary>Gets or sets the TLS mode (None, StartTLS, or SSL), or null to use the provider default.</summary>
|
||||
public string? TlsMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the email delivery transport ("Smtp" or "Ews"). Null/empty means Smtp
|
||||
/// (legacy rows). Under Ews, <see cref="Host"/> holds the full EWS endpoint URL,
|
||||
/// <see cref="AuthType"/> must be Basic, and Port/TlsMode/OAuth2* are unused.
|
||||
/// </summary>
|
||||
public string? Transport { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the sender address placed in the From header.</summary>
|
||||
public string FromAddress { get; set; }
|
||||
/// <summary>Gets or sets the connection timeout in seconds.</summary>
|
||||
|
||||
@@ -8,7 +8,9 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList<string> R
|
||||
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
|
||||
public record DeleteNotificationListCommand(int NotificationListId);
|
||||
public record ListSmtpConfigsCommand;
|
||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null);
|
||||
// Transport is a trailing additive parameter (Smtp or Ews; null = preserve the
|
||||
// stored value, which is itself null for every pre-EWS row and means Smtp).
|
||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null, string? Transport = null);
|
||||
public record ListSmsConfigsCommand;
|
||||
// FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid
|
||||
// instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary.
|
||||
|
||||
+4
@@ -124,6 +124,10 @@ public class SmtpConfigurationConfiguration : IEntityTypeConfiguration<SmtpConfi
|
||||
builder.Property(s => s.TlsMode)
|
||||
.HasMaxLength(50);
|
||||
|
||||
// Email transport discriminator ("Smtp"/"Ews"); null = Smtp for pre-EWS rows.
|
||||
builder.Property(s => s.Transport)
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(s => s.FromAddress)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
|
||||
+2094
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSmtpConfigurationTransport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Transport",
|
||||
table: "SmtpConfigurations",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Transport",
|
||||
table: "SmtpConfigurations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -1064,6 +1064,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Transport")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SmtpConfigurations");
|
||||
|
||||
@@ -2120,6 +2120,7 @@ public class ManagementActor : ReceiveActor
|
||||
c.AuthType,
|
||||
c.FromAddress,
|
||||
c.TlsMode,
|
||||
c.Transport,
|
||||
c.ConnectionTimeoutSeconds,
|
||||
c.MaxConcurrentConnections,
|
||||
c.MaxRetries,
|
||||
@@ -2156,6 +2157,10 @@ public class ManagementActor : ReceiveActor
|
||||
// omitted authority/scope leaves the stored value (or its M365 default) intact.
|
||||
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
|
||||
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
|
||||
// Preserve-if-null for the transport too: an omitted value keeps the stored
|
||||
// one (null on every pre-EWS row, which means SMTP).
|
||||
if (cmd.Transport is not null) config.Transport = cmd.Transport;
|
||||
ValidateSmtpConfigTransportShape(config);
|
||||
await repo.UpdateSmtpConfigurationAsync(config);
|
||||
await repo.SaveChangesAsync();
|
||||
// Audit the credential-free shape — the *fact of* the change
|
||||
@@ -2166,6 +2171,73 @@ public class ManagementActor : ReceiveActor
|
||||
return publicShape;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-path gate for the email transport: validates the EFFECTIVE
|
||||
/// post-assignment state of an SmtpConfiguration so an operator cannot persist a
|
||||
/// row the delivery adapter would immediately park every notification on. The
|
||||
/// adapter re-validates at delivery time and remains the authoritative check —
|
||||
/// this is a fail-fast at the point of the mistake, not a second source of truth.
|
||||
/// </summary>
|
||||
/// <param name="config">The configuration with all command assignments already applied.</param>
|
||||
/// <exception cref="ManagementCommandException">The transport is unknown, or the EWS shape rules are violated.</exception>
|
||||
private static void ValidateSmtpConfigTransportShape(Commons.Entities.Notifications.SmtpConfiguration config)
|
||||
{
|
||||
// Mirrors NotificationService's EmailTransportParser, inlined because
|
||||
// ManagementService does not (and should not) reference NotificationService
|
||||
// just for a two-value parse. Keep the accepted values in step with it.
|
||||
var isEws = false;
|
||||
var transport = config.Transport;
|
||||
if (!string.IsNullOrWhiteSpace(transport))
|
||||
{
|
||||
switch (transport.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "smtp":
|
||||
break;
|
||||
case "ews":
|
||||
isEws = true;
|
||||
break;
|
||||
default:
|
||||
throw new ManagementCommandException(
|
||||
$"Unknown email transport '{transport}'. Expected one of: Smtp, Ews.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEws)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// EWS reuses Host as the SOAP endpoint URL, not a mail-server hostname.
|
||||
if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
|
||||
|| !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
"EWS transport requires Host to be an absolute https:// EWS endpoint URL "
|
||||
+ $"(e.g. https://mail.example.com/ews/exchange.asmx); got '{config.Host}'.");
|
||||
}
|
||||
|
||||
// The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists
|
||||
// on that path, so an OAuth2 row would park on every send.
|
||||
if (!string.Equals(config.AuthType, "basic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
"EWS transport supports only Basic authentication; set AuthMode to 'basic'.");
|
||||
}
|
||||
|
||||
// Same split semantics as the delivery adapter: FIRST colon only, because a
|
||||
// domain-qualified user name contains none but a password legitimately may.
|
||||
// Without this the common mistake — flipping an existing SMTP row (whose
|
||||
// Credentials is a bare password) to Ews — would persist happily and then park
|
||||
// every single notification at delivery time.
|
||||
var credentialParts = config.Credentials?.Split(':', 2) ?? [];
|
||||
if (credentialParts.Length != 2 || credentialParts[0].Length == 0 || credentialParts[1].Length == 0)
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
"EWS transport requires Credentials in 'username:password' form "
|
||||
+ "(username may be 'domain\\user').");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project an SmsConfiguration to a credential-free shape so the
|
||||
/// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
|
||||
|
||||
+160
-6
@@ -4,6 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
|
||||
@@ -18,6 +19,14 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
/// Transient) — the canonical central-side email delivery path. The prior
|
||||
/// site-shaped <c>NotificationDeliveryService</c> was deleted with sites no longer
|
||||
/// delivering notifications.
|
||||
/// <para>
|
||||
/// The configured <c>SmtpConfiguration.Transport</c> selects the submission mechanism:
|
||||
/// <c>Smtp</c> (the default for every row that stores no transport) takes the SMTP path
|
||||
/// above; <c>Ews</c> hands the message to <see cref="IEwsMailSender"/> instead — one
|
||||
/// <c>CreateItem</c> SOAP call to an on-prem Exchange endpoint. List resolution, recipient
|
||||
/// resolution, configuration selection and address validation are shared by both
|
||||
/// transports; only TLS mode is SMTP-specific.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
|
||||
{
|
||||
@@ -26,6 +35,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
private readonly OAuth2TokenService? _tokenService;
|
||||
private readonly ILogger<EmailNotificationDeliveryAdapter> _logger;
|
||||
private readonly NotificationOptions _options;
|
||||
private readonly IEwsMailSender? _ewsMailSender;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="EmailNotificationDeliveryAdapter"/>.</summary>
|
||||
/// <param name="repository">Repository for resolving notification list recipients.</param>
|
||||
@@ -33,12 +43,18 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="tokenService">Optional OAuth2 token service for Microsoft 365 Client Credentials auth.</param>
|
||||
/// <param name="options">Optional notification options providing documented fallback values.</param>
|
||||
/// <param name="ewsMailSender">
|
||||
/// Optional EWS mail sender used when the selected configuration row sets the Ews transport.
|
||||
/// Registered by <c>AddNotificationService()</c>; a host that omits that call leaves it null,
|
||||
/// in which case an Ews row fails permanently rather than silently falling back to SMTP.
|
||||
/// </param>
|
||||
public EmailNotificationDeliveryAdapter(
|
||||
INotificationRepository repository,
|
||||
Func<ISmtpClientWrapper> smtpClientFactory,
|
||||
ILogger<EmailNotificationDeliveryAdapter> logger,
|
||||
OAuth2TokenService? tokenService = null,
|
||||
IOptions<NotificationOptions>? options = null)
|
||||
IOptions<NotificationOptions>? options = null,
|
||||
IEwsMailSender? ewsMailSender = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_smtpClientFactory = smtpClientFactory;
|
||||
@@ -47,6 +63,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
// NotificationOptions supplies the documented fallback values used when a
|
||||
// deployed SmtpConfiguration row leaves a field unset (non-positive).
|
||||
_options = options?.Value ?? new NotificationOptions();
|
||||
_ewsMailSender = ewsMailSender;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -89,21 +106,42 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
smtpConfig.Id);
|
||||
}
|
||||
|
||||
// An unknown TLS mode is a configuration error that retrying cannot fix —
|
||||
// surface it as a permanent failure (SMTP TLS validation policy).
|
||||
SmtpTlsMode tlsMode;
|
||||
// An unknown transport is a configuration error that retrying cannot fix.
|
||||
EmailTransport transport;
|
||||
try
|
||||
{
|
||||
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||
transport = EmailTransportParser.Parse(smtpConfig.Transport);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
|
||||
"Email notification to list '{List}' has an invalid transport: {Reason}",
|
||||
notification.ListName, ex.Message);
|
||||
return DeliveryOutcome.Permanent(ex.Message);
|
||||
}
|
||||
|
||||
// TLS applies to the SMTP transport only — an EWS row's Port/TlsMode are unused.
|
||||
// Parsed here, ahead of address validation, so the SMTP path's check order (and
|
||||
// hence which reason surfaces for a row that is invalid in more than one way) is
|
||||
// exactly what it was before the transport branch existed.
|
||||
var tlsMode = SmtpTlsMode.None;
|
||||
if (transport == EmailTransport.Smtp)
|
||||
{
|
||||
// An unknown TLS mode is a configuration error that retrying cannot fix —
|
||||
// surface it as a permanent failure (SMTP TLS validation policy).
|
||||
try
|
||||
{
|
||||
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
|
||||
notification.ListName, ex.Message);
|
||||
return DeliveryOutcome.Permanent(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed sender or recipient address cannot be fixed by retrying —
|
||||
// surface it as a permanent failure.
|
||||
var addressError = EmailAddressValidator.ValidateAddresses(
|
||||
@@ -121,6 +159,13 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
.Select(r => r.EmailAddress!)
|
||||
.ToList();
|
||||
|
||||
// Everything above is transport-agnostic; the EWS branch takes over here.
|
||||
if (transport == EmailTransport.Ews)
|
||||
{
|
||||
return await DeliverViaEwsAsync(
|
||||
smtpConfig, recipientAddresses, notification, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendAsync(smtpConfig, tlsMode, recipientAddresses,
|
||||
@@ -165,6 +210,115 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers the plain-text BCC email through Exchange Web Services.
|
||||
/// <para>
|
||||
/// Every EWS-specific configuration defect (a non-HTTPS or relative endpoint, a non-Basic
|
||||
/// auth type, a credential that is not <c>username:password</c>, or a missing sender
|
||||
/// registration) is a permanent failure decided before the sender is touched — retrying
|
||||
/// cannot fix a misconfigured row, and no half-formed request should reach Exchange.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<DeliveryOutcome> DeliverViaEwsAsync(
|
||||
SmtpConfiguration config,
|
||||
IReadOnlyList<string> recipientAddresses,
|
||||
Notification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
|
||||
|| endpoint.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
return PermanentConfigError(
|
||||
"EWS transport requires an absolute https:// endpoint URL in Host; "
|
||||
+ $"got '{config.Host}'");
|
||||
}
|
||||
|
||||
// Basic is the only credential shape the sender builds an Authorization header for;
|
||||
// OAuth2/NTLM are documented follow-ons, not silent fallbacks.
|
||||
if (!config.AuthType.Equals("basic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PermanentConfigError(
|
||||
$"EWS transport supports only Basic authentication; got '{config.AuthType}'.");
|
||||
}
|
||||
|
||||
// Split on the FIRST colon only: a domain-qualified user name has none, but a
|
||||
// password legitimately may contain one.
|
||||
var parts = config.Credentials?.Split(':', 2) ?? [];
|
||||
if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0)
|
||||
{
|
||||
return PermanentConfigError("EWS credentials must be in 'username:password' form.");
|
||||
}
|
||||
|
||||
if (_ewsMailSender == null)
|
||||
{
|
||||
return PermanentConfigError(
|
||||
"EWS transport configured but no EWS sender is registered");
|
||||
}
|
||||
|
||||
var timeoutSeconds = config.ConnectionTimeoutSeconds > 0
|
||||
? config.ConnectionTimeoutSeconds
|
||||
: _options.ConnectionTimeoutSeconds;
|
||||
var request = new EwsSendRequest(
|
||||
endpoint,
|
||||
parts[0],
|
||||
parts[1],
|
||||
config.FromAddress,
|
||||
recipientAddresses,
|
||||
notification.Subject,
|
||||
notification.Body,
|
||||
timeoutSeconds);
|
||||
|
||||
try
|
||||
{
|
||||
await _ewsMailSender.SendAsync(request, cancellationToken);
|
||||
|
||||
return DeliveryOutcome.Success(string.Join(", ", recipientAddresses));
|
||||
}
|
||||
catch (EwsPermanentException ex)
|
||||
{
|
||||
// Auth, wrong URL, schema fault or recipient rejection — not retried.
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
|
||||
_logger.LogError(
|
||||
"Permanent EWS failure delivering email to list '{List}': {Detail}",
|
||||
notification.ListName, detail);
|
||||
return DeliveryOutcome.Permanent(detail);
|
||||
}
|
||||
catch (EwsTransientException ex)
|
||||
{
|
||||
// Availability-shaped failure (network/timeout/5xx/server-busy) — retried.
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
|
||||
_logger.LogWarning(
|
||||
"Transient EWS failure delivering email to list '{List}': {Detail}",
|
||||
notification.ListName, detail);
|
||||
return DeliveryOutcome.Transient(detail);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// A caller-requested cancellation propagates; it is neither a success
|
||||
// nor a delivery failure.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Anything the sender did not classify is treated as permanent, mirroring the
|
||||
// SMTP path's default-to-permanent stance.
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
|
||||
_logger.LogError(
|
||||
"Unclassified failure delivering email via EWS to list '{List}' ({ExceptionType}): {Detail}",
|
||||
notification.ListName, ex.GetType().Name, detail);
|
||||
return DeliveryOutcome.Permanent($"Email delivery failed: {detail}");
|
||||
}
|
||||
|
||||
// Local helper: one log-and-park shape for every EWS configuration defect.
|
||||
DeliveryOutcome PermanentConfigError(string reason)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Email notification to list '{List}' has an invalid EWS configuration: {Reason}",
|
||||
notification.ListName, reason);
|
||||
return DeliveryOutcome.Permanent(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers the plain-text BCC email via SMTP. A permanent failure surfaces as
|
||||
/// <see cref="SmtpPermanentException"/>; transient failures propagate for the
|
||||
|
||||
@@ -26,10 +26,14 @@ public static class ServiceCollectionExtensions
|
||||
/// This extension covers only the outbox-specific registrations. The
|
||||
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP machinery —
|
||||
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c> and
|
||||
/// <c>NotificationOptions</c> — so the caller (the Host on the central node) must also
|
||||
/// call <c>AddNotificationService()</c>. Re-registering those services here would
|
||||
/// duplicate them; relying on <c>AddNotificationService</c> keeps a single source of truth.
|
||||
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c>,
|
||||
/// <c>NotificationOptions</c> and — for configuration rows selecting the EWS transport —
|
||||
/// <c>IEwsMailSender</c> with its named HTTP client — so the caller (the Host on the
|
||||
/// central node) must also call <c>AddNotificationService()</c>. Re-registering those
|
||||
/// services here would duplicate them; relying on <c>AddNotificationService</c> keeps a
|
||||
/// single source of truth. Each is an optional constructor parameter on the adapter, so a
|
||||
/// host that skips <c>AddNotificationService()</c> still constructs — an EWS row then
|
||||
/// fails permanently ("no EWS sender is registered") rather than falling back to SMTP.
|
||||
///
|
||||
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
||||
/// takes a scoped <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.INotificationRepository"/>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// The email delivery transports the service supports. The configured
|
||||
/// <c>SmtpConfiguration.Transport</c> string is parsed into this enum to choose
|
||||
/// which client actually submits the message.
|
||||
/// </summary>
|
||||
public enum EmailTransport
|
||||
{
|
||||
/// <summary>Direct SMTP submission via MailKit (the original path).</summary>
|
||||
Smtp,
|
||||
|
||||
/// <summary>On-prem Exchange Web Services — one CreateItem SOAP call over HTTPS.</summary>
|
||||
Ews,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the free-text <c>SmtpConfiguration.Transport</c> value into an
|
||||
/// <see cref="EmailTransport"/>, rejecting unknown values rather than silently
|
||||
/// falling back to SMTP.
|
||||
/// </summary>
|
||||
public static class EmailTransportParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a configured transport string. A null or empty value defaults to
|
||||
/// <see cref="EmailTransport.Smtp"/> — every pre-EWS configuration row stores no
|
||||
/// transport and is an SMTP row. An unknown value is a configuration error that
|
||||
/// retrying cannot fix, so it throws and the delivery adapter surfaces it as a
|
||||
/// permanent failure (mirrors <see cref="SmtpTlsModeParser"/>).
|
||||
/// </summary>
|
||||
/// <param name="transport">The transport string to parse (Smtp or Ews); null/empty defaults to Smtp.</param>
|
||||
/// <exception cref="ArgumentException">The value is not one of Smtp/Ews.</exception>
|
||||
/// <returns>The corresponding <see cref="EmailTransport"/> enum value.</returns>
|
||||
public static EmailTransport Parse(string? transport)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(transport))
|
||||
{
|
||||
return EmailTransport.Smtp;
|
||||
}
|
||||
|
||||
return transport.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"smtp" => EmailTransport.Smtp,
|
||||
"ews" => EmailTransport.Ews,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown email transport '{transport}'. Expected one of: Smtp, Ews.",
|
||||
nameof(transport)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// The shape of an EWS response body. This says what Exchange answered, not
|
||||
/// whether the failure is worth retrying — that judgement belongs to the sender's classifier.
|
||||
/// </summary>
|
||||
public enum EwsResponseKind
|
||||
{
|
||||
/// <summary>The <c>CreateItemResponseMessage</c> reported <c>ResponseClass="Success"</c>.</summary>
|
||||
Success,
|
||||
|
||||
/// <summary>The <c>CreateItemResponseMessage</c> reported a non-success response class.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>The body carried a SOAP <c>Fault</c> instead of a response message.</summary>
|
||||
Fault,
|
||||
|
||||
/// <summary>The body was not XML, was empty, or was XML of no recognised EWS shape.</summary>
|
||||
Unparseable,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of parsing an EWS response body.
|
||||
/// </summary>
|
||||
/// <param name="Kind">The response shape.</param>
|
||||
/// <param name="ResponseCode">
|
||||
/// The EWS response code (for example <c>NoError</c>, <c>ErrorServerBusy</c>), or <c>null</c>
|
||||
/// when the body carried none.
|
||||
/// </param>
|
||||
/// <param name="MessageText">The human-readable message or SOAP fault string, when present.</param>
|
||||
public sealed record EwsParseResult(EwsResponseKind Kind, string? ResponseCode, string? MessageText);
|
||||
|
||||
/// <summary>
|
||||
/// Parses an EWS <c>CreateItem</c> response body into a <see cref="EwsParseResult"/>.
|
||||
/// <para>
|
||||
/// Pure and judgement-free by design: it reports the response shape and code and never decides
|
||||
/// transient versus permanent, so the retry policy stays in one place in the sender.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class EwsResponseParser
|
||||
{
|
||||
private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/";
|
||||
private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages";
|
||||
|
||||
private static readonly EwsParseResult UnparseableResult =
|
||||
new(EwsResponseKind.Unparseable, ResponseCode: null, MessageText: null);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a response body. A malformed or unrecognised payload is reported as
|
||||
/// <see cref="EwsResponseKind.Unparseable"/> rather than throwing — a broken response must not
|
||||
/// take out the delivery path with an unhandled exception.
|
||||
/// </summary>
|
||||
/// <param name="responseBody">The raw HTTP response body returned by the EWS endpoint.</param>
|
||||
/// <returns>The parsed shape, response code and message text.</returns>
|
||||
public static EwsParseResult Parse(string responseBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
{
|
||||
return UnparseableResult;
|
||||
}
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
// The body is external input, so DTDs are prohibited outright: LINQ-to-XML's own
|
||||
// XDocument.Parse permits an internal DTD subset and expands its entities (verified),
|
||||
// which is an entity-expansion DoS on a response body. A null resolver additionally
|
||||
// blocks external entity/DTD fetches (XXE).
|
||||
var settings = new XmlReaderSettings
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null,
|
||||
};
|
||||
|
||||
using var reader = XmlReader.Create(new StringReader(responseBody), settings);
|
||||
document = XDocument.Load(reader);
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
return UnparseableResult;
|
||||
}
|
||||
|
||||
var responseMessage = document.Descendants(Messages + "CreateItemResponseMessage").FirstOrDefault();
|
||||
if (responseMessage is not null)
|
||||
{
|
||||
var responseClass = (string?)responseMessage.Attribute("ResponseClass");
|
||||
var kind = string.Equals(responseClass, "Success", StringComparison.Ordinal)
|
||||
? EwsResponseKind.Success
|
||||
: EwsResponseKind.Error;
|
||||
|
||||
return new EwsParseResult(
|
||||
kind,
|
||||
responseMessage.Element(Messages + "ResponseCode")?.Value,
|
||||
responseMessage.Element(Messages + "MessageText")?.Value);
|
||||
}
|
||||
|
||||
var fault = document.Descendants(Soap + "Fault").FirstOrDefault();
|
||||
if (fault is not null)
|
||||
{
|
||||
// faultstring is unqualified in SOAP 1.1, and the detail's ResponseCode appears under
|
||||
// several vendor namespaces (errors/types) depending on the fault — match on local name.
|
||||
var faultString = fault.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "faultstring")?.Value;
|
||||
|
||||
var detail = fault.Descendants().FirstOrDefault(e => e.Name.LocalName == "detail");
|
||||
var responseCode = detail?.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "ResponseCode")?.Value;
|
||||
|
||||
return new EwsParseResult(EwsResponseKind.Fault, responseCode, faultString);
|
||||
}
|
||||
|
||||
return UnparseableResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the EWS <c>CreateItem</c> SOAP envelope used to send a notification email
|
||||
/// through Exchange Web Services.
|
||||
/// <para>
|
||||
/// Pure and I/O-free: the HTTP send lives in the sender that consumes this. The envelope is
|
||||
/// composed with LINQ-to-XML rather than string concatenation so operator-authored subject and
|
||||
/// body content is XML-escaped by construction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class EwsSoapEnvelope
|
||||
{
|
||||
private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/";
|
||||
private static readonly XNamespace Types = "http://schemas.microsoft.com/exchange/services/2006/types";
|
||||
private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c>CreateItem</c> request that sends one plain-text message.
|
||||
/// <para>
|
||||
/// <c>MessageDisposition="SendOnly"</c> sends without keeping a Sent Items copy, and every
|
||||
/// recipient is addressed via <c>BccRecipients</c> so recipients cannot see one another —
|
||||
/// both mirror the SMTP delivery path's semantics.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="fromAddress">The sending mailbox address stamped into <c>t:From</c>.</param>
|
||||
/// <param name="bccRecipients">
|
||||
/// The recipient addresses; each becomes one <c>t:Mailbox</c> under <c>t:BccRecipients</c>.
|
||||
/// Callers are expected to supply at least one — an empty list yields an envelope Exchange
|
||||
/// rejects as schema-invalid.
|
||||
/// </param>
|
||||
/// <param name="subject">The message subject; escaped, not interpreted.</param>
|
||||
/// <param name="body">The plain-text message body (<c>BodyType="Text"</c>); escaped, not interpreted.</param>
|
||||
/// <returns>The complete SOAP envelope as XML text, prefixed with a UTF-8 declaration.</returns>
|
||||
public static string BuildCreateItem(
|
||||
string fromAddress,
|
||||
IReadOnlyList<string> bccRecipients,
|
||||
string subject,
|
||||
string body)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromAddress);
|
||||
ArgumentNullException.ThrowIfNull(bccRecipients);
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
||||
// EWS MessageType is an xs:sequence: Subject, Body, BccRecipients, From must appear in
|
||||
// this order or the server answers ErrorSchemaValidation.
|
||||
var message = new XElement(
|
||||
Types + "Message",
|
||||
new XElement(Types + "Subject", subject),
|
||||
new XElement(Types + "Body", new XAttribute("BodyType", "Text"), body),
|
||||
new XElement(Types + "BccRecipients", bccRecipients.Select(Mailbox)),
|
||||
new XElement(Types + "From", Mailbox(fromAddress)));
|
||||
|
||||
var document = new XDocument(
|
||||
new XDeclaration("1.0", "utf-8", null),
|
||||
new XElement(
|
||||
Soap + "Envelope",
|
||||
new XAttribute(XNamespace.Xmlns + "soap", Soap.NamespaceName),
|
||||
new XAttribute(XNamespace.Xmlns + "t", Types.NamespaceName),
|
||||
new XAttribute(XNamespace.Xmlns + "m", Messages.NamespaceName),
|
||||
new XElement(
|
||||
Soap + "Header",
|
||||
new XElement(Types + "RequestServerVersion", new XAttribute("Version", "Exchange2013"))),
|
||||
new XElement(
|
||||
Soap + "Body",
|
||||
new XElement(
|
||||
Messages + "CreateItem",
|
||||
new XAttribute("MessageDisposition", "SendOnly"),
|
||||
new XElement(Messages + "Items", message)))));
|
||||
|
||||
// XDocument.ToString() drops the declaration; saving through a writer that reports UTF-8
|
||||
// keeps it, and keeps the declared encoding honest.
|
||||
using var writer = new Utf8StringWriter();
|
||||
document.Save(writer);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static XElement Mailbox(string address)
|
||||
=> new(Types + "Mailbox", new XElement(Types + "EmailAddress", address));
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="StringWriter"/> that reports UTF-8 so <see cref="XDocument.Save(TextWriter)"/>
|
||||
/// emits <c>encoding="utf-8"</c> instead of the default UTF-16.
|
||||
/// </summary>
|
||||
private sealed class Utf8StringWriter : StringWriter
|
||||
{
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// Sends notification mail through Exchange Web Services over plain HTTPS + SOAP — no EWS SDK,
|
||||
/// honouring the project's no-new-NuGet-package rule.
|
||||
/// <para>
|
||||
/// Authentication is an explicit <c>Authorization: Basic</c> header set on each
|
||||
/// <see cref="HttpRequestMessage"/>, never on the shared <see cref="HttpClient"/>: the factory's
|
||||
/// handler is pooled and shared across every configuration, so a client-level credential would
|
||||
/// leak between callers. Negotiate/NTLM is the documented follow-on if Basic is ever disabled on
|
||||
/// the EWS virtual directory.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Classification (see the design's §4.3) prefers what Exchange <em>said</em> over what HTTP
|
||||
/// reported: a parsed response code decides the outcome even on a 500, and only an unparseable
|
||||
/// body falls back to the status code. Unclassified failures default to permanent, matching the
|
||||
/// SMTP adapter's stance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The password and the base64 Basic-auth value never appear in a thrown message or a log line:
|
||||
/// attempts are logged at Debug with the endpoint host and a recipient <em>count</em> only, and
|
||||
/// every surfaced message runs through <see cref="CredentialRedactor"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EwsSoapMailSender : IEwsMailSender
|
||||
{
|
||||
/// <summary>The named <see cref="HttpClient"/> registered for EWS in <c>ServiceCollectionExtensions</c>.</summary>
|
||||
public const string HttpClientName = "EwsMail";
|
||||
|
||||
/// <summary>Mask applied to the base64 credential, matching <see cref="CredentialRedactor"/>'s.</summary>
|
||||
private const string Mask = "***REDACTED***";
|
||||
|
||||
/// <summary>
|
||||
/// EWS response codes that describe load or availability rather than a defect in the request.
|
||||
/// Everything else — schema faults, recipient rejections, authorization — is permanent.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> TransientResponseCodes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ErrorServerBusy",
|
||||
"ErrorInternalServerTransientError",
|
||||
"ErrorTimeoutExpired",
|
||||
"ErrorMailboxStoreUnavailable",
|
||||
"ErrorInsufficientResources",
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<EwsSoapMailSender> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="EwsSoapMailSender"/>.</summary>
|
||||
/// <param name="httpClientFactory">Factory creating the named <c>"EwsMail"</c> HTTP client per send.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public EwsSoapMailSender(IHttpClientFactory httpClientFactory, ILogger<EwsSoapMailSender> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpClientFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Defense in depth: a Basic header is a replayable cleartext credential, so it never
|
||||
// leaves this process over anything but https — not even if a misconfigured
|
||||
// SmtpConfiguration row supplies an http:// URL. Config defects do not improve on
|
||||
// retry, so this is permanent. Only the scheme and host appear in the message.
|
||||
if (!string.Equals(request.Endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var schemeError =
|
||||
$"EWS endpoint '{request.Endpoint.Scheme}://{request.Endpoint.Host}' is not https; " +
|
||||
"Basic credentials are only ever sent over https.";
|
||||
_logger.LogError("Permanent EWS failure: {Detail}", schemeError);
|
||||
throw new EwsPermanentException(schemeError);
|
||||
}
|
||||
|
||||
var envelope = EwsSoapEnvelope.BuildCreateItem(
|
||||
request.FromAddress, request.BccRecipients, request.Subject, request.Body);
|
||||
|
||||
var packedCredential = $"{request.Username}:{request.Password}";
|
||||
var base64Credential = Convert.ToBase64String(Encoding.UTF8.GetBytes(packedCredential));
|
||||
|
||||
// Recipient COUNT only — addresses are notification content and do not belong in the log.
|
||||
_logger.LogDebug(
|
||||
"Submitting EWS CreateItem to {EwsHost} for {RecipientCount} recipient(s).",
|
||||
request.Endpoint.Host,
|
||||
request.BccRecipients.Count);
|
||||
|
||||
// Per-request timeout layered over the caller's token, so a stalled Exchange surfaces as
|
||||
// a TaskCanceledException with no caller cancel — classified transient below — while a
|
||||
// genuine caller cancel still propagates unwrapped. HttpClient.Timeout is left alone: the
|
||||
// client is shared and its timeout is not per-configuration.
|
||||
using var linkedCts = request.TimeoutSeconds > 0
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
: null;
|
||||
linkedCts?.CancelAfter(TimeSpan.FromSeconds(request.TimeoutSeconds));
|
||||
var sendToken = linkedCts?.Token ?? cancellationToken;
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient(HttpClientName);
|
||||
|
||||
try
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, request.Endpoint)
|
||||
{
|
||||
Content = new StringContent(envelope, Encoding.UTF8, "text/xml"),
|
||||
};
|
||||
httpRequest.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", base64Credential);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, sendToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(sendToken);
|
||||
|
||||
ThrowOnFailure(response.StatusCode, responseBody, packedCredential, base64Credential);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller cancelled: neither a success nor a delivery failure, so it propagates
|
||||
// unchanged rather than being recorded as a transient send error.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
// Transport failure or our own timeout — availability-shaped, so retry. Logged with
|
||||
// the host and exception type only: the exception message is untrusted text and the
|
||||
// recipient addresses are notification content.
|
||||
_logger.LogWarning(
|
||||
"Transient EWS failure contacting {EwsHost} ({ExceptionType}).",
|
||||
request.Endpoint.Host,
|
||||
ex.GetType().Name);
|
||||
|
||||
throw new EwsTransientException(
|
||||
Scrub(
|
||||
$"EWS request to {request.Endpoint.Host} failed: {ex.Message}",
|
||||
packedCredential,
|
||||
base64Credential),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies one EWS response, returning quietly on success and throwing the matching typed
|
||||
/// exception otherwise. The parsed response body wins over the HTTP status whenever it is
|
||||
/// intelligible; only an unparseable body falls back to the status code.
|
||||
/// </summary>
|
||||
private void ThrowOnFailure(
|
||||
HttpStatusCode statusCode,
|
||||
string responseBody,
|
||||
string packedCredential,
|
||||
string base64Credential)
|
||||
{
|
||||
var parsed = EwsResponseParser.Parse(responseBody);
|
||||
|
||||
// Exchange accepted the message. A non-2xx status alongside a Success response class is
|
||||
// not a thing Exchange does, but if a proxy rewrites the status the message still went.
|
||||
if (parsed.Kind == EwsResponseKind.Success
|
||||
|| string.Equals(parsed.ResponseCode, "NoError", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var status = (int)statusCode;
|
||||
|
||||
if (parsed.Kind is EwsResponseKind.Error or EwsResponseKind.Fault)
|
||||
{
|
||||
var code = parsed.ResponseCode ?? "(no response code)";
|
||||
var detail = Scrub(
|
||||
$"EWS returned {code} (HTTP {status}): {parsed.MessageText ?? "(no message text)"}",
|
||||
packedCredential,
|
||||
base64Credential);
|
||||
|
||||
if (parsed.ResponseCode is not null && TransientResponseCodes.Contains(parsed.ResponseCode))
|
||||
{
|
||||
_logger.LogWarning("Transient EWS failure: {Detail}", detail);
|
||||
throw new EwsTransientException(detail);
|
||||
}
|
||||
|
||||
// Includes a fault carrying no response code: the default-permanent stance keeps an
|
||||
// unrecognised failure from retrying forever.
|
||||
_logger.LogError("Permanent EWS failure: {Detail}", detail);
|
||||
throw new EwsPermanentException(detail);
|
||||
}
|
||||
|
||||
// Unparseable body — nothing but the HTTP status is left to judge on.
|
||||
var message = $"EWS endpoint returned {status} ({statusCode}) with an unrecognised body";
|
||||
|
||||
// 401/403 credential or authorization, 404/410 wrong URL, 405 wrong verb/endpoint: all
|
||||
// configuration defects, and retrying 401 burns a domain account's lockout budget.
|
||||
if (status is 401 or 403 or 404 or 405 or 410)
|
||||
{
|
||||
_logger.LogError("Permanent EWS failure: {Detail}", message);
|
||||
throw new EwsPermanentException(message);
|
||||
}
|
||||
|
||||
if (status is 408 or 429 || status >= 500)
|
||||
{
|
||||
_logger.LogWarning("Transient EWS failure: {Detail}", message);
|
||||
throw new EwsTransientException(message);
|
||||
}
|
||||
|
||||
// A 2xx whose body is not an EWS response is a protocol violation from something in the
|
||||
// path (proxy error page, captive portal); anything else non-success is unclassified.
|
||||
// Neither is fixed by replaying the same request.
|
||||
_logger.LogError("Permanent EWS failure: {Detail}", message);
|
||||
throw new EwsPermanentException(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Masks both credential shapes — the packed <c>user:password</c> (via the shared
|
||||
/// <see cref="CredentialRedactor"/>, which also covers the bare password) and the base64
|
||||
/// Basic-auth value — out of text bound for an exception message or a log line.
|
||||
/// </summary>
|
||||
private static string Scrub(string text, string packedCredential, string base64Credential)
|
||||
=> CredentialRedactor
|
||||
.Scrub(text, packedCredential)
|
||||
.Replace(base64Credential, Mask, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot EWS mail submission (<c>CreateItem</c>, <c>SendOnly</c>, BCC-only).
|
||||
/// <para>
|
||||
/// The seam the central Notification Outbox's <c>EmailNotificationDeliveryAdapter</c> calls when a
|
||||
/// <c>SmtpConfiguration</c> selects the EWS transport, mirroring the role
|
||||
/// <c>ISmtpClientWrapper</c> plays for the SMTP transport.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IEwsMailSender
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends one message through Exchange Web Services.
|
||||
/// </summary>
|
||||
/// <param name="request">The endpoint, credential, sender, recipients and content to submit.</param>
|
||||
/// <param name="cancellationToken">Token cancelling the send; propagates unwrapped when the caller cancels.</param>
|
||||
/// <returns>A task completing when Exchange has accepted the message.</returns>
|
||||
/// <exception cref="EwsPermanentException">
|
||||
/// The send failed for a reason retrying cannot fix — authentication (401/403), a wrong URL
|
||||
/// (404), a schema fault, or a recipient rejection.
|
||||
/// </exception>
|
||||
/// <exception cref="EwsTransientException">
|
||||
/// The send failed for an availability-shaped reason — network/DNS/timeout, HTTP 5xx/408/429,
|
||||
/// or an <c>ErrorServerBusy</c>-class response code — and is worth retrying.
|
||||
/// </exception>
|
||||
Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything one EWS submission needs. Carried per call rather than held on the sender so the
|
||||
/// sender stays a stateless singleton and the credential never outlives the request.
|
||||
/// </summary>
|
||||
/// <param name="Endpoint">The absolute HTTPS EWS URL (for example <c>https://host/EWS/Exchange.asmx</c>).</param>
|
||||
/// <param name="Username">The service account, which may be domain-qualified (<c>domain\user</c>).</param>
|
||||
/// <param name="Password">The service account password.</param>
|
||||
/// <param name="FromAddress">The sending mailbox address.</param>
|
||||
/// <param name="BccRecipients">The recipients; all addressed BCC so they cannot see one another.</param>
|
||||
/// <param name="Subject">The message subject.</param>
|
||||
/// <param name="Body">The plain-text message body.</param>
|
||||
/// <param name="TimeoutSeconds">
|
||||
/// The per-request timeout; a non-positive value leaves the <see cref="HttpClient"/> default in force.
|
||||
/// </param>
|
||||
public sealed record EwsSendRequest(
|
||||
Uri Endpoint,
|
||||
string Username,
|
||||
string Password,
|
||||
string FromAddress,
|
||||
IReadOnlyList<string> BccRecipients,
|
||||
string Subject,
|
||||
string Body,
|
||||
int TimeoutSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Signals an availability-shaped EWS failure that is worth retrying.
|
||||
/// </summary>
|
||||
public class EwsTransientException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the exception with a message and optional inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">Message describing the transient EWS failure; never carries the credential.</param>
|
||||
/// <param name="innerException">Optional underlying transport exception.</param>
|
||||
public EwsTransientException(string message, Exception? innerException = null)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals an EWS failure that retrying cannot fix, so the notification parks immediately.
|
||||
/// </summary>
|
||||
public class EwsPermanentException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the exception with a message and optional inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">Message describing the permanent EWS failure; never carries the credential.</param>
|
||||
/// <param name="innerException">Optional underlying transport exception.</param>
|
||||
public EwsPermanentException(string message, Exception? innerException = null)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the shared SMTP delivery primitives consumed by the central Notification
|
||||
/// Registers the shared email delivery primitives consumed by the central Notification
|
||||
/// Outbox's <c>EmailNotificationDeliveryAdapter</c>: <see cref="NotificationOptions"/>,
|
||||
/// <see cref="OAuth2TokenService"/>, and the <see cref="ISmtpClientWrapper"/> factory.
|
||||
/// <see cref="OAuth2TokenService"/>, the <see cref="ISmtpClientWrapper"/> factory, and —
|
||||
/// for the EWS transport — <see cref="IEwsMailSender"/> with its named HTTP client.
|
||||
/// Central-only — sites no longer deliver notifications (see
|
||||
/// <c>Component-NotificationService.md</c>), and the orphaned site-shaped
|
||||
/// <c>NotificationDeliveryService</c> + <c>INotificationDeliveryService</c> contract
|
||||
@@ -30,6 +32,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<OAuth2TokenService>();
|
||||
services.AddSingleton<Func<ISmtpClientWrapper>>(_ => () => new MailKitSmtpClientWrapper());
|
||||
|
||||
// EWS transport: a named client so the EWS calls get their own handler pool, and a
|
||||
// stateless singleton sender (credentials travel per request, never on the client).
|
||||
services.AddHttpClient(EwsSoapMailSender.HttpClientName);
|
||||
services.TryAddSingleton<IEwsMailSender, EwsSoapMailSender>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,12 @@ public sealed class ArtifactDiff
|
||||
AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
|
||||
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
||||
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
||||
// Non-sensitive delivery metadata: diffed by value (unlike the credential,
|
||||
// which is presence-only below). Both sides null on pre-EWS bundles, so a
|
||||
// legacy configuration still classifies Identical.
|
||||
AddIfDifferent(changes, "OAuth2Authority", existing.OAuth2Authority, incoming.OAuth2Authority);
|
||||
AddIfDifferent(changes, "OAuth2Scope", existing.OAuth2Scope, incoming.OAuth2Scope);
|
||||
AddIfDifferent(changes, "Transport", existing.Transport, incoming.Transport);
|
||||
|
||||
var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
|
||||
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
|
||||
|
||||
@@ -3437,6 +3437,14 @@ public sealed class BundleImporter : IBundleImporter
|
||||
target.MaxConcurrentConnections = dto.MaxConcurrentConnections;
|
||||
target.MaxRetries = dto.MaxRetries;
|
||||
target.RetryDelay = dto.RetryDelay;
|
||||
// Non-sensitive delivery metadata; null on a bundle written before these
|
||||
// were carried, which is the entity's "provider defaults / Smtp" state.
|
||||
// Consequence, by design: a legacy (pre-Transport-field) bundle applied with
|
||||
// Overwrite carries nulls here and so downgrades an EWS-configured target back
|
||||
// to SMTP — visible to the operator as a Transport row in the import preview diff.
|
||||
target.OAuth2Authority = dto.OAuth2Authority;
|
||||
target.OAuth2Scope = dto.OAuth2Scope;
|
||||
target.Transport = dto.Transport;
|
||||
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,12 @@ public sealed record NotificationRecipientDto(
|
||||
// WhenWritingNull policy keeps it out of email-only bundles' JSON.
|
||||
string? PhoneNumber = null);
|
||||
|
||||
/// <summary>
|
||||
/// An email delivery configuration (the <c>SmtpConfiguration</c> entity). Every
|
||||
/// non-sensitive field is carried directly and only the credential (SMTP password
|
||||
/// or OAuth2 client secret) rides inside <see cref="Secrets"/>, so a future
|
||||
/// "share without secrets" export can drop it as a unit.
|
||||
/// </summary>
|
||||
public sealed record SmtpConfigDto(
|
||||
string Host,
|
||||
int Port,
|
||||
@@ -295,7 +301,17 @@ public sealed record SmtpConfigDto(
|
||||
int MaxConcurrentConnections,
|
||||
int MaxRetries,
|
||||
TimeSpan RetryDelay,
|
||||
SecretsBlock? Secrets);
|
||||
SecretsBlock? Secrets,
|
||||
// OAuth2 token-endpoint authority and scope: endpoint URLs, not secrets, so
|
||||
// they belong on the public DTO rather than in the SecretsBlock. Trailing +
|
||||
// nullable so a bundle written before these were carried deserializes them as
|
||||
// null (= provider defaults); WhenWritingNull keeps them out of the JSON of
|
||||
// configurations that don't set them.
|
||||
string? OAuth2Authority = null,
|
||||
string? OAuth2Scope = null,
|
||||
// Delivery transport ("Smtp" or "Ews"); null means Smtp. Trailing + nullable
|
||||
// for the same additive-evolution reason — no bundleFormatVersion bump.
|
||||
string? Transport = null);
|
||||
|
||||
/// <summary>
|
||||
/// An SMS provider configuration (the <c>SmsConfiguration</c> entity). Mirrors
|
||||
|
||||
@@ -186,7 +186,12 @@ public sealed class EntitySerializer
|
||||
MaxConcurrentConnections: smtp.MaxConcurrentConnections,
|
||||
MaxRetries: smtp.MaxRetries,
|
||||
RetryDelay: smtp.RetryDelay,
|
||||
Secrets: secrets);
|
||||
Secrets: secrets,
|
||||
// Non-sensitive delivery metadata: the OAuth2 endpoint pair and
|
||||
// the Smtp/Ews transport selector travel as plain DTO fields.
|
||||
OAuth2Authority: smtp.OAuth2Authority,
|
||||
OAuth2Scope: smtp.OAuth2Scope,
|
||||
Transport: smtp.Transport);
|
||||
}).ToList(),
|
||||
// Inbound API keys are not transported between environments:
|
||||
// the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
|
||||
@@ -486,6 +491,11 @@ public sealed class EntitySerializer
|
||||
MaxConcurrentConnections = dto.MaxConcurrentConnections,
|
||||
MaxRetries = dto.MaxRetries,
|
||||
RetryDelay = dto.RetryDelay,
|
||||
// Null on a bundle written before these fields were carried, which
|
||||
// is exactly the entity's "use the provider defaults / Smtp" state.
|
||||
OAuth2Authority = dto.OAuth2Authority,
|
||||
OAuth2Scope = dto.OAuth2Scope,
|
||||
Transport = dto.Transport,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge notification smtp update</c> subcommand. The command
|
||||
/// gained two optional flags — <c>--tls-mode</c> and <c>--credentials</c> — that plumb
|
||||
/// through to <see cref="UpdateSmtpConfigCommand"/>. These tests pin that the flags
|
||||
/// parse, are genuinely optional (non-breaking), and that <c>--tls-mode</c> rejects
|
||||
/// values outside the canonical {None, StartTLS, SSL} set.
|
||||
/// gained optional flags — <c>--tls-mode</c>, <c>--credentials</c> and <c>--transport</c> —
|
||||
/// that plumb through to <see cref="UpdateSmtpConfigCommand"/>. These tests pin that the
|
||||
/// flags parse, are genuinely optional (non-breaking), that <c>--tls-mode</c> rejects
|
||||
/// values outside the canonical {None, StartTLS, SSL} set, and that <c>--transport</c>
|
||||
/// rejects values outside {Smtp, Ews}.
|
||||
/// </summary>
|
||||
public class SmtpUpdateCommandTests
|
||||
{
|
||||
@@ -91,6 +92,46 @@ public class SmtpUpdateCommandTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WithTransportEws_ProducesCommandCarryingIt()
|
||||
{
|
||||
var parse = ParseUpdate(
|
||||
"--id", "1", "--server", "https://mail.example.com/ews/exchange.asmx", "--port", "443",
|
||||
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||
"--transport", "Ews");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
|
||||
|
||||
Assert.Equal("Ews", cmd.Transport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WithoutTransport_ProducesCommandWithNullTransport()
|
||||
{
|
||||
var parse = ParseUpdate(
|
||||
"--id", "2", "--server", "smtp.example.com", "--port", "25",
|
||||
"--auth-mode", "Basic", "--from-address", "noreply@example.com");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
|
||||
|
||||
Assert.Null(cmd.Transport);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Graph")]
|
||||
[InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
|
||||
public void Update_TransportOption_RejectsValuesOutsideCanonicalSet(string value)
|
||||
{
|
||||
var parse = ParseUpdate(
|
||||
"--id", "1", "--server", "smtp.example.com", "--port", "587",
|
||||
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||
"--transport", value);
|
||||
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TlsModeAndCredentials_AreNotRequired()
|
||||
{
|
||||
|
||||
@@ -37,6 +37,55 @@ public class SmtpConfigurationPageTests : BunitContext
|
||||
Credentials = "user:pass",
|
||||
};
|
||||
|
||||
private static SmtpConfiguration OAuth2Sample() =>
|
||||
new("smtp.example.com", "OAuth2", "noreply@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 587,
|
||||
TlsMode = "StartTLS",
|
||||
Credentials = "client-secret",
|
||||
};
|
||||
|
||||
private static SmtpConfiguration EwsSample() =>
|
||||
new("https://mail.example.com/ews/exchange.asmx", "Basic", "noreply@example.com")
|
||||
{
|
||||
Id = 2,
|
||||
Port = 587,
|
||||
TlsMode = "StartTLS",
|
||||
Transport = "Ews",
|
||||
Credentials = @"domain\user:pass",
|
||||
};
|
||||
|
||||
private IRenderedComponent<SmtpConfigurationPage> RenderWith(
|
||||
INotificationRepository repo, params SmtpConfiguration[] configs)
|
||||
{
|
||||
repo.GetAllSmtpConfigurationsAsync()
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(configs.ToList()));
|
||||
Services.AddSingleton(repo);
|
||||
WireAuth();
|
||||
|
||||
var cut = Render<SmtpConfigurationPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains(configs[0].Host));
|
||||
return cut;
|
||||
}
|
||||
|
||||
private static void ClickEdit(IRenderedComponent<SmtpConfigurationPage> cut)
|
||||
=> cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click();
|
||||
|
||||
private static void ClickSave(IRenderedComponent<SmtpConfigurationPage> cut)
|
||||
=> cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click();
|
||||
|
||||
/// <summary>Locates a select by an option it offers — the page has no element ids.</summary>
|
||||
private static AngleSharp.Dom.IElement SelectWithOption(
|
||||
IRenderedComponent<SmtpConfigurationPage> cut, string optionText)
|
||||
=> cut.FindAll("select")
|
||||
.Single(s => s.QuerySelectorAll("option").Any(o => o.TextContent == optionText));
|
||||
|
||||
private static bool HasSelectWithOption(
|
||||
IRenderedComponent<SmtpConfigurationPage> cut, string optionText)
|
||||
=> cut.FindAll("select")
|
||||
.Any(s => s.QuerySelectorAll("option").Any(o => o.TextContent == optionText));
|
||||
|
||||
[Fact]
|
||||
public void EditForm_RendersTlsModeSelectWithAllThreeModes()
|
||||
{
|
||||
@@ -110,4 +159,147 @@ public class SmtpConfigurationPageTests : BunitContext
|
||||
repo.Received().SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Transport selector (EWS) — form state machine
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void EditForm_WithEwsTransport_HidesPortTlsAndAuthTypeAndForcesBasicAuth()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
var cut = RenderWith(repo, OAuth2Sample());
|
||||
|
||||
ClickEdit(cut);
|
||||
SelectWithOption(cut, "Ews").Change("Ews");
|
||||
|
||||
// Port input, TLS select and Auth Type select are all gone under EWS.
|
||||
Assert.Empty(cut.FindAll("input[type=number]"));
|
||||
Assert.False(HasSelectWithOption(cut, "StartTLS"));
|
||||
Assert.False(HasSelectWithOption(cut, "OAuth2"));
|
||||
Assert.Contains("EWS URL", cut.Markup);
|
||||
|
||||
// The forced auth type is only observable through what gets persisted: the row
|
||||
// loaded as OAuth2 must save as Basic because EWS supports Basic only.
|
||||
cut.Find("input[type=text]").Change("https://mail.example.com/ews/exchange.asmx");
|
||||
cut.Find("input[type=password]").Change(@"dom\user:pw");
|
||||
ClickSave(cut);
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
repo.Received().UpdateSmtpConfigurationAsync(Arg.Is<SmtpConfiguration>(
|
||||
c => c.Transport == "Ews"
|
||||
&& c.AuthType == "Basic"
|
||||
&& c.Host == "https://mail.example.com/ews/exchange.asmx")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditForm_SwitchingEwsBackToSmtp_RestoresAuthTypeAndReshowsPortAndTls()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
var cut = RenderWith(repo, OAuth2Sample());
|
||||
|
||||
ClickEdit(cut);
|
||||
SelectWithOption(cut, "Ews").Change("Ews");
|
||||
SelectWithOption(cut, "Ews").Change("Smtp");
|
||||
|
||||
// Port and TLS controls are back...
|
||||
Assert.Single(cut.FindAll("input[type=number]"));
|
||||
Assert.True(HasSelectWithOption(cut, "StartTLS"));
|
||||
Assert.True(HasSelectWithOption(cut, "OAuth2"));
|
||||
|
||||
// ...and the round trip left the pre-switch values (OAuth2 / 587 / StartTLS) intact.
|
||||
ClickSave(cut);
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
repo.Received().UpdateSmtpConfigurationAsync(Arg.Is<SmtpConfiguration>(
|
||||
c => c.Transport == "Smtp"
|
||||
&& c.AuthType == "OAuth2"
|
||||
&& c.Port == 587
|
||||
&& c.TlsMode == "StartTLS")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_WithEwsTransportAndNonHttpsHost_ShowsFormErrorAndDoesNotPersist()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
var cut = RenderWith(repo, OAuth2Sample());
|
||||
|
||||
ClickEdit(cut);
|
||||
SelectWithOption(cut, "Ews").Change("Ews");
|
||||
cut.Find("input[type=text]").Change("http://mail.example.com/ews/exchange.asmx");
|
||||
ClickSave(cut);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("absolute https://", cut.Markup));
|
||||
repo.DidNotReceive().UpdateSmtpConfigurationAsync(Arg.Any<SmtpConfiguration>());
|
||||
repo.DidNotReceive().SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_WithEwsTransportAndCredentialsWithoutColon_ShowsFormErrorAndDoesNotPersist()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
// The realistic mistake: an existing SMTP row whose credential is a bare password
|
||||
// is flipped to Ews. Host is valid, so only the credentials rule can reject it.
|
||||
var cut = RenderWith(repo, OAuth2Sample());
|
||||
|
||||
ClickEdit(cut);
|
||||
SelectWithOption(cut, "Ews").Change("Ews");
|
||||
cut.Find("input[type=text]").Change("https://mail.example.com/ews/exchange.asmx");
|
||||
cut.Find("input[type=password]").Change("bare-password");
|
||||
ClickSave(cut);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("username:password", cut.Markup));
|
||||
repo.DidNotReceive().UpdateSmtpConfigurationAsync(Arg.Any<SmtpConfiguration>());
|
||||
repo.DidNotReceive().SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_WithEwsTransportAndDomainQualifiedCredentials_Persists()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
var cut = RenderWith(repo, OAuth2Sample());
|
||||
|
||||
ClickEdit(cut);
|
||||
SelectWithOption(cut, "Ews").Change("Ews");
|
||||
cut.Find("input[type=text]").Change("https://mail.example.com/ews/exchange.asmx");
|
||||
// A domain-qualified user name carries no colon of its own; the split takes the
|
||||
// FIRST colon only, so a password containing colons stays intact.
|
||||
cut.Find("input[type=password]").Change(@"dom\user:pw:with:colons");
|
||||
ClickSave(cut);
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
repo.Received().UpdateSmtpConfigurationAsync(Arg.Is<SmtpConfiguration>(
|
||||
c => c.Transport == "Ews" && c.Credentials == @"dom\user:pw:with:colons"));
|
||||
repo.Received().SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOnlyView_ShowsTransportBadgeAndHidesPortAndTlsForEwsRows()
|
||||
{
|
||||
var ewsCut = RenderWith(Substitute.For<INotificationRepository>(), EwsSample());
|
||||
|
||||
ewsCut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("<span class=\"badge bg-secondary\">Ews</span>", ewsCut.Markup);
|
||||
Assert.Contains("EWS URL", ewsCut.Markup);
|
||||
Assert.DoesNotContain("TLS Mode", ewsCut.Markup);
|
||||
// The EWS URL is rendered bare — no ":port" suffix as the SMTP host row has.
|
||||
Assert.DoesNotContain("exchange.asmx:587", ewsCut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOnlyView_LegacyRowWithNullTransport_ShowsSmtpBadgeAndKeepsPortAndTls()
|
||||
{
|
||||
var cut = RenderWith(Substitute.For<INotificationRepository>(), Sample());
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("<span class=\"badge bg-secondary\">Smtp</span>", cut.Markup);
|
||||
Assert.Contains("smtp.example.com:587", cut.Markup);
|
||||
Assert.Contains("TLS Mode", cut.Markup);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1667,6 +1667,303 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Equal("old.scope/.default", existing.OAuth2Scope);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// UpdateSmtpConfig — Transport (EWS) write-path gate
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_WithEwsTransport_PersistsTransportAndSurfacesItInPublicShape()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"old.example.com", "Basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 25,
|
||||
Credentials = "example\\notify:fake-password",
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||
Transport: "Ews"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.Equal("Ews", existing.Transport);
|
||||
Assert.Equal("https://ews.example.test/ews/exchange.asmx", existing.Host);
|
||||
// The transport is a non-secret field, so it belongs on the public shape
|
||||
// the response and audit afterState carry.
|
||||
Assert.Contains("\"transport\":\"Ews\"", response.JsonData, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_WithUnknownTransport_ReturnsManagementError()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"old.example.com", "Basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 25,
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://graph.example.test/", 443, "basic", "new@example.com",
|
||||
Transport: "Graph"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Unknown email transport", response.Error);
|
||||
Assert.Contains("Graph", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_WithEwsTransportAndNonHttpsHost_ReturnsManagementError()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"old.example.com", "Basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 25,
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "http://ews.example.test/ews/exchange.asmx", 80, "basic", "new@example.com",
|
||||
Transport: "Ews"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("https://", response.Error);
|
||||
// The offending value is echoed so the operator sees what was rejected
|
||||
// (matches the delivery adapter's message style).
|
||||
Assert.Contains("http://ews.example.test/ews/exchange.asmx", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_WithEwsTransportAndOAuth2Auth_ReturnsManagementError()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"old.example.com", "Basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 25,
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://ews.example.test/ews/exchange.asmx", 443, "oauth2", "new@example.com",
|
||||
Transport: "Ews"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Basic authentication", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_WithEwsTransportAndMalformedCredentials_ReturnsManagementError()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"old.example.com", "Basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 25,
|
||||
Credentials = "example\\notify:fake-password",
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
// A supplied credential with no colon at all — the EWS sender cannot build a
|
||||
// Basic header from it, so every send would park.
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||
Credentials: "passwordwithoutcolon",
|
||||
Transport: "Ews"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("username:password", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_WithEwsTransportAndEmptyCredentialHalf_ReturnsManagementError()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"old.example.com", "Basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 25,
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
// A trailing colon splits into two parts, but the password half is empty —
|
||||
// the adapter rejects it, so the write gate must too.
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||
Credentials: "example\\notify:",
|
||||
Transport: "Ews"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("username:password", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_FlippingToEwsWithStoredBadCredentials_ReturnsManagementError()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"old.example.com", "Basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 25,
|
||||
Credentials = "bare-smtp-password",
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
// Credentials omitted entirely: the gate validates the EFFECTIVE state, so the
|
||||
// preserved (unusable) stored value is what gets rejected.
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||
Transport: "Ews"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("username:password", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue()
|
||||
{
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 443,
|
||||
Transport = "Ews",
|
||||
Credentials = "example\\notify:fake-password",
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
// Transport omitted (a pre-EWS caller, or Newtonsoft deserializing a
|
||||
// payload without the field): the stored transport survives, and the
|
||||
// EWS shape rules are still enforced against that preserved value.
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://ews2.example.test/ews/exchange.asmx", 443, "basic", "new@example.com"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.Equal("Ews", existing.Transport);
|
||||
Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Smtp")]
|
||||
[InlineData("Ews")]
|
||||
[InlineData("Graph")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void UpdateSmtpConfig_TransportAcceptance_MatchesEmailTransportParser(string? transport)
|
||||
{
|
||||
// Parity pin: the write gate re-implements the transport parse inline (ManagementService
|
||||
// does not reference NotificationService), so this asserts the mirror accepts exactly the
|
||||
// values EmailTransportParser accepts. If the parser gains a transport, this goes red.
|
||||
var parserAccepts = true;
|
||||
try
|
||||
{
|
||||
NotificationService.EmailTransportParser.Parse(transport);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
parserAccepts = false;
|
||||
}
|
||||
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
// A fixture valid under EITHER transport, so the transport parse is the only variable.
|
||||
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||
"https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 443,
|
||||
Credentials = "example\\notify:fake-password",
|
||||
};
|
||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(
|
||||
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||
Transport: transport),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
if (parserAccepts)
|
||||
{
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Unknown email transport", error.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SMS Notifications (S5) — list Type discriminator + SMS-config management
|
||||
// ========================================================================
|
||||
|
||||
+5
@@ -28,5 +28,10 @@
|
||||
<!-- #206: SecuredWrite audit now routes through the real CentralAuditWriter so the
|
||||
handler tests verify SourceNode stamping end-to-end (actor → writer → repo). -->
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj" />
|
||||
<!-- Test-only: ManagementActor deliberately does NOT reference NotificationService, so its
|
||||
EWS write gate re-implements the transport parse inline. This reference exists purely so
|
||||
a parity test can pin that mirror to the real EmailTransportParser. Do not take a
|
||||
production dependency on it from ManagementService. -->
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
|
||||
|
||||
/// <summary>
|
||||
/// Task 5: Tests for the EWS transport branch of the Email outbox delivery adapter —
|
||||
/// the transport parse, the EWS config-shape validation (each failure permanent and
|
||||
/// short-circuiting before the sender is touched), the request the sender receives,
|
||||
/// the typed transient/permanent outcome mapping, and the proof that an unset or
|
||||
/// "Smtp" transport still takes the untouched SMTP path.
|
||||
/// </summary>
|
||||
public class EmailNotificationDeliveryAdapterEwsTests
|
||||
{
|
||||
private const string Endpoint = "https://ews.example.test/ews/exchange.asmx";
|
||||
|
||||
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
|
||||
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
|
||||
private readonly IEwsMailSender _ewsSender = Substitute.For<IEwsMailSender>();
|
||||
|
||||
private EmailNotificationDeliveryAdapter CreateAdapter(bool withEwsSender = true)
|
||||
{
|
||||
return new EmailNotificationDeliveryAdapter(
|
||||
_repository,
|
||||
() => _smtpClient,
|
||||
NullLogger<EmailNotificationDeliveryAdapter>.Instance,
|
||||
tokenService: null,
|
||||
options: null,
|
||||
ewsMailSender: withEwsSender ? _ewsSender : null);
|
||||
}
|
||||
|
||||
private static Notification MakeNotification(string listName = "ops-team")
|
||||
{
|
||||
return new Notification(
|
||||
Guid.NewGuid().ToString(),
|
||||
NotificationType.Email,
|
||||
listName,
|
||||
"Subject",
|
||||
"Body",
|
||||
"site-1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires a resolvable list with two recipients plus a single configuration row shaped
|
||||
/// by the caller — the one knob every case below turns.
|
||||
/// </summary>
|
||||
private void SetupWithConfig(SmtpConfiguration config)
|
||||
{
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
var recipients = new List<NotificationRecipient>
|
||||
{
|
||||
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
|
||||
new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 }
|
||||
};
|
||||
|
||||
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
||||
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { config });
|
||||
}
|
||||
|
||||
private static SmtpConfiguration EwsConfig(
|
||||
string host = Endpoint,
|
||||
string authType = "basic",
|
||||
string? credentials = @"dom\svc:pw",
|
||||
string transport = "Ews")
|
||||
{
|
||||
return new SmtpConfiguration(host, authType, "noreply@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Transport = transport,
|
||||
Credentials = credentials,
|
||||
ConnectionTimeoutSeconds = 45
|
||||
};
|
||||
}
|
||||
|
||||
private static SmtpConfiguration SmtpConfig(string? transport)
|
||||
{
|
||||
return new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", Transport = transport
|
||||
};
|
||||
}
|
||||
|
||||
private EwsSendRequest CapturedRequest()
|
||||
{
|
||||
var call = _ewsSender.ReceivedCalls().Single();
|
||||
return (EwsSendRequest)call.GetArguments()[0]!;
|
||||
}
|
||||
|
||||
private async Task AssertSenderUntouched()
|
||||
{
|
||||
await _ewsSender.DidNotReceive().SendAsync(
|
||||
Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_EwsTransport_SendsThroughEwsSenderAndReturnsSuccess()
|
||||
{
|
||||
SetupWithConfig(EwsConfig());
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
var request = CapturedRequest();
|
||||
Assert.Equal(new Uri(Endpoint), request.Endpoint);
|
||||
Assert.Equal(@"dom\svc", request.Username);
|
||||
Assert.Equal("pw", request.Password);
|
||||
Assert.Equal("noreply@example.com", request.FromAddress);
|
||||
Assert.Equal(new[] { "alice@example.com", "bob@example.com" }, request.BccRecipients);
|
||||
Assert.Equal("Subject", request.Subject);
|
||||
Assert.Equal("Body", request.Body);
|
||||
Assert.Equal(45, request.TimeoutSeconds);
|
||||
|
||||
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||
Assert.Contains("alice@example.com", outcome.ResolvedTargets);
|
||||
Assert.Contains("bob@example.com", outcome.ResolvedTargets);
|
||||
Assert.Null(outcome.Error);
|
||||
|
||||
// The EWS branch must never open an SMTP connection.
|
||||
await _smtpClient.DidNotReceive().ConnectAsync(
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_PasswordContainsColon_SplitsOnFirstColonOnly()
|
||||
{
|
||||
SetupWithConfig(EwsConfig(credentials: @"dom\svc:p:w"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
var request = CapturedRequest();
|
||||
Assert.Equal(@"dom\svc", request.Username);
|
||||
Assert.Equal("p:w", request.Password);
|
||||
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_EwsTransientException_ReturnsTransient()
|
||||
{
|
||||
SetupWithConfig(EwsConfig());
|
||||
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new EwsTransientException("503 server busy"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
Assert.Contains("server busy", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_EwsPermanentException_ReturnsPermanent()
|
||||
{
|
||||
SetupWithConfig(EwsConfig());
|
||||
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new EwsPermanentException("401 unauthorized"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("unauthorized", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_UnclassifiedSenderException_ReturnsPermanentAndRedactsCredential()
|
||||
{
|
||||
SetupWithConfig(EwsConfig(credentials: @"dom\svc:super-secret-password"));
|
||||
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException(
|
||||
@"boom for dom\svc:super-secret-password"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.DoesNotContain("super-secret-password", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_CancelledToken_ThrowsOperationCanceledException()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
_repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<NotificationRecipient>
|
||||
{
|
||||
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
||||
});
|
||||
_repository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<SmtpConfiguration> { EwsConfig() });
|
||||
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new OperationCanceledException(cts.Token));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => adapter.DeliverAsync(MakeNotification(), cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_NonHttpsEndpoint_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
SetupWithConfig(EwsConfig(host: "http://ews.example.test/ews/exchange.asmx"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("https", outcome.Error);
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_NonAbsoluteEndpoint_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
SetupWithConfig(EwsConfig(host: "ews.example.test"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("ews.example.test", outcome.Error);
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_NonBasicAuthType_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
SetupWithConfig(EwsConfig(authType: "oauth2"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("Basic", outcome.Error, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("no-colon-here")]
|
||||
[InlineData(":pw")]
|
||||
[InlineData(@"dom\svc:")]
|
||||
public async Task Deliver_MalformedCredentials_ReturnsPermanentWithoutCallingSender(
|
||||
string? credentials)
|
||||
{
|
||||
SetupWithConfig(EwsConfig(credentials: credentials));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("username:password", outcome.Error);
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_UnknownTransport_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
SetupWithConfig(EwsConfig(transport: "Graph"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("Graph", outcome.Error);
|
||||
await AssertSenderUntouched();
|
||||
await _smtpClient.DidNotReceive().ConnectAsync(
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_NoEwsSenderRegistered_ReturnsPermanent()
|
||||
{
|
||||
SetupWithConfig(EwsConfig());
|
||||
var adapter = CreateAdapter(withEwsSender: false);
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("EWS", outcome.Error);
|
||||
Assert.Contains("registered", outcome.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("Smtp")]
|
||||
public async Task Deliver_SmtpOrUnsetTransport_TakesSmtpPath(string? transport)
|
||||
{
|
||||
SetupWithConfig(SmtpConfig(transport));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||
await _smtpClient.Received().ConnectAsync(
|
||||
"smtp.example.com", 587, SmtpTlsMode.StartTls,
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for parsing the configured email transport discriminator into the two-state enum.
|
||||
/// </summary>
|
||||
public class EmailTransportParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Smtp", EmailTransport.Smtp)]
|
||||
[InlineData("smtp", EmailTransport.Smtp)]
|
||||
[InlineData("SMTP", EmailTransport.Smtp)]
|
||||
[InlineData("Ews", EmailTransport.Ews)]
|
||||
[InlineData("ews", EmailTransport.Ews)]
|
||||
[InlineData("EWS", EmailTransport.Ews)]
|
||||
[InlineData(" ews ", EmailTransport.Ews)]
|
||||
public void Parse_KnownTransports_ReturnsExpected(string input, EmailTransport expected)
|
||||
{
|
||||
Assert.Equal(expected, EmailTransportParser.Parse(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Parse_NullOrEmpty_DefaultsToSmtp(string? input)
|
||||
{
|
||||
// Every pre-EWS SmtpConfiguration row stores no transport; those rows are SMTP rows.
|
||||
Assert.Equal(EmailTransport.Smtp, EmailTransportParser.Parse(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Graph")]
|
||||
[InlineData("smtps")]
|
||||
public void Parse_UnknownTransport_Throws(string input)
|
||||
{
|
||||
// An unknown transport must be rejected, not silently treated as SMTP.
|
||||
var ex = Assert.Throws<ArgumentException>(() => EmailTransportParser.Parse(input));
|
||||
Assert.Contains(input, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the pure EWS response parser. The parser reports shape only
|
||||
/// (success / error / SOAP fault / unparseable) plus the response code — the
|
||||
/// transient-vs-permanent judgement belongs to the sender's classifier.
|
||||
/// </summary>
|
||||
public class EwsResponseParserTests
|
||||
{
|
||||
private const string SuccessBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||
<m:ResponseMessages>
|
||||
<m:CreateItemResponseMessage ResponseClass="Success">
|
||||
<m:ResponseCode>NoError</m:ResponseCode>
|
||||
<m:Items><t:Message><t:ItemId Id="AAA=" ChangeKey="CQ=="/></t:Message></m:Items>
|
||||
</m:CreateItemResponseMessage>
|
||||
</m:ResponseMessages>
|
||||
</m:CreateItemResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
private const string ErrorBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||
<m:ResponseMessages>
|
||||
<m:CreateItemResponseMessage ResponseClass="Error">
|
||||
<m:MessageText>server busy</m:MessageText>
|
||||
<m:ResponseCode>ErrorServerBusy</m:ResponseCode>
|
||||
<m:DescriptiveLinkKey>0</m:DescriptiveLinkKey>
|
||||
</m:CreateItemResponseMessage>
|
||||
</m:ResponseMessages>
|
||||
</m:CreateItemResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
private const string FaultWithResponseCodeBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<s:Fault>
|
||||
<faultcode xmlns:a="http://schemas.microsoft.com/exchange/services/2006/types">a:ErrorSchemaValidation</faultcode>
|
||||
<faultstring xml:lang="en-US">The request failed schema validation.</faultstring>
|
||||
<detail>
|
||||
<e:ResponseCode xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">ErrorSchemaValidation</e:ResponseCode>
|
||||
<e:Message xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">The request failed schema validation.</e:Message>
|
||||
</detail>
|
||||
</s:Fault>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
private const string FaultWithoutResponseCodeBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<s:Fault>
|
||||
<faultcode>s:Server</faultcode>
|
||||
<faultstring xml:lang="en-US">An internal server error occurred.</faultstring>
|
||||
<detail><e:Message xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">boom</e:Message></detail>
|
||||
</s:Fault>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Parse_SuccessResponse_ReturnsSuccessWithNoError()
|
||||
{
|
||||
var result = EwsResponseParser.Parse(SuccessBody);
|
||||
|
||||
Assert.Equal(EwsResponseKind.Success, result.Kind);
|
||||
Assert.Equal("NoError", result.ResponseCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ErrorResponse_ReturnsErrorWithCodeAndMessage()
|
||||
{
|
||||
var result = EwsResponseParser.Parse(ErrorBody);
|
||||
|
||||
Assert.Equal(EwsResponseKind.Error, result.Kind);
|
||||
Assert.Equal("ErrorServerBusy", result.ResponseCode);
|
||||
Assert.Equal("server busy", result.MessageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SoapFaultWithDetailResponseCode_ReturnsFaultWithCode()
|
||||
{
|
||||
var result = EwsResponseParser.Parse(FaultWithResponseCodeBody);
|
||||
|
||||
Assert.Equal(EwsResponseKind.Fault, result.Kind);
|
||||
Assert.Equal("ErrorSchemaValidation", result.ResponseCode);
|
||||
Assert.Equal("The request failed schema validation.", result.MessageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SoapFaultWithoutResponseCode_ReturnsFaultWithNullCode()
|
||||
{
|
||||
var result = EwsResponseParser.Parse(FaultWithoutResponseCodeBody);
|
||||
|
||||
Assert.Equal(EwsResponseKind.Fault, result.Kind);
|
||||
Assert.Null(result.ResponseCode);
|
||||
Assert.Equal("An internal server error occurred.", result.MessageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_PayloadWithDtdEntityDeclaration_IsRejectedAsUnparseable()
|
||||
{
|
||||
// Pins DTD-prohibited parsing (XXE guard). The response body is external input, so the
|
||||
// parser must never process a DOCTYPE. LINQ-to-XML does NOT prohibit DTDs on its own, so
|
||||
// the parser reads through an XmlReader pinned to DtdProcessing.Prohibit with a null
|
||||
// resolver; a refactor that loosens either setting fails here.
|
||||
//
|
||||
// The payload is deliberately a well-formed EWS response so the assertion has teeth: with
|
||||
// DTDs prohibited the DOCTYPE itself throws and the body is Unparseable, whereas any
|
||||
// DtdProcessing.Parse configuration expands the internal entity and yields Kind=Error.
|
||||
// The external file:/// entity is declared alongside it as the XXE payload that must
|
||||
// likewise never be reached.
|
||||
const string xxe = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE Envelope [
|
||||
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||
<!ENTITY expanded "server busy">
|
||||
]>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
|
||||
<m:ResponseMessages>
|
||||
<m:CreateItemResponseMessage ResponseClass="Error">
|
||||
<m:MessageText>&expanded;</m:MessageText>
|
||||
<m:ResponseCode>ErrorServerBusy</m:ResponseCode>
|
||||
</m:CreateItemResponseMessage>
|
||||
</m:ResponseMessages>
|
||||
</m:CreateItemResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
var result = EwsResponseParser.Parse(xxe);
|
||||
|
||||
Assert.Equal(EwsResponseKind.Unparseable, result.Kind);
|
||||
Assert.Null(result.ResponseCode);
|
||||
Assert.Null(result.MessageText);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not xml")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("<foo/>")]
|
||||
public void Parse_GarbageOrUnrelatedPayload_ReturnsUnparseable(string body)
|
||||
{
|
||||
var result = EwsResponseParser.Parse(body);
|
||||
|
||||
Assert.Equal(EwsResponseKind.Unparseable, result.Kind);
|
||||
Assert.Null(result.ResponseCode);
|
||||
Assert.Null(result.MessageText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Xml.Linq;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the pure EWS <c>CreateItem</c> SOAP envelope builder: the fixed schema
|
||||
/// sequence Exchange validates against, BCC-only recipients, and XML escaping of
|
||||
/// operator-authored subject/body content.
|
||||
/// </summary>
|
||||
public class EwsSoapEnvelopeTests
|
||||
{
|
||||
private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/";
|
||||
private static readonly XNamespace Types = "http://schemas.microsoft.com/exchange/services/2006/types";
|
||||
private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages";
|
||||
|
||||
private static XDocument Build(
|
||||
string from = "scada@example.com",
|
||||
IReadOnlyList<string>? bcc = null,
|
||||
string subject = "Subject",
|
||||
string body = "Body")
|
||||
=> XDocument.Parse(EwsSoapEnvelope.BuildCreateItem(from, bcc ?? ["one@example.com"], subject, body));
|
||||
|
||||
[Fact]
|
||||
public void BuildCreateItem_SetsSendOnlyDispositionAndExchange2013RequestVersion()
|
||||
{
|
||||
var doc = Build();
|
||||
|
||||
var createItem = doc.Descendants(Messages + "CreateItem").Single();
|
||||
Assert.Equal("SendOnly", (string?)createItem.Attribute("MessageDisposition"));
|
||||
|
||||
var version = doc.Descendants(Types + "RequestServerVersion").Single();
|
||||
Assert.Equal("Exchange2013", (string?)version.Attribute("Version"));
|
||||
Assert.Single(doc.Descendants(Soap + "Header").Single().Elements());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCreateItem_EscapesMarkupInSubjectAndBody()
|
||||
{
|
||||
const string subject = "Alarm <HIGH> & \"critical\"";
|
||||
const string body = "value < 5 & tag=\"A\" > threshold";
|
||||
|
||||
var doc = Build(subject: subject, body: body);
|
||||
|
||||
// Round-tripping through the parser proves the content was escaped, not concatenated.
|
||||
Assert.Equal(subject, doc.Descendants(Types + "Subject").Single().Value);
|
||||
Assert.Equal(body, doc.Descendants(Types + "Body").Single().Value);
|
||||
Assert.Equal("Text", (string?)doc.Descendants(Types + "Body").Single().Attribute("BodyType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCreateItem_PutsEveryRecipientInBccAndNeverEmitsToRecipients()
|
||||
{
|
||||
string[] recipients = ["a@example.com", "b@example.com", "c@example.com"];
|
||||
|
||||
var doc = Build(bcc: recipients);
|
||||
|
||||
var bcc = doc.Descendants(Types + "BccRecipients").Single();
|
||||
var addresses = bcc.Elements(Types + "Mailbox")
|
||||
.Select(m => m.Element(Types + "EmailAddress")!.Value)
|
||||
.ToArray();
|
||||
Assert.Equal(recipients, addresses);
|
||||
|
||||
// Recipient privacy is the whole reason for BCC-only: no To/Cc may leak the list.
|
||||
Assert.DoesNotContain(doc.Descendants(), e => e.Name.LocalName is "ToRecipients" or "CcRecipients");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCreateItem_EmitsFromMailbox()
|
||||
{
|
||||
var doc = Build(from: "scada-alerts@example.com");
|
||||
|
||||
var from = doc.Descendants(Types + "From").Single();
|
||||
var mailbox = from.Elements(Types + "Mailbox").Single();
|
||||
Assert.Equal("scada-alerts@example.com", mailbox.Element(Types + "EmailAddress")!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCreateItem_OrdersMessageChildrenPerEwsSchemaSequence()
|
||||
{
|
||||
var doc = Build();
|
||||
|
||||
// MessageType is an xs:sequence — any other order is ErrorSchemaValidation at the server.
|
||||
var message = doc.Descendants(Types + "Message").Single();
|
||||
Assert.Equal(
|
||||
new[] { "Subject", "Body", "BccRecipients", "From" },
|
||||
message.Elements().Select(e => e.Name.LocalName).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCreateItem_StartsWithUtf8XmlDeclaration()
|
||||
{
|
||||
var xml = EwsSoapEnvelope.BuildCreateItem("a@example.com", ["b@example.com"], "s", "b");
|
||||
|
||||
Assert.StartsWith("<?xml", xml, StringComparison.Ordinal);
|
||||
var declaration = xml[..xml.IndexOf("?>", StringComparison.Ordinal)];
|
||||
Assert.Contains("utf-8", declaration, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the EWS SOAP mail sender: request shape (Basic header, content type, BCC-only
|
||||
/// envelope) and the transient-versus-permanent classification of every failure shape.
|
||||
/// <para>
|
||||
/// Every credential here is fake and every endpoint points at the reserved <c>.test</c> TLD —
|
||||
/// no test in this file touches a network.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EwsSoapMailSenderTests
|
||||
{
|
||||
private const string Username = @"dom\svc";
|
||||
private const string Password = "not-a-real-password-1234";
|
||||
private const string Recipient = "operator@example.test";
|
||||
private static readonly Uri Endpoint = new("https://ews.example.test/EWS/Exchange.asmx");
|
||||
|
||||
/// <summary>The Basic-auth value the sender is expected to produce for the fake credential.</summary>
|
||||
private static readonly string ExpectedBase64 =
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"));
|
||||
|
||||
private const string SuccessBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||
<m:ResponseMessages>
|
||||
<m:CreateItemResponseMessage ResponseClass="Success">
|
||||
<m:ResponseCode>NoError</m:ResponseCode>
|
||||
<m:Items><t:Message><t:ItemId Id="AAA=" ChangeKey="CQ=="/></t:Message></m:Items>
|
||||
</m:CreateItemResponseMessage>
|
||||
</m:ResponseMessages>
|
||||
</m:CreateItemResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
private const string ServerBusyBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
|
||||
<m:ResponseMessages>
|
||||
<m:CreateItemResponseMessage ResponseClass="Error">
|
||||
<m:MessageText>The server is busy.</m:MessageText>
|
||||
<m:ResponseCode>ErrorServerBusy</m:ResponseCode>
|
||||
</m:CreateItemResponseMessage>
|
||||
</m:ResponseMessages>
|
||||
</m:CreateItemResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
private const string SchemaValidationFaultBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<s:Fault>
|
||||
<faultcode xmlns:a="http://schemas.microsoft.com/exchange/services/2006/types">a:ErrorSchemaValidation</faultcode>
|
||||
<faultstring xml:lang="en-US">The request failed schema validation.</faultstring>
|
||||
<detail>
|
||||
<e:ResponseCode xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">ErrorSchemaValidation</e:ResponseCode>
|
||||
</detail>
|
||||
</s:Fault>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
|
||||
var factory = new FakeHttpClientFactory(handler);
|
||||
var sender = CreateSender(factory);
|
||||
|
||||
await sender.SendAsync(CreateRequest());
|
||||
|
||||
Assert.Equal(EwsSoapMailSender.HttpClientName, factory.RequestedClientName);
|
||||
Assert.Equal("Basic", handler.CapturedScheme);
|
||||
Assert.Equal(ExpectedBase64, handler.CapturedParameter);
|
||||
Assert.Equal("text/xml", handler.CapturedMediaType);
|
||||
Assert.Contains(Recipient, handler.CapturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_NonHttpsEndpoint_ThrowsPermanentWithoutSendingCredentials()
|
||||
{
|
||||
// A Basic header is a replayable cleartext credential: the guard must fire before the
|
||||
// request is ever built, so a misconfigured http:// URL cannot put it on the wire.
|
||||
var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
|
||||
var sender = CreateSender(handler);
|
||||
var request = CreateRequest() with { Endpoint = new Uri("http://ews.example.test/EWS/Exchange.asmx") };
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(request));
|
||||
|
||||
Assert.Equal(0, handler.InvocationCount);
|
||||
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.OK, ServerBusyBody);
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("ErrorServerBusy", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Unauthorized_ThrowsPermanent()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.Unauthorized, string.Empty);
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("401", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SchemaFaultOnHttp500_ThrowsPermanent()
|
||||
{
|
||||
// The parsed fault beats the HTTP status: a 500 would otherwise look transient, but
|
||||
// a schema-invalid request is never fixed by retrying it unchanged.
|
||||
var handler = StubHandler.Responding(HttpStatusCode.InternalServerError, SchemaValidationFaultBody);
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("ErrorSchemaValidation", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_BareServiceUnavailable_ThrowsTransient()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.ServiceUnavailable, "<html>proxy down</html>");
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("503", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_TransportFailure_ThrowsTransient()
|
||||
{
|
||||
var handler = StubHandler.Throwing(new HttpRequestException("No such host is known."));
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.IsType<HttpRequestException>(ex.InnerException);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_RequestExceedsTimeout_ThrowsTransientNotCancellation()
|
||||
{
|
||||
var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30));
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(
|
||||
() => sender.SendAsync(CreateRequest(timeoutSeconds: 1)));
|
||||
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_CallerCancelled_PropagatesCancellationUnwrapped()
|
||||
{
|
||||
var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30));
|
||||
var sender = CreateSender(handler);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => sender.SendAsync(CreateRequest(timeoutSeconds: 30), cts.Token));
|
||||
|
||||
Assert.IsNotType<EwsTransientException>(ex);
|
||||
Assert.IsNotType<EwsPermanentException>(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SuccessStatusWithUnparseableBody_ThrowsPermanent()
|
||||
{
|
||||
// A 200 whose body is not an EWS response is a protocol violation from something in
|
||||
// the path (captive portal / proxy error page); replaying it changes nothing.
|
||||
var handler = StubHandler.Responding(HttpStatusCode.OK, "not xml at all");
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Neither the password nor the base64 Basic-auth value may reach an exception — those land
|
||||
/// in the operational log and in the notification's stored error. The whole
|
||||
/// <see cref="Exception.ToString"/> is scanned, so an inner transport exception carrying the
|
||||
/// credential would fail this too.
|
||||
/// </summary>
|
||||
private static void AssertNoCredentialLeak(Exception ex)
|
||||
{
|
||||
var rendered = ex.ToString();
|
||||
Assert.DoesNotContain(Password, rendered, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(ExpectedBase64, rendered, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static EwsSendRequest CreateRequest(int timeoutSeconds = 0)
|
||||
=> new(
|
||||
Endpoint,
|
||||
Username,
|
||||
Password,
|
||||
"alerts@example.test",
|
||||
new[] { Recipient },
|
||||
"Alarm raised",
|
||||
"Tank 4 level high.",
|
||||
timeoutSeconds);
|
||||
|
||||
private static EwsSoapMailSender CreateSender(HttpMessageHandler handler)
|
||||
=> CreateSender(new FakeHttpClientFactory(handler));
|
||||
|
||||
private static EwsSoapMailSender CreateSender(FakeHttpClientFactory factory)
|
||||
=> new(factory, NullLogger<EwsSoapMailSender>.Instance);
|
||||
|
||||
/// <summary>Hands every named client the one stub handler under test.</summary>
|
||||
private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
||||
{
|
||||
/// <summary>The client name the sender asked for, so tests can pin the named registration.</summary>
|
||||
public string? RequestedClientName { get; private set; }
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
RequestedClientName = name;
|
||||
return new HttpClient(handler, disposeHandler: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the outgoing request (auth header, content type, serialized body) and answers
|
||||
/// with a canned response, a thrown exception, or a delay long enough to trip a timeout.
|
||||
/// </summary>
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<CancellationToken, Task<HttpResponseMessage>> _responder;
|
||||
|
||||
private StubHandler(Func<CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
=> _responder = responder;
|
||||
|
||||
public string? CapturedScheme { get; private set; }
|
||||
|
||||
public string? CapturedParameter { get; private set; }
|
||||
|
||||
public string? CapturedMediaType { get; private set; }
|
||||
|
||||
public string CapturedContent { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>How many times the handler was reached — 0 proves a guard fired first.</summary>
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public static StubHandler Responding(HttpStatusCode status, string body)
|
||||
=> new(_ => Task.FromResult(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "text/xml"),
|
||||
}));
|
||||
|
||||
public static StubHandler Throwing(Exception exception)
|
||||
=> new(_ => Task.FromException<HttpResponseMessage>(exception));
|
||||
|
||||
public static StubHandler Delaying(TimeSpan delay)
|
||||
=> new(async token =>
|
||||
{
|
||||
await Task.Delay(delay, token);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
CapturedScheme = request.Headers.Authorization?.Scheme;
|
||||
CapturedParameter = request.Headers.Authorization?.Parameter;
|
||||
CapturedMediaType = request.Content?.Headers.ContentType?.MediaType;
|
||||
CapturedContent = request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
return await _responder(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,6 +633,12 @@ public sealed class RoundTripEquivalenceTests : IDisposable
|
||||
MaxConcurrentConnections = 4,
|
||||
MaxRetries = 9,
|
||||
RetryDelay = TimeSpan.FromSeconds(33),
|
||||
// Non-sensitive delivery metadata. Left null, these three were
|
||||
// silently dropped by the bundle without the guard noticing
|
||||
// (null == null), so they are seeded explicitly here.
|
||||
Transport = "Ews",
|
||||
OAuth2Authority = "https://login.example.test/token",
|
||||
OAuth2Scope = "https://mail.example.test/.default",
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
@@ -938,4 +938,118 @@ public sealed class EntitySerializerTests
|
||||
var rt = new EntitySerializer().FromBundleContent(dto);
|
||||
Assert.Null(Assert.Single(rt.SmsConfigurations).AuthToken);
|
||||
}
|
||||
|
||||
// --- EWS: Transport + the pre-existing OAuth2 authority/scope drop ---------
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_smtp_config_preserves_transport_and_oauth2_fields()
|
||||
{
|
||||
// Transport is the EWS selector; OAuth2Authority/OAuth2Scope predate it and
|
||||
// were silently dropped by the bundle (they were never on the DTO). All
|
||||
// three are non-sensitive endpoint metadata, so they ride as plain DTO
|
||||
// fields — only Credentials belongs in the SecretsBlock.
|
||||
var smtp = new SmtpConfiguration("https://mail.example.test/EWS/Exchange.asmx", "Basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 587,
|
||||
Credentials = "user:p@ssw0rd",
|
||||
TlsMode = "StartTLS",
|
||||
ConnectionTimeoutSeconds = 25,
|
||||
MaxConcurrentConnections = 3,
|
||||
MaxRetries = 4,
|
||||
RetryDelay = TimeSpan.FromSeconds(90),
|
||||
Transport = "Ews",
|
||||
OAuth2Authority = "https://login.example.test/token",
|
||||
OAuth2Scope = "https://mail.example.test/.default",
|
||||
};
|
||||
var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };
|
||||
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
var dtoSmtp = Assert.Single(dto.SmtpConfigs);
|
||||
Assert.Equal("Ews", dtoSmtp.Transport);
|
||||
Assert.Equal("https://login.example.test/token", dtoSmtp.OAuth2Authority);
|
||||
Assert.Equal("https://mail.example.test/.default", dtoSmtp.OAuth2Scope);
|
||||
|
||||
var rt = sut.FromBundleContent(dto);
|
||||
|
||||
var rtSmtp = Assert.Single(rt.SmtpConfigurations);
|
||||
Assert.Equal("Ews", rtSmtp.Transport);
|
||||
Assert.Equal("https://login.example.test/token", rtSmtp.OAuth2Authority);
|
||||
Assert.Equal("https://mail.example.test/.default", rtSmtp.OAuth2Scope);
|
||||
// The pre-existing fields still survive alongside the new ones.
|
||||
Assert.Equal("https://mail.example.test/EWS/Exchange.asmx", rtSmtp.Host);
|
||||
Assert.Equal(587, rtSmtp.Port);
|
||||
Assert.Equal("StartTLS", rtSmtp.TlsMode);
|
||||
Assert.Equal("user:p@ssw0rd", rtSmtp.Credentials);
|
||||
Assert.Equal(25, rtSmtp.ConnectionTimeoutSeconds);
|
||||
Assert.Equal(3, rtSmtp.MaxConcurrentConnections);
|
||||
Assert.Equal(4, rtSmtp.MaxRetries);
|
||||
Assert.Equal(TimeSpan.FromSeconds(90), rtSmtp.RetryDelay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_smtp_config_without_transport_or_oauth2_fields_yields_nulls()
|
||||
{
|
||||
// Backward-compat at the DTO level (mirrors the PhoneNumber precedent): an
|
||||
// SMTP DTO built without the three trailing params — as an old bundle's JSON
|
||||
// deserializes, since those properties are simply absent — imports with all
|
||||
// three null, i.e. legacy SMTP delivery and provider-default OAuth2 endpoints.
|
||||
var dto = new BundleContentDto(
|
||||
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||
Templates: Array.Empty<TemplateDto>(),
|
||||
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
||||
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||
SmtpConfigs: new[]
|
||||
{
|
||||
new SmtpConfigDto(
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
AuthType: "Basic",
|
||||
FromAddress: "noreply@example.com",
|
||||
TlsMode: "StartTLS",
|
||||
ConnectionTimeoutSeconds: 30,
|
||||
MaxConcurrentConnections: 2,
|
||||
MaxRetries: 3,
|
||||
RetryDelay: TimeSpan.FromSeconds(60),
|
||||
Secrets: null),
|
||||
},
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var smtp = Assert.Single(aggregate.SmtpConfigurations);
|
||||
Assert.Null(smtp.Transport);
|
||||
Assert.Null(smtp.OAuth2Authority);
|
||||
Assert.Null(smtp.OAuth2Scope);
|
||||
Assert.Equal("smtp.example.com", smtp.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmtpConfigDto_omits_null_transport_and_oauth2_fields_from_json()
|
||||
{
|
||||
// WhenWritingNull keeps the three new properties out of a plain-SMTP
|
||||
// bundle's JSON entirely, so the wire shape is unchanged for existing
|
||||
// exports (additive evolution — no bundleFormatVersion bump).
|
||||
var dto = new SmtpConfigDto(
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
AuthType: "Basic",
|
||||
FromAddress: "noreply@example.com",
|
||||
TlsMode: "StartTLS",
|
||||
ConnectionTimeoutSeconds: 30,
|
||||
MaxConcurrentConnections: 2,
|
||||
MaxRetries: 3,
|
||||
RetryDelay: TimeSpan.FromSeconds(60),
|
||||
Secrets: null);
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, BundleJsonOptions.Default);
|
||||
|
||||
Assert.DoesNotContain("Transport", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("OAuth2Authority", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("OAuth2Scope", json, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user