docs(notifications): EWS email transport implementation plan (11 tasks)

This commit is contained in:
Joseph Doherty
2026-08-10 06:05:04 -04:00
parent b2ea9c6c74
commit 9f4d7d4bcb
2 changed files with 455 additions and 0 deletions
@@ -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 ~168189, deserialize ~478490)
- 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 T1T9
```
Wave 1: T1, T2, T3 (parallel — file-disjoint). Wave 2: T4, T6, T9. Wave 3: T5, T7, T8. Wave 4: T10, then T11.