# 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: `