439 lines
27 KiB
Markdown
439 lines
27 KiB
Markdown
# EWS Email Transport Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development in-session) to implement this plan task-by-task.
|
||
|
||
**Goal:** Add on-prem Exchange EWS (SOAP over HTTPS, Basic auth) as a second selectable email transport beside SMTP in the central Notification Outbox, per `docs/plans/2026-08-10-ews-email-transport-design.md`.
|
||
|
||
**Architecture:** One additive `Transport` discriminator (`Smtp` default / `Ews`) on the existing `SmtpConfiguration` row; `EmailNotificationDeliveryAdapter` branches after config load to a new no-SDK `EwsSoapMailSender` (one `CreateItem` SOAP POST, BCC-only, `SendOnly`, explicit Basic header). Everything else — outbox lifecycle, retry/park, recipient resolution, KPIs, UI/CLI surface, bundle SecretsBlock — is reused.
|
||
|
||
**Tech Stack:** .NET 10, xUnit, EF Core (MS SQL, one additive migration), `IHttpClientFactory`, hand-rolled SOAP (no EWS SDK). Solution: `ZB.MOM.WW.ScadaBridge.slnx`.
|
||
|
||
**Execution environment:** Work in a dedicated worktree (the `EnterWorktree` tool is broken here — create manually):
|
||
```bash
|
||
git -C /Users/dohertj2/Desktop/ScadaBridge worktree add /Users/dohertj2/Desktop/ScadaBridge-wt-ews -b feature/ews-email-transport main
|
||
cd /Users/dohertj2/Desktop/ScadaBridge-wt-ews # ALWAYS use absolute paths; Bash cwd can drift
|
||
```
|
||
All file paths below are worktree-relative. Run tests with explicit `--filter` (targeted-tests rule); full `dotnet build ZB.MOM.WW.ScadaBridge.slnx` at the end, not per task.
|
||
|
||
**Never** put the contents of `email_details.txt` (untracked, git-ignored) in code, tests, or docs. Test fixtures use fake hosts/credentials only.
|
||
|
||
---
|
||
|
||
### Task 1: EmailTransport enum + parser (NotificationService)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 2, Task 3
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/EmailTransport.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/EmailTransportParserTests.cs`
|
||
|
||
Mirror the existing `SmtpTlsMode`/`SmtpTlsModeParser` pattern (`src/ZB.MOM.WW.ScadaBridge.NotificationService/SmtpTlsMode.cs`) exactly — read it first.
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||
|
||
public class EmailTransportParserTests
|
||
{
|
||
[Theory]
|
||
[InlineData(null, EmailTransport.Smtp)] // null/empty = legacy rows = SMTP
|
||
[InlineData("", EmailTransport.Smtp)]
|
||
[InlineData("Smtp", EmailTransport.Smtp)]
|
||
[InlineData("smtp", EmailTransport.Smtp)]
|
||
[InlineData("SMTP", EmailTransport.Smtp)]
|
||
[InlineData("Ews", EmailTransport.Ews)]
|
||
[InlineData("ews", EmailTransport.Ews)]
|
||
[InlineData("EWS", EmailTransport.Ews)]
|
||
public void Parse_recognizes_transports_case_insensitively(string? input, EmailTransport expected)
|
||
=> Assert.Equal(expected, EmailTransportParser.Parse(input));
|
||
|
||
[Theory]
|
||
[InlineData("Graph")]
|
||
[InlineData("smtps")]
|
||
public void Parse_rejects_unknown_transport(string input)
|
||
{
|
||
var ex = Assert.Throws<ArgumentException>(() => EmailTransportParser.Parse(input));
|
||
Assert.Contains(input, ex.Message);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run to verify failure**
|
||
`dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests --filter EmailTransportParserTests -v minimal` → compile error (type missing).
|
||
|
||
**Step 3: Implement**
|
||
|
||
```csharp
|
||
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
|
||
|
||
/// <summary>Email delivery transport selected by <c>SmtpConfiguration.Transport</c>.</summary>
|
||
public enum EmailTransport
|
||
{
|
||
/// <summary>Direct SMTP submission via MailKit (the original path).</summary>
|
||
Smtp,
|
||
/// <summary>On-prem Exchange Web Services — one CreateItem SOAP call over HTTPS.</summary>
|
||
Ews,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parses the stored transport string. Null/empty maps to <see cref="EmailTransport.Smtp"/>
|
||
/// (every pre-EWS configuration row is an SMTP row); an unknown value throws
|
||
/// <see cref="ArgumentException"/> — a configuration error retrying cannot fix, which the
|
||
/// delivery adapter surfaces as a permanent failure (mirrors <c>SmtpTlsModeParser</c>).
|
||
/// </summary>
|
||
public static class EmailTransportParser
|
||
{
|
||
public static EmailTransport Parse(string? value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value)) return EmailTransport.Smtp;
|
||
return value.Trim().ToLowerInvariant() switch
|
||
{
|
||
"smtp" => EmailTransport.Smtp,
|
||
"ews" => EmailTransport.Ews,
|
||
_ => throw new ArgumentException(
|
||
$"Unknown email transport '{value}'. Expected one of: Smtp, Ews."),
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4: Run tests** → PASS.
|
||
|
||
**Step 5: Commit** — `git add` the two files; `git commit -m "feat(notifications): EmailTransport enum + parser for the EWS transport discriminator"`.
|
||
|
||
---
|
||
|
||
### Task 2: SmtpConfiguration.Transport entity field + EF mapping + migration
|
||
|
||
**Classification:** high-risk (EF migration)
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 1, Task 3
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/SmtpConfiguration.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationConfiguration.cs` (class `SmtpConfigurationConfiguration`, ~line 103)
|
||
- Create: migration via CLI (see Step 3 — **build first**, never `--no-build`)
|
||
|
||
**Step 1: Add the property** (after `TlsMode`, before `FromAddress`):
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Gets or sets the email delivery transport ("Smtp" or "Ews"). Null/empty means Smtp
|
||
/// (legacy rows). Under Ews, <see cref="Host"/> holds the full EWS endpoint URL,
|
||
/// <see cref="AuthType"/> must be Basic, and Port/TlsMode/OAuth2* are unused.
|
||
/// </summary>
|
||
public string? Transport { get; set; }
|
||
```
|
||
|
||
**Step 2: EF mapping** — in `SmtpConfigurationConfiguration.Configure`, after the `TlsMode` property block:
|
||
|
||
```csharp
|
||
// Email transport discriminator ("Smtp"/"Ews"); null = Smtp for pre-EWS rows.
|
||
builder.Property(s => s.Transport)
|
||
.HasMaxLength(50);
|
||
```
|
||
|
||
**Step 3: Migration** — from the worktree root:
|
||
```bash
|
||
dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase # NEVER scaffold off a stale DLL
|
||
dotnet ef migrations add AddSmtpConfigurationTransport \
|
||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||
```
|
||
Verify the generated migration adds exactly one nullable `nvarchar(50)` column `Transport` to `SmtpConfigurations` — if it is empty, the build was stale: delete the files and redo (see `ef-migrations-add-no-build-stale` gotcha).
|
||
|
||
**Step 4: Build + existing ConfigurationDatabase tests**
|
||
`dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests -v minimal` → PASS.
|
||
|
||
**Step 5: Commit** — `feat(notifications): additive Transport column on SmtpConfigurations`.
|
||
|
||
---
|
||
|
||
### Task 3: EWS SOAP envelope builder + response parser (pure)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 1, Task 2
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapEnvelope.cs`
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsResponseParser.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs`
|
||
|
||
**Step 1: Failing tests.** Envelope: builds `CreateItem` with `MessageDisposition="SendOnly"`, `RequestServerVersion Version="Exchange2013"`, subject/body XML-escaped (`<`, `&`, `"` cases), plain-text `BodyType="Text"`, all recipients under `BccRecipients` (none in `ToRecipients` — assert the element is absent), `From` mailbox present, element order Subject → Body → BccRecipients → From (EWS `MessageType` is a schema `sequence`; wrong order = `ErrorSchemaValidation`). Parser: `(Success, "NoError")` for a success body; `(Error, "ErrorServerBusy")` from a `ResponseClass="Error"` message; SOAP 1.1 `<soap:Fault>` body → fault detail `ResponseCode` extracted (`ErrorSchemaValidation`); garbage input → `EwsParseResult.Unparseable`.
|
||
|
||
Use small inline XML literals for canned responses; namespaces: `soap=http://schemas.xmlsoap.org/soap/envelope/`, `m=…/2006/messages`, `t=…/2006/types`.
|
||
|
||
**Step 2: Run to verify failure.**
|
||
|
||
**Step 3: Implement.** `EwsSoapEnvelope.BuildCreateItem(string from, IReadOnlyList<string> bcc, string subject, string body)` returning `string` — build with `XDocument`/`XElement` (escaping for free), structure:
|
||
|
||
```xml
|
||
<soap:Envelope xmlns:soap="…" xmlns:t="…/types" xmlns:m="…/messages">
|
||
<soap:Header><t:RequestServerVersion Version="Exchange2013"/></soap:Header>
|
||
<soap:Body>
|
||
<m:CreateItem MessageDisposition="SendOnly">
|
||
<m:Items><t:Message>
|
||
<t:Subject>…</t:Subject>
|
||
<t:Body BodyType="Text">…</t:Body>
|
||
<t:BccRecipients><t:Mailbox><t:EmailAddress>…</t:EmailAddress></t:Mailbox>…</t:BccRecipients>
|
||
<t:From><t:Mailbox><t:EmailAddress>…</t:EmailAddress></t:Mailbox></t:From>
|
||
</t:Message></m:Items>
|
||
</m:CreateItem>
|
||
</soap:Body>
|
||
</soap:Envelope>
|
||
```
|
||
|
||
`EwsResponseParser.Parse(string responseBody)` returning `EwsParseResult` record: `Kind` (`Success` | `Error` | `Fault` | `Unparseable`) + `ResponseCode` (string?) + `MessageText` (string?). Wrap `XDocument.Parse` in try/catch → `Unparseable`. Look for `CreateItemResponseMessage` `ResponseClass` attribute + `m:ResponseCode`; else `soap:Fault` → `detail//t:ResponseCode` (fall back to `faultstring`).
|
||
|
||
**Step 4: Run tests** → PASS. **Step 5: Commit** — `feat(notifications): EWS CreateItem envelope builder + response parser`.
|
||
|
||
---
|
||
|
||
### Task 4: EwsSoapMailSender + DI registration
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (needs Task 3)
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/IEwsMailSender.cs` (+ `EwsSendRequest` record, `EwsTransientException`, `EwsPermanentException`)
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapMailSender.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs`
|
||
|
||
**Contract:**
|
||
|
||
```csharp
|
||
/// <summary>One-shot EWS mail submission (CreateItem, SendOnly, BCC-only).</summary>
|
||
public interface IEwsMailSender
|
||
{
|
||
/// <summary>
|
||
/// Sends via EWS. Throws <see cref="EwsPermanentException"/> for failures retrying cannot
|
||
/// fix (auth 401/403, bad URL 404, schema faults, recipient rejections) and
|
||
/// <see cref="EwsTransientException"/> for availability-shaped failures (network/DNS/timeout,
|
||
/// HTTP 5xx/408/429, ErrorServerBusy-class response codes).
|
||
/// </summary>
|
||
Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default);
|
||
}
|
||
|
||
public sealed record EwsSendRequest(
|
||
Uri Endpoint, // absolute https EWS URL (from SmtpConfiguration.Host)
|
||
string Username, // may be "domain\\user"
|
||
string Password,
|
||
string FromAddress,
|
||
IReadOnlyList<string> BccRecipients,
|
||
string Subject,
|
||
string Body,
|
||
int TimeoutSeconds); // non-positive = HttpClient default
|
||
```
|
||
|
||
**Implementation notes (Step 3):**
|
||
- `public const string HttpClientName = "EwsMail";` on the sender; ctor takes `IHttpClientFactory` + `ILogger<EwsSoapMailSender>`.
|
||
- **Explicit Basic header** (deterministic — the live probe proved Basic enabled): `new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}")))` per request message — never on the shared client (pooled handler).
|
||
- POST `StringContent(envelope, Encoding.UTF8, "text/xml")` (SOAP 1.1). Timeout via linked CTS (mirror `SmsNotificationDeliveryAdapter`, which leaves `HttpClient.Timeout` at default).
|
||
- Classification (transient set chosen in design §4.3):
|
||
- `HttpRequestException`, `TaskCanceledException` when NOT the caller's token → wrap in `EwsTransientException`. Caller-cancelled `OperationCanceledException` propagates unchanged.
|
||
- HTTP 401/403/404/405/410 → `EwsPermanentException`; 408/429/5xx with **unparseable** body → `EwsTransientException`; other unparseable non-success → permanent.
|
||
- Parsed body (any HTTP status): `ResponseCode` in `{ErrorServerBusy, ErrorInternalServerTransientError, ErrorTimeoutExpired, ErrorMailboxStoreUnavailable, ErrorInsufficientResources}` → transient; `Success`/`NoError` → return; any other `Error`/`Fault` code → permanent (message includes the code).
|
||
- **Never log or embed the password**: exception messages carry status/response code/`MessageText` only; the Authorization header value must not appear in any message (add a test asserting the thrown message does not contain the base64 credential).
|
||
|
||
**Tests (Step 1, failing first):** stub `HttpMessageHandler` (capture request; canned responses). Assert: Basic header matches expected base64 of `user:pass`; content type `text/xml`; envelope contains the BCC recipient; success body → no throw; `ErrorServerBusy` body → `EwsTransientException`; 401 → `EwsPermanentException`; `ErrorSchemaValidation` fault (HTTP 500) → **permanent** (the critical case distinguishing parsed-fault-500 from bare-500); bare 503 → transient; socket failure (handler throws `HttpRequestException`) → transient; credential never in exception message.
|
||
|
||
**DI (in `AddNotificationService`):** `services.AddHttpClient(EwsSoapMailSender.HttpClientName);` + `services.TryAddSingleton<IEwsMailSender, EwsSoapMailSender>();` (stateless; factory-per-request handler).
|
||
|
||
**Steps 4/5:** filtered test run PASS → commit `feat(notifications): no-SDK EWS SOAP mail sender with typed transient/permanent classification`.
|
||
|
||
---
|
||
|
||
### Task 5: EmailNotificationDeliveryAdapter transport branch
|
||
|
||
**Classification:** high-risk (delivery path + outcome contract)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (needs Tasks 1, 2, 4)
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs` (doc-comment only: note the EWS seam also comes from `AddNotificationService`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs` (new file; leave the existing SMTP adapter tests untouched — they must stay green unmodified, proving the SMTP path is untouched)
|
||
|
||
**Step 1 (failing tests):** with a config row `Transport="Ews"`, `Host="https://ews.example.test/ews/exchange.asmx"`, `AuthType="basic"`, `Credentials=@"dom\svc:pw"`:
|
||
- happy path → `IEwsMailSender` (mock) receives endpoint/user `dom\svc`/pw/from/bcc/subject/body; outcome `Success`.
|
||
- sender throws `EwsTransientException` → `Transient`; `EwsPermanentException` → `Permanent`.
|
||
- config errors → `Permanent` WITHOUT calling the sender: non-absolute/non-https `Host`; `AuthType="oauth2"`; missing/`:`-less `Credentials`; unknown `Transport="Graph"`.
|
||
- `Transport=null` and `Transport="Smtp"` still drive the SMTP path (existing `_smtpClientFactory` invoked, sender mock untouched).
|
||
|
||
**Step 2:** run new test file → fails.
|
||
|
||
**Step 3:** Adapter changes — ctor gains `IEwsMailSender? ewsMailSender = null` (optional, matching the `OAuth2TokenService?` pattern so existing test ctor calls compile — but UPDATE the DI doc comment). In `DeliverAsync`, after the SMTP-config load + multiple-row warning and BEFORE the TLS-mode parse, branch:
|
||
|
||
```csharp
|
||
EmailTransport transport;
|
||
try { transport = EmailTransportParser.Parse(smtpConfig.Transport); }
|
||
catch (ArgumentException ex) { return DeliveryOutcome.Permanent(ex.Message); }
|
||
|
||
if (transport == EmailTransport.Ews)
|
||
{
|
||
return await DeliverViaEwsAsync(smtpConfig, recipients, notification, cancellationToken);
|
||
}
|
||
// existing SMTP path continues unchanged below
|
||
```
|
||
|
||
`DeliverViaEwsAsync` (private): validate addresses via the existing `EmailAddressValidator.ValidateAddresses`; validate endpoint (`Uri.TryCreate` absolute + `https`), auth type `basic`, credentials split on FIRST `':'` (password may contain `:`? split-limit 2 like the SMTP path); null `_ewsMailSender` → `Permanent("EWS transport configured but no EWS sender is registered")`; call `SendAsync`; catch `EwsPermanentException` → `Permanent`, `EwsTransientException` → `Transient`, caller-cancelled OCE rethrow, anything else → `Permanent` (default-permanent stance) — every message through `CredentialRedactor.Scrub(msg, smtpConfig.Credentials)`.
|
||
|
||
**Step 4:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests --filter EmailNotificationDeliveryAdapter -v minimal` → ALL pass (new + pre-existing SMTP tests).
|
||
|
||
**Step 5:** Commit — `feat(notifications): EWS branch in the email delivery adapter`.
|
||
|
||
---
|
||
|
||
### Task 6: Management command + validation + public shape
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 9
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs:11`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`SmtpConfigPublicShape` ~2114, `HandleUpdateSmtpConfig` ~2142)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs` (extend existing UpdateSmtpConfig coverage)
|
||
|
||
**Changes:**
|
||
- `UpdateSmtpConfigCommand` gains a TRAILING optional param `string? Transport = null` (additive-only message evolution; Newtonsoft deserializes missing → null → preserve-if-null. Do NOT reorder existing params).
|
||
- `HandleUpdateSmtpConfig`: `if (cmd.Transport is not null) config.Transport = cmd.Transport;` plus validation BEFORE save: parse via `EmailTransportParser.Parse(cmd.Transport ?? config.Transport)` → `ManagementCommandException` on `ArgumentException`; when the effective transport is Ews, require `config.Host` (post-assignment) to be an absolute `https` URI and effective `AuthType` to be `basic` — else `ManagementCommandException` with a message naming the offending field. (NotificationService is already referenced? **Check**: if `ManagementService.csproj` does not reference `ZB.MOM.WW.ScadaBridge.NotificationService`, inline the parse — a local `switch` on the lowercased string — instead of adding a project reference; keeping the dependency graph flat wins over DRY here.)
|
||
- `SmtpConfigPublicShape` adds `c.Transport`.
|
||
- Tests (failing first): update-with-`Transport="Ews"`+https host+basic → persisted, response shape carries Transport; `Transport="Graph"` → `ManagementCommandException`; Ews with `http://` host → rejected; omitted Transport preserves stored value. RequiredRoleMatrix is unchanged (no new command — do not touch the frozen matrix).
|
||
|
||
**Run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter UpdateSmtpConfig -v minimal` → PASS. Commit — `feat(management): Transport on UpdateSmtpConfigCommand with EWS shape validation`.
|
||
|
||
---
|
||
|
||
### Task 7: CLI --transport flag
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 8 (after Task 6)
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs` (BuildSmtp ~127, options block ~160, `BuildUpdateSmtpConfigCommand` ~207)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SmtpUpdateCommandTests.cs`
|
||
|
||
Add static `SmtpTransportOption`:
|
||
|
||
```csharp
|
||
private static readonly Option<string?> SmtpTransportOption = CreateTransportOption();
|
||
|
||
private static Option<string?> CreateTransportOption()
|
||
{
|
||
var option = new Option<string?>("--transport")
|
||
{
|
||
Description = "Email transport: Smtp or Ews (optional; preserves existing if omitted). " +
|
||
"Under Ews, --server is the full EWS URL (https://…/ews/exchange.asmx), " +
|
||
"--auth-mode must be basic, and --port/--tls-mode are unused.",
|
||
};
|
||
option.AcceptOnlyFromAmong("Smtp", "Ews");
|
||
return option;
|
||
}
|
||
```
|
||
|
||
Wire into `updateCmd.Add(...)` and `BuildUpdateSmtpConfigCommand` (pass as the new trailing arg). Tests failing-first in `SmtpUpdateCommandTests` (mirror existing option tests): `--transport Ews` maps through; omitted → null; `--transport Graph` → parse error.
|
||
|
||
**Run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter SmtpUpdate -v minimal` → PASS. Commit — `feat(cli): notification smtp update --transport smtp|ews`.
|
||
|
||
---
|
||
|
||
### Task 8: Central UI transport selector
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 7 (after Task 6)
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor`
|
||
|
||
Read the whole page first (272 lines). Changes, following its existing patterns exactly (Bootstrap, `@bind`, no component libs):
|
||
- Read view: a "Transport" row (badge, `@(smtp.Transport ?? "Smtp")`) above Host; when transport is Ews, relabel the Host row "EWS URL" and hide the Port/TLS rows; OAuth2 rows stay gated on AuthType as today. (The management list response now carries `Transport` — the page's row model needs the property added where `OAuth2Authority` etc. are declared.)
|
||
- Edit form: `<select @bind="_transport">` with `Smtp`/`Ews` options, default from the loaded row (`?? "Smtp"`); when `_transport == "Ews"` hide Port + TLS + auth-type select (force-display `basic`, set `_authType = "basic"` on switch to Ews), show Host input labelled "EWS URL" with placeholder `https://mail.example.com/ews/exchange.asmx`; credentials placeholder `domain\username:password`. Pass `_transport` as the new `UpdateSmtpConfigCommand` trailing arg where the page builds the command (~line 237 area).
|
||
- Playwright: `SmtpConfigTests` exist but are env-gated (live cluster) — do NOT add new Playwright coverage in this task; server-side validation is covered by Task 6.
|
||
|
||
**Verify:** `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI` → 0 errors. Commit — `feat(ui): EWS transport selector on /notifications/smtp`.
|
||
|
||
---
|
||
|
||
### Task 9: Transport bundle carriage (Transport + the pre-existing OAuth2 drop)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 6
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs` (`SmtpConfigDto` ~288)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs` (serialize ~168–189, deserialize ~478–490)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs`
|
||
|
||
**Discovered defect this task also fixes:** `OAuth2Authority`/`OAuth2Scope` are NOT carried by `SmtpConfigDto` today — an exported bundle silently drops them on import (exactly the T8 silent-data-loss class). Fix alongside `Transport`.
|
||
|
||
- `SmtpConfigDto`: add THREE trailing nullable params (additive — old bundles deserialize them as null; serializer's WhenWritingNull keeps them out of old-shaped JSON): `string? OAuth2Authority = null, string? OAuth2Scope = null, string? Transport = null`.
|
||
- Serializer: map all three from the entity. Deserializer: restore all three in the object initializer.
|
||
- Tests failing-first (extend the existing SMTP round-trip test at ~line 53): a config with `Transport="Ews"`, `OAuth2Authority`/`OAuth2Scope` set survives export→import with all three intact; and a JSON payload WITHOUT the new properties (old bundle) deserializes with all three null.
|
||
- `schemaVersion` stays (additive rule); do not bump `bundleFormatVersion`.
|
||
|
||
**Run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter EntitySerializer -v minimal` → PASS. Commit — `fix(transport): carry Transport + OAuth2 authority/scope on SmtpConfigDto (OAuth2 fields were silently dropped)`.
|
||
|
||
---
|
||
|
||
### Task 10: Docs sweep
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (last code-adjacent task; needs all prior merged)
|
||
|
||
**Files:**
|
||
- Modify: `docs/requirements/Component-NotificationService.md` — EWS transport section under the SMTP config section (~line 44): config model (field reuse table), Basic-over-HTTPS posture, BCC-only + SendOnly semantics, transient/permanent classification, the no-NTLM decision + its revisit trigger (Basic disabled on the EWS vdir).
|
||
- Modify: `docs/plans/questions.md:13` + `docs/plans/phase-7-integrations.md:466` — Q12 status → "Closed 2026-08-10 as superseded — production mail is on-prem Exchange EWS; see docs/plans/2026-08-10-ews-email-transport-design.md".
|
||
- Modify: `docs/deployment/production-checklist.md:46` — add the EWS variant lines (outbound HTTPS 443 to the Exchange CAS; service-account password rotation; Basic requires TLS) alongside the SMTP items.
|
||
- Modify: `docs/test_infra/test_infra_smtp.md:125` — note the OAuth2 gap is superseded by EWS for on-prem deployments (pointer to the design doc).
|
||
- Modify: `docs/plans/2026-08-10-ews-email-transport-design.md` §4.1 — correct "same storage treatment" note: Credentials ARE encrypted at rest via `EncryptedStringConverter` (ConfigurationDatabase), so no asymmetry with SmsConfiguration exists; and note the Task 9 OAuth2-drop fix.
|
||
- Modify: `CLAUDE.md` — External Integrations bullet: one line stating email delivery supports SMTP or on-prem Exchange EWS via `SmtpConfiguration.Transport`, EWS = no-SDK CreateItem/Basic/BCC/SendOnly.
|
||
- Modify: `README.md` component table row for Notification Service ONLY IF it names SMTP specifically (check first).
|
||
|
||
Commit — `docs(notifications): EWS transport docs; close Q12 as superseded`.
|
||
|
||
---
|
||
|
||
### Task 11: Integration verify + merge back
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min (plus build/test wall time)
|
||
**Parallelizable with:** none
|
||
|
||
From the worktree root:
|
||
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 errors / 0 warnings.
|
||
2. Targeted suites (full-suite is a milestone-end exception — this is the milestone end for the feature, but per the targeted-tests rule run the impacted + consumer set, not the world):
|
||
`dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests tests/ZB.MOM.WW.ScadaBridge.CLI.Tests tests/ZB.MOM.WW.ScadaBridge.Transport.Tests tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests -v minimal` → green (known env-only Playwright exclusions don't run here).
|
||
3. Verify every task's commit is on the branch (`git log --oneline main..HEAD` — expect ~10 commits).
|
||
4. **Do not merge to main or push** — report done; merging is the finishing-a-development-branch flow with the user.
|
||
|
||
**Known live-gate residue (manual, user-run, after merge):** configure `Transport=Ews` against `webmail.zimmer.com` with the dev/test credentials from the untracked `email_details.txt`, send to a single-recipient test list, verify Delivered + received mail. Not automatable from CI (real server, real creds).
|
||
|
||
---
|
||
|
||
## Dependency graph
|
||
|
||
```
|
||
T1 ─┬────────────► T5 ─► T11
|
||
T2 ─┤ ▲
|
||
T3 ─► T4 ────────┘
|
||
T2 ─► T6 ─► T7 ─► T11
|
||
└► T8 ─► T11
|
||
T2 ─► T9 ─► T11
|
||
T10 after T1–T9
|
||
```
|
||
Wave 1: T1, T2, T3 (parallel — file-disjoint). Wave 2: T4, T6, T9. Wave 3: T5, T7, T8. Wave 4: T10, then T11.
|