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

27 KiB
Raw Blame History

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: Commitgit 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):

    /// <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: Commitfeat(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:Faultdetail//t:ResponseCode (fall back to faultstring).

Step 4: Run tests → PASS. Step 5: Commitfeat(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:

/// <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 EwsTransientExceptionTransient; EwsPermanentExceptionPermanent.
  • 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:

        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 _ewsMailSenderPermanent("EWS transport configured but no EWS sender is registered"); call SendAsync; catch EwsPermanentExceptionPermanent, EwsTransientExceptionTransient, 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:

    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.