Skip to content

Model the third send-as state, and let a mail carry Cc/Bcc (#3450, #3473) - #3493

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/3450-3473-email-send-as-three-state-and-copied-recipients
Sep 6, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/3450-3473-email-send-as-three-state-and-copied-recipients

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Two defects on the same interface — IEmailSender — fixed as one coherent contract change, so
the second does not rebase onto a signature the first moved.

Pairs-with: none — measured, not asserted. scripts/check-type-forwards.py --base HEAD reports
removed: []: this diff removes 0 public types and 0 public members. Both new members are
default interface methods, so the Cross-repo pair (public surface) gate does not fire. See
"Purely additive" below.


(1) #3450 — CanSendAsUser had two answers for three states

world before now
the user connected their mailbox true Available
the check completed, no credential false Unavailable
the check did not complete false Undetermined

The send dialog renders false at OPEN time as ui.sendDocument.connectFirst — "Your Microsoft 365
mailbox is not connected yet"
— beside a Connect button. On a transient fault that is a
user-visible statement that is false, shown to a user who had connected. Both consumers
already wrapped the probe in .Catch(_ => Observable.Return(false)), so the collapse predates
the #3433 reactive seam and nothing was logged — the third state was not even greppable.

The rendering rule lives on the answer, not in each UI. EmailSendAsCapability.OffersConnect is
true for Unavailable alone — deliberately not !IsAvailable, because that expression is the
defect written out. A consumer cannot re-derive it by accident.

hub.ObserveSendAsCapability(...) routes a faulted or silent probe into Undetermined and logs
it at Warning naming the user and the diagnostic — once, where the sender is resolved, instead of
every consumer having to remember. A probe that completes without emitting gets the same state: that
is exactly how a dropped mesh read looks.

🚨 The send path is deliberately unchanged. hub.CanSendAsUser(...) still folds Undetermined
to false, so pressing Send still shows AskWhichMailbox rather than assuming an identity. That is
the correct answer for a genuinely binary decision, and it is the positive control below.

(2) #3473 — IEmailSender had no Cc/Bcc

Every overload took ONE address, so ExportAndSend sent one mail per recipient. A To with seven Cc —
the ordinary shape of a business mail — went out as eight messages: no recipient could see who
else received it, Reply-All reached only the sender, the thread split eight ways, and Bcc had no
expressible form at all
.

EmailMessage carries To/Cc/Bcc/Subject/HtmlBody/Attachments/Delivery;
IEmailSender.SendEmail(EmailMessage) sends it as ONE mail. VisibleRecipients is To ⧺ Cc and
never Bcc.

🚨 A sender that cannot carry copies REFUSES, naming the counts — it never delivers a quietly
smaller mail.
Delivering to fewer people than the sender addressed while reporting success is the
same class of defect as stamping an undelivered mail Sent (#2023). The one exception is
NoOpEmailSender: it delivers nothing to anybody and says so, so it accepts the full envelope and
logs every count — and still refuses outright on a mail-enabled-but-unconfigured install.


Purely additive — and why that is the whole design

Both members are default interface methods whose defaults point in opposite directions and never
recurse
:

  • the interface's ObserveSendAsCapability folds CanSendAsUser;
  • the hub extension CanSendAsUser folds ObserveSendAsCapability.

So an old sender (overriding only CanSendAsUser — the shape of every implementation in a repo
pinning an older core) compiles untouched and answers exactly what it answered before, in two states.
A new sender overriding only the three-state member is folded correctly at every consumer.

This is what makes shape 8 (an added interface member breaking external implementers)
inapplicable: a DIM has a body, so nothing is stranded. Every implementer found is listed in the
report. CanSendAsUser is kept as the two-state shim and is deliberately not [Obsolete], for
the reason spelled out on IEaGraphAuth's retiring surface — MeshWeaver.Plugins builds
-warnaserror against a core checkout at a pinned ref, and the attribute would red that repo before
its own half could land.

CS0419 (shape 3) checked in both repos: no unqualified <see cref> to any member that gained
an overload. One was caught locally, inside Mesh.Contract, and fixed.

Falsification — both arms, with numbers

Real monolith mesh (MonolithMeshTestBase), no mocking of core interfaces, no Task.Delay, no
.ToTask().

Arm 1 (#3450) — restore the old .Catch(… => Unavailable) swallow:

Failed: 2, Passed: 11, Total: 13
  ✗ AProbeThatFaults_IsUndetermined_AndTheDialogDoesNotClaimNotConnected
      Expected value to be true because a probe that faulted produced NO answer
      about this user's mailbox, but found False.
  ✗ AProbeThatNeverAnswers_IsUndetermined
  ✓ AnUndeterminedProbe_StillFoldsToFalse_SoTheSendPathAsksWhichMailbox  (×3)  ← passes BOTH ways

Arm 2 (#3473) — make the default silently forward the first address and drop the rest:

Failed: 2, Passed: 7, Total: 9
  ✗ ASingleAddressSender_RefusesCopiedRecipients_RatherThanDroppingThem
      Expected value to be OnError because a Cc that cannot be honoured is a
      refusal, not a smaller success, but found OnNext.
  ✗ AMessageWithNoRecipients_IsRefused  (IndexOutOfRange — the drop shape cannot even express it)
  ✓ AMessageWithCcAndBcc_IsDeliveredAsOneMessage                              ← positive control

Restored: 22 new tests green; 54/54 across every email suite in the project
(EmailSendAsCapabilityTest, EmailCopiedRecipientsTest, NoOpEmailSenderRefusalTest,
EmailMisconfigurationRefusalTest, InvitationEmailTest, OutboundSendQueueTest) — the last four
pre-existing, proving no regression to the senders already shipping.

The Cc/Bcc numbers, stated as counts: 1 send carrying 4 recipients, 3 mutually visible,
1 blind — against 4 sends carrying 1 each, 0 able to see anyone, which
TheOldPerRecipientShape_SplitsOneMailIntoFour measures as the control.

Builds (-c Release -warnaserror, one project per invocation)

MeshWeaver.Mesh.Contract · MeshWeaver.Messaging.Hub · MeshWeaver.Graph ·
Memex.Portal.Shared · Memex.Portal.Shared.Test · MeshWeaver.Documentation.Test ·
MeshWeaver.Messaging.Hub.Test — all 0 Warning(s), 0 Error(s).

Guards run: DocumentationLinkIntegrityTest ✓ · HubReachableAsyncGuard ✓ (nothing added returns
Task/ValueTask) · LocalizationTest 58/58 ✓.

🚧 What is NOT in this PR, and why

The user-visible half of both issues lives in MeshWeaver.Plugins, which builds -warnaserror
against a core checkout at MW_PLATFORM_REF (bbcb22f2). It cannot compile until that pin carries
these members, and this session is fenced from moving any platform pin. The order is therefore
core → pin → Plugins — exactly the scoping in
#3450 (comment) (maintainer, on the issue).

Because the change is additive, landing core alone strands nothing: no signature moved, no
implementer broke, and every consumer keeps its current behaviour until it opts in.

SendingEmail.md names the remaining diff file by file (GraphEmailSender,
SendDocumentLayoutArea, SendDocumentDispatch) so the next session can execute it without
re-deriving the design. The two localization keys the third panel needs
(ui.sendDocument.connectUnknown, ui.sendDocument.checkAgain) ship here, in en and de, so
the consumer half adds no catalog entries.

🚨 i18n mirror handover — this PR adds 2 catalog keys. After merge:
npm run sync:i18n -- --ref <merged core sha> in MeshWeaver.Plugins. The mirror guard compares
against a pinned core commit, so these keys red nothing and leave it silently stale until that runs.

Docs

Doc/Architecture/SendingEmail gains two sections — the three-answer probe (with the table above and
the send-path exception) and Cc/Bcc as one message — plus the remaining-work table. Two What's New
entries, Category: Fix.

Refs #3450 · Refs #3473 — deliberately not Closes: each issue's user-visible half is the
Plugins consumer. Both stay open until that lands.

)

Two defects on the same interface, IEmailSender, fixed as one coherent
contract change so the second does not rebase onto a signature the first
moved.

#3450 — CanSendAsUser returns IObservable<bool>: two answers for three
states. A check that TIMED OUT or FAULTED became the same false as a check
that completed and found no credential, and the send dialog renders false at
open time as "Your Microsoft 365 mailbox is not connected yet" beside a
Connect button. On a transient fault that sentence is untrue, shown to a user
who HAD connected — #3433 one layer down. Both consumers wrapped the probe in
.Catch(_ => Observable.Return(false)), so the collapse predates the reactive
seam and nothing was logged.

  EmailSendAsCapability carries Available / Unavailable / Undetermined, and
  the RENDERING RULE lives on the answer: OffersConnect is true for
  Unavailable ALONE — deliberately not !IsAvailable, which is the collapse
  written out. hub.ObserveSendAsCapability routes a faulted OR silent probe
  into Undetermined and logs it at Warning naming the user and diagnostic,
  once, where the sender is resolved.

  The send path is deliberately unchanged: hub.CanSendAsUser still folds
  Undetermined to false, so pressing Send still asks which mailbox rather
  than assuming an identity. That is the correct answer for a binary
  decision, and it is a positive control that passes both ways.

#3473 — every SendEmail overload took ONE address, so ExportAndSend sent one
mail per recipient. A To with seven Cc went out as eight messages: nobody
could see who else received it, Reply-All reached only the sender, and Bcc
had no expressible form at all.

  EmailMessage carries To/Cc/Bcc/Subject/HtmlBody/Attachments/Delivery, and
  SendEmail(EmailMessage) sends it as one mail. VisibleRecipients is To ⧺ Cc
  and never Bcc. A sender that cannot carry copies REFUSES with a message
  naming the counts; it never delivers a quietly smaller mail, which is the
  same class of defect as stamping an undelivered mail Sent (#2023).

Both members are default interface methods whose defaults point in opposite
directions and never recurse, so the change is PURELY ADDITIVE: measured with
scripts/check-type-forwards.py, this diff removes 0 public types and 0 public
members. Every existing implementer — including those in a repository pinning
an older core — compiles untouched and answers exactly what it answered
before. CanSendAsUser is kept as the two-state shim and is deliberately NOT
[Obsolete], for the reason given on IEaGraphAuth's retiring surface.

Falsified both ways, on a real monolith mesh, no mocking:
  - restore the .Catch swallow  -> 2 of 13 #3450 tests fail on IsUndetermined;
    the send-time fold stays green, as it must.
  - make the default drop Cc/Bcc -> the refusal test fails with "found OnNext"
    (a silent smaller success); the one-message test stays green.
  22 new tests, 54 green across every email suite in the project.

The user-visible consumers live in MeshWeaver.Plugins and cannot compile
until MW_PLATFORM_REF carries these members, so the order is core -> pin ->
Plugins. SendingEmail.md names the remaining diff file by file; the two
localization keys the third panel needs ship here in en and de.

Refs #3450
Refs #3473

Deliberately NOT "Closes": each issue's user-visible half is the Plugins
consumer, which needs the pin. Both stay open until that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 6, 2026 22:24
@meshweaver-cloud
meshweaver-cloud Bot enabled auto-merge September 6, 2026 22:24
Self-review catch on the first commit. ObserveSendAsCapability carried a
.Take(1) that nothing needed and that broke two things:

  * The send dialog CombineLatests this stream into the form it renders, so
    it IS a live data-bound view — and .Take(1) on one of those freezes the
    binding (AGENTS.md, the GUI rule).
  * It is the mechanism the "Check again" affordance needs: a sender that
    re-probes must be able to replace an Undetermined answer with a real one
    IN PLACE, without the dialog being torn down.

Neither operator downstream required a single emission: Catch replaces only
the tail after a fault, and DefaultIfEmpty fires only on an empty completion.
The send-time caller, which genuinely wants one answer, already takes its own
.Take(1) in SubmitSend.

Pinned by TheProbeStaysLive_SoARetryCanReplaceAnUndeterminedAnswerInPlace.
Falsified: re-adding .Take(1) fails it with "Expected collection to have 2
item(s) ... but found 1". 55 green across every email suite in the project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The two new What's New entries are future-dated (2026-09-07) relative to the PR timestamp (2026-09-06) and should be renamed/retimed to avoid future-dated release notes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR evolves the core email-sending contract (IEmailSender) to (1) model a third “send-as” capability state (Undetermined) so UI can distinguish “not connected” vs “could not check”, and (2) support sending a single email with To/Cc/Bcc via a new EmailMessage envelope, with defaults that refuse silent recipient dropping.

Changes:

  • Add EmailSendAsCapability (+ hub helper) so faulted/silent capability probes become Undetermined with a diagnostic and a single warning log site.
  • Add EmailMessage + IEmailSender.SendEmail(EmailMessage) default behavior (forward only when single-recipient/no-copies; otherwise refuse), and override NoOp sender to accept full envelopes when mail is disabled.
  • Add i18n keys, docs, and new monolith-mesh tests covering both defect arms and compatibility behavior.
File summaries
File Description
test/Memex.Portal.Shared.Test/EmailSendAsCapabilityTest.cs New monolith-mesh tests for 3-state send-as capability, including warning logging and binary fold control.
test/Memex.Portal.Shared.Test/EmailCopiedRecipientsTest.cs New tests proving single-message To/Cc/Bcc behavior, refusal semantics, and NoOp sender behavior.
src/MeshWeaver.Messaging.Hub/Localization/strings.en.json Add ui.sendDocument.connectUnknown and ui.sendDocument.checkAgain (English).
src/MeshWeaver.Messaging.Hub/Localization/strings.de.json Add ui.sendDocument.connectUnknown and ui.sendDocument.checkAgain (German).
src/MeshWeaver.Mesh.Contract/IEmailSender.cs Add DIMs for ObserveSendAsCapability and SendEmail(EmailMessage); retain CanSendAsUser as two-state shim.
src/MeshWeaver.Mesh.Contract/HubEmailExtensions.cs Add SendEmail(EmailMessage) hub overload; add/log the 3-state probe and keep binary fold helper.
src/MeshWeaver.Mesh.Contract/EmailSendAsCapability.cs New EmailSendAs + EmailSendAsCapability types defining the 3-state model and UI rule (OffersConnect).
src/MeshWeaver.Mesh.Contract/EmailMessage.cs New immutable email envelope with To/Cc/Bcc, visibility helpers, and refusal diagnostics.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-07-a-mail-with-people-in-copy-goes-out-as-one-message.md New What's New entry describing Cc/Bcc single-message behavior.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-07-a-check-that-did-not-run-no-longer-says-you-are-not-connected.md New What's New entry describing the 3-state capability probe behavior.
src/MeshWeaver.Documentation/Data/Architecture/SendingEmail.md Document the new 3-state probe and EmailMessage To/Cc/Bcc contract, plus remaining consumer work in Plugins.
memex/Memex.Portal.Shared/Email/NoOpEmailSender.cs Override SendEmail(EmailMessage) to accept full envelopes when disabled and refuse when enabled/misconfigured.
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@rbuergi

rbuergi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Third falsification arm (added in 73fb0e5, a self-review catch on the first commit).

ObserveSendAsCapability carried a .Take(1) that nothing needed and that broke two things:

  • the send dialog CombineLatests this stream into the form it renders, so it is a live
    data-bound view — and .Take(1) on one of those freezes the binding (AGENTS.md, the GUI rule);
  • it is the mechanism the Check again affordance needs: a sender that re-probes must be able to
    replace an Undetermined answer with a real one in place, without the dialog being torn down.

Neither downstream operator required a single emission — Catch replaces only the tail after a
fault, DefaultIfEmpty fires only on an empty completion — and the send-time caller already takes
its own .Take(1) in SubmitSend.

Pinned by TheProbeStaysLive_SoARetryCanReplaceAnUndeterminedAnswerInPlace. Falsified by re-adding
.Take(1):

Failed: 1, Passed: 0, Total: 1
  Expected collection to have 2 item(s) because truncating the probe to one answer
  freezes the dialog's binding and makes 'Check again' unable to replace the panel
  it is offered on, but found 1.

Restored: 55/55 green across every email suite in the project.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files  ± 0      5 suites  ±0   2m 13s ⏱️ -3s
1 457 tests +23  1 457 ✅ +23  0 💤 ±0  0 ❌ ±0 
1 458 runs  +23  1 458 ✅ +23  0 💤 ±0  0 ❌ ±0 

Results for commit 73fb0e5. ± Comparison against base commit ddb95e6.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

251 tests  ±0   251 ✅ ±0   1m 54s ⏱️ +8s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 73fb0e5. ± Comparison against base commit ddb95e6.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

488 tests  ±0   488 ✅ ±0   41s ⏱️ -1s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 73fb0e5. ± Comparison against base commit ddb95e6.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 016 tests  ±0   1 016 ✅ ±0   1m 15s ⏱️ -4s
    2 suites ±0       0 💤 ±0 
    2 files   ±0       0 ❌ ±0 

Results for commit 73fb0e5. ± Comparison against base commit ddb95e6.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 599 tests  ±0   1 405 ✅ ±0   2m 7s ⏱️ +4s
    4 suites ±0     194 💤 ±0 
    4 files   ±0       0 ❌ ±0 

Results for commit 73fb0e5. ± Comparison against base commit ddb95e6.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 057 tests  ±0   1 057 ✅ ±0   3m 30s ⏱️ -1s
    3 suites ±0       0 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit 73fb0e5. ± Comparison against base commit ddb95e6.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Test Results

   16 files  ± 0     16 suites  ±0   11m 43s ⏱️ +6s
5 868 tests +23  5 674 ✅ +23  194 💤 ±0  0 ❌ ±0 
5 869 runs  +23  5 675 ✅ +23  194 💤 ±0  0 ❌ ±0 

Results for commit 73fb0e5. ± Comparison against base commit ddb95e6.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 6, 2026
@rbuergi

rbuergi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Re the Copilot review — the finding is correct, and here is the measurement plus why I am not
ejecting the queue for it.

Confirmed: date -u reads 2026-09-06 22:46Z while local reads 2026-09-07 00:46 +0200. I named
both What's New entries from the harness's local date, so they are one day ahead of UTC — and this
PR will merge on 2026-09-06 UTC. The theme page says an entry is "produced automatically when a
pull request merges"
, so the merge date is the right anchor and 2026-09-06 is what these should
have been.

Precedent measured, both directions:

entry committed (UTC)
2026-09-06-*.md 2026-09-06T21:15Z — same day
2026-09-05-*.md 2026-09-05T21:15Z — same day
2026-09-02-*.md 2026-09-03T10:41Z — dated before its commit

So entries have lagged their commit before; none has led it. Mine would be the first future-dated
pair, which is the direction worth avoiding.

Not fixing it here, deliberately. The PR is already in the merge queue (added_to_merge_queue
22:43:48Z), and a push to a queued branch ejects it. Ejecting a green, queued PR — and burning a
second full matrix run — to rename two release-note files is poor value against the standing
directive that every CI run is paid for. It is cosmetic: no link resolves to these filenames
(DocumentationLinkIntegrityTest is green), and Order: only affects sort position within What's
New.

The rename rides the next change that touches this area — the Plugins-half follow-up for #3450 /
#3473, which needs a MW_PLATFORM_REF bump and will touch these docs anyway. Recording it here so it
is not lost: rename both 2026-09-07-*.md → 2026-09-06-*.md and set Order: -20260906.

No other Copilot finding to address — the review raised this one point and no inline comments.

@rbuergi

rbuergi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Copilot's one finding, weighed and declined — with the reasoning, since it is not obviously wrong

The two new What's New entries are future-dated (2026-09-07) relative to the PR timestamp (2026-09-06)

Half right, and the half that is right does not justify the fix.

  • Right: every other entry in Data/WhatsNew/ is dated 2026-09-06, and UTC at the time of writing is 2026-09-06 22:47Z. So 2026-09-07-… is ahead of the tree and ahead of UTC by 73 minutes. The date came from local time (CEST, 2026-09-07 00:47).
  • Not right enough to act on: the only way to rename the files is a push to this branch, and a push to a queued branch EJECTS it — EVERY queue mutation restarts every build. Paying two full queue builds (this PR's and every entry behind it) to move a release-note filename by one day, on a date that becomes correct in 73 minutes, is not a trade this repo's CI budget should make. The maintainer's directive is explicit that CI is paid for.

No gate objects — the run is green on this shape, and #3487 carries a 2026-09-07-… entry through the same lane.

What it does leave behind, and is worth fixing once rather than twice: nothing decides which clock names a What's New file. Both spellings are legal today, which is why two PRs on the same night disagree. That is a one-line convention in AuthoringDocumentation.md plus a check, not a rebase of two queued PRs — filed separately rather than smuggled in here.

Merged via the queue into main with commit 91ef231 Sep 6, 2026
28 checks passed
@rbuergi
rbuergi deleted the fix/3450-3473-email-send-as-three-state-and-copied-recipients branch September 13, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants