Repository navigation
Model the third send-as state, and let a mail carry Cc/Bcc (#3450, #3473) - #3493
Conversation
) 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>
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>
There was a problem hiding this comment.
🟡 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 becomeUndeterminedwith 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.
|
Third falsification arm (added in 73fb0e5, a self-review catch on the first commit).
Neither downstream operator required a single emission — Pinned by Restored: 55/55 green across every email suite in the project. |
|
Re the Copilot review — the finding is correct, and here is the measurement plus why I am not Confirmed: Precedent measured, both directions:
So entries have lagged their commit before; none has led it. Mine would be the first future-dated Not fixing it here, deliberately. The PR is already in the merge queue ( The rename rides the next change that touches this area — the Plugins-half follow-up for #3450 / No other Copilot finding to address — the review raised this one point and no inline comments. |
Copilot's one finding, weighed and declined — with the reasoning, since it is not obviously wrong
Half right, and the half that is right does not justify the fix.
No gate objects — the run is green on this shape, and #3487 carries a 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 |
Two defects on the same interface —
IEmailSender— fixed as one coherent contract change, sothe second does not rebase onto a signature the first moved.
Pairs-with: none— measured, not asserted.scripts/check-type-forwards.py --base HEADreportsremoved: []: this diff removes 0 public types and 0 public members. Both new members aredefault interface methods, so the
Cross-repo pair (public surface)gate does not fire. See"Purely additive" below.
(1) #3450 —
CanSendAsUserhad two answers for three statestrueAvailablefalseUnavailablefalseUndeterminedThe send dialog renders
falseat OPEN time asui.sendDocument.connectFirst— "Your Microsoft 365mailbox 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 predatesthe #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.OffersConnectistrue for
Unavailablealone — deliberately not!IsAvailable, because that expression is thedefect written out. A consumer cannot re-derive it by accident.
hub.ObserveSendAsCapability(...)routes a faulted or silent probe intoUndeterminedand logsit 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 foldsUndeterminedto
false, so pressing Send still showsAskWhichMailboxrather than assuming an identity. That isthe correct answer for a genuinely binary decision, and it is the positive control below.
(2) #3473 —
IEmailSenderhad no Cc/BccEvery overload took ONE address, so
ExportAndSendsent 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.
EmailMessagecarriesTo/Cc/Bcc/Subject/HtmlBody/Attachments/Delivery;IEmailSender.SendEmail(EmailMessage)sends it as ONE mail.VisibleRecipientsis To ⧺ Cc andnever 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 isNoOpEmailSender: it delivers nothing to anybody and says so, so it accepts the full envelope andlogs 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:
ObserveSendAsCapabilityfoldsCanSendAsUser;CanSendAsUserfoldsObserveSendAsCapability.So an old sender (overriding only
CanSendAsUser— the shape of every implementation in a repopinning 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.
CanSendAsUseris kept as the two-state shim and is deliberately not[Obsolete], forthe reason spelled out on
IEaGraphAuth's retiring surface — MeshWeaver.Plugins builds-warnaserroragainst a core checkout at a pinned ref, and the attribute would red that repo beforeits own half could land.
CS0419(shape 3) checked in both repos: no unqualified<see cref>to any member that gainedan overload. One was caught locally, inside
Mesh.Contract, and fixed.Falsification — both arms, with numbers
Real monolith mesh (
MonolithMeshTestBase), no mocking of core interfaces, noTask.Delay, no.ToTask().Arm 1 (#3450) — restore the old
.Catch(… => Unavailable)swallow:Arm 2 (#3473) — make the default silently forward the first address and drop the rest:
Restored: 22 new tests green; 54/54 across every email suite in the project
(
EmailSendAsCapabilityTest,EmailCopiedRecipientsTest,NoOpEmailSenderRefusalTest,EmailMisconfigurationRefusalTest,InvitationEmailTest,OutboundSendQueueTest) — the last fourpre-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_SplitsOneMailIntoFourmeasures 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— all0 Warning(s), 0 Error(s).Guards run:
DocumentationLinkIntegrityTest✓ ·HubReachableAsyncGuard✓ (nothing added returnsTask/ValueTask) ·LocalizationTest58/58 ✓.🚧 What is NOT in this PR, and why
The user-visible half of both issues lives in
MeshWeaver.Plugins, which builds-warnaserroragainst a core checkout at
MW_PLATFORM_REF(bbcb22f2). It cannot compile until that pin carriesthese 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.mdnames the remaining diff file by file (GraphEmailSender,SendDocumentLayoutArea,SendDocumentDispatch) so the next session can execute it withoutre-deriving the design. The two localization keys the third panel needs
(
ui.sendDocument.connectUnknown,ui.sendDocument.checkAgain) ship here, inenandde, sothe 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 comparesagainst a pinned core commit, so these keys red nothing and leave it silently stale until that runs.
Docs
Doc/Architecture/SendingEmailgains two sections — the three-answer probe (with the table above andthe 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 thePlugins consumer. Both stay open until that lands.