Files
ScadaBridge/docs/plans/2026-08-10-ews-email-transport.md
T

439 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.