27 KiB
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):
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
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
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(classSmtpConfigurationConfiguration, ~line 103) - Create: migration via CLI (see Step 3 — build first, never
--no-build)
Step 1: Add the property (after TlsMode, before FromAddress):
/// <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:
// 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:
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:
<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(+EwsSendRequestrecord,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:
/// <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 takesIHttpClientFactory+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 (mirrorSmsNotificationDeliveryAdapter, which leavesHttpClient.Timeoutat default). - Classification (transient set chosen in design §4.3):
HttpRequestException,TaskCanceledExceptionwhen NOT the caller's token → wrap inEwsTransientException. Caller-cancelledOperationCanceledExceptionpropagates 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):
ResponseCodein{ErrorServerBusy, ErrorInternalServerTransientError, ErrorTimeoutExpired, ErrorMailboxStoreUnavailable, ErrorInsufficientResources}→ transient;Success/NoError→ return; any otherError/Faultcode → permanent (message includes the code).
- Never log or embed the password: exception messages carry status/response code/
MessageTextonly; 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 fromAddNotificationService) - 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/userdom\svc/pw/from/bcc/subject/body; outcomeSuccess. - sender throws
EwsTransientException→Transient;EwsPermanentException→Permanent. - config errors →
PermanentWITHOUT calling the sender: non-absolute/non-httpsHost;AuthType="oauth2"; missing/:-lessCredentials; unknownTransport="Graph". Transport=nullandTransport="Smtp"still drive the SMTP path (existing_smtpClientFactoryinvoked, 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:
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:
UpdateSmtpConfigCommandgains a TRAILING optional paramstring? 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 viaEmailTransportParser.Parse(cmd.Transport ?? config.Transport)→ManagementCommandExceptiononArgumentException; when the effective transport is Ews, requireconfig.Host(post-assignment) to be an absolutehttpsURI and effectiveAuthTypeto bebasic— elseManagementCommandExceptionwith a message naming the offending field. (NotificationService is already referenced? Check: ifManagementService.csprojdoes not referenceZB.MOM.WW.ScadaBridge.NotificationService, inline the parse — a localswitchon the lowercased string — instead of adding a project reference; keeping the dependency graph flat wins over DRY here.)SmtpConfigPublicShapeaddsc.Transport.- Tests (failing first): update-with-
Transport="Ews"+https host+basic → persisted, response shape carries Transport;Transport="Graph"→ManagementCommandException; Ews withhttp://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:
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 carriesTransport— the page's row model needs the property added whereOAuth2Authorityetc. are declared.) - Edit form:
<select @bind="_transport">withSmtp/Ewsoptions, default from the loaded row (?? "Smtp"); when_transport == "Ews"hide Port + TLS + auth-type select (force-displaybasic, set_authType = "basic"on switch to Ews), show Host input labelled "EWS URL" with placeholderhttps://mail.example.com/ews/exchange.asmx; credentials placeholderdomain\username:password. Pass_transportas the newUpdateSmtpConfigCommandtrailing arg where the page builds the command (~line 237 area). - Playwright:
SmtpConfigTestsexist 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/OAuth2Scopeset survives export→import with all three intact; and a JSON payload WITHOUT the new properties (old bundle) deserializes with all three null. schemaVersionstays (additive rule); do not bumpbundleFormatVersion.
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 viaEncryptedStringConverter(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 viaSmtpConfiguration.Transport, EWS = no-SDK CreateItem/Basic/BCC/SendOnly. - Modify:
README.mdcomponent 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:
dotnet build ZB.MOM.WW.ScadaBridge.slnx→ 0 errors / 0 warnings.- 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). - Verify every task's commit is on the branch (
git log --oneline main..HEAD— expect ~10 commits). - 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.