From 9f4d7d4bcbfb18b9cf142258ba42514d3807db18 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 06:05:04 -0400 Subject: [PATCH] docs(notifications): EWS email transport implementation plan (11 tasks) --- docs/plans/2026-08-10-ews-email-transport.md | 438 ++++++++++++++++++ ...26-08-10-ews-email-transport.md.tasks.json | 17 + 2 files changed, 455 insertions(+) create mode 100644 docs/plans/2026-08-10-ews-email-transport.md create mode 100644 docs/plans/2026-08-10-ews-email-transport.md.tasks.json diff --git a/docs/plans/2026-08-10-ews-email-transport.md b/docs/plans/2026-08-10-ews-email-transport.md new file mode 100644 index 00000000..de3b4e42 --- /dev/null +++ b/docs/plans/2026-08-10-ews-email-transport.md @@ -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(() => 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; + +/// Email delivery transport selected by SmtpConfiguration.Transport. +public enum EmailTransport +{ + /// Direct SMTP submission via MailKit (the original path). + Smtp, + /// On-prem Exchange Web Services — one CreateItem SOAP call over HTTPS. + Ews, +} + +/// +/// Parses the stored transport string. Null/empty maps to +/// (every pre-EWS configuration row is an SMTP row); an unknown value throws +/// — a configuration error retrying cannot fix, which the +/// delivery adapter surfaces as a permanent failure (mirrors SmtpTlsModeParser). +/// +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 + /// + /// Gets or sets the email delivery transport ("Smtp" or "Ews"). Null/empty means Smtp + /// (legacy rows). Under Ews, holds the full EWS endpoint URL, + /// must be Basic, and Port/TlsMode/OAuth2* are unused. + /// + 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 `` 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 bcc, string subject, string body)` returning `string` — build with `XDocument`/`XElement` (escaping for free), structure: + +```xml + + + + + + + + + + + + + +``` + +`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 +/// One-shot EWS mail submission (CreateItem, SendOnly, BCC-only). +public interface IEwsMailSender +{ + /// + /// Sends via EWS. Throws for failures retrying cannot + /// fix (auth 401/403, bad URL 404, schema faults, recipient rejections) and + /// for availability-shaped failures (network/DNS/timeout, + /// HTTP 5xx/408/429, ErrorServerBusy-class response codes). + /// + 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 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`. +- **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();` (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 SmtpTransportOption = CreateTransportOption(); + + private static Option CreateTransportOption() + { + var option = new Option("--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: `