Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions memex/Memex.Portal.Shared/Email/NoOpEmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,35 @@ public IObservable<bool> SendEmail(string toAddress, string subject, string html

public IObservable<bool> SendEmail(
string toAddress, string subject, string htmlBody, IReadOnlyCollection<EmailAttachment> attachments)
=> SendEmail(new EmailMessage
{
To = [toAddress],
Subject = subject,
HtmlBody = htmlBody,
Attachments = attachments,
});

/// <summary>
/// 🚨 Overridden so a message with <b>Cc/Bcc</b> is handled by this sender rather than refused
/// by the interface default (#3473).
///
/// <para>The default refusal exists to stop a single-address transport from quietly delivering
/// a DIFFERENT message than the one it was handed. That reasoning does not apply here: this
/// sender delivers nothing at all, to anybody, and says so. Refusing copied recipients would
/// break every local-dev and test send that carries one, while protecting a delivery that is
/// not happening either way.</para>
///
/// <para>What it does still do is name the FULL envelope in its log line — To, Cc and Bcc
/// counts — so a developer reading the log sees the message that would have gone out, copies
/// included, instead of a line that silently describes fewer recipients than were asked for.
/// The <c>Email:Enabled=true</c> refusal (#2023) is unchanged and applies to the whole
/// envelope.</para>
/// </summary>
/// <param name="message">The message, its recipients, its attachments and its sender identity.</param>
public IObservable<bool> SendEmail(EmailMessage message)
{
var recipients = string.Join(", ", message.AllRecipients);

if (options.Enabled)
{
// ExplainRefusal, not Explain: this install may have the module and be missing only a
Expand All @@ -50,14 +78,18 @@ public IObservable<bool> SendEmail(
var explanation = EmailDeliveryGuard.ExplainRefusal(
options, "This send is REFUSED rather than reported as delivered.");
logger?.LogError(
"Refusing to send to {To} (subject: {Subject}, attachments: {Attachments}). {Explanation}",
toAddress, subject, attachments.Count, explanation);
"Refusing to send to {To} (subject: {Subject}, to: {ToCount}, cc: {CcCount}, "
+ "bcc: {BccCount}, attachments: {Attachments}). {Explanation}",
recipients, message.Subject, message.To.Length, message.Cc.Length,
message.Bcc.Length, message.Attachments.Count, explanation);
return Observable.Throw<bool>(new InvalidOperationException(explanation));
}

logger?.LogInformation(
"Email disabled (Email:Enabled=false) — skipping send to {To} (subject: {Subject}, attachments: {Attachments})",
toAddress, subject, attachments.Count);
"Email disabled (Email:Enabled=false) — skipping send to {To} (subject: {Subject}, "
+ "to: {ToCount}, cc: {CcCount}, bcc: {BccCount}, attachments: {Attachments})",
recipients, message.Subject, message.To.Length, message.Cc.Length,
message.Bcc.Length, message.Attachments.Count);
return Observable.Return(true);
}
}
118 changes: 116 additions & 2 deletions src/MeshWeaver.Documentation/Data/Architecture/SendingEmail.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,126 @@ Two identities exist, and the recipient can tell them apart — so the choice is
The delegated scope needed is `Mail.Send`, and it is **already part of `EaGraphAuth.Scopes`** —
connecting the personal assistant grants it, so there is no separate consent step for sending.

- **Probe before composing**: `hub.CanSendAsUser(objectId)`, so the UI can STATE the identity rather
than the user discovering it in the recipient's inbox.
- **Probe before composing**: `hub.ObserveSendAsCapability(objectId)`, so the UI can STATE the
identity rather than the user discovering it in the recipient's inbox.
- **Never fall back silently.** If the user is not connected, offer `/auth/ea/connect`; the shared
mailbox is only ever an explicitly chosen second option — and then set
`EmailDelivery.AsSharedMailboxReplyingTo(userEmail)` so a reply still reaches the human.

### 🚨 The probe has THREE answers, and only one of them may say "you have not connected"

`IEmailSender.CanSendAsUser` returns `IObservable<bool>` — two answers for three states of the
world. Both consumers wrapped it in `.Catch(_ => Observable.Return(false))`, so a probe that timed
out or faulted became the same `false` as a probe that completed and found no credential. The send
dialog renders `false` at open time as *"Your Microsoft 365 mailbox is not connected yet"* beside a
Connect button — a sentence that is simply untrue on a transient fault, shown to a user who **had**
connected. Nothing was logged, so the third state was not even greppable. That is
[#3450](https://github.com/Systemorph/MeshWeaver/issues/3450), and it is
[#3433](https://github.com/Systemorph/MeshWeaver/issues/3433) one layer down.

`IEmailSender.ObserveSendAsCapability(userObjectId)` answers with `EmailSendAsCapability`:

| `EmailSendAs` | Means | What the UI may say |
|---|---|---|
| `Available` | The check completed; this sender can act as the person | Name the address the mail leaves from |
| `Unavailable` | The check completed and found nothing | *"Not connected yet"* + a Connect button — **the only truthful moment** |
| `Undetermined` | The check produced **no answer** (timeout, transport fault, unreadable content) | *"We could not check just now"* + a retry. **Never** a claim about the mailbox |

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

Faults route into the state rather than being swallowed: `hub.ObserveSendAsCapability(...)` maps a
faulted **or silent** probe to `Undetermined` and logs it at Warning naming the user and the
diagnostic, once, where the sender is resolved. A probe that completes without emitting is the same
non-answer as one that threw — that is exactly how a dropped mesh read looks.

**The send path is different, and is deliberately unchanged.** At send time the decision really is
binary: send as the person, or show `AskWhichMailbox`. `hub.CanSendAsUser(...)` remains, folding
`Undetermined` to `false`, and that is the *correct* answer there — the user is **asked** which
mailbox rather than assumed connected. Only code that RENDERS A CLAIM must ask for the three-state
answer.

`IEmailSender.CanSendAsUser` itself is retained as the two-state shim so every existing implementer
keeps compiling and answering exactly what it answered before. The two defaults point in opposite
directions and never recurse: the interface's `ObserveSendAsCapability` folds `CanSendAsUser`, while
the hub extension `CanSendAsUser` folds `ObserveSendAsCapability`. So an old sender is unchanged, and
a sender that overrides only the new member is folded correctly at every consumer. It is not
`[Obsolete]` for the reason given 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.

---

## Cc and Bcc — one message, not N

A message with copied recipients **cannot be delivered as several mails**. Before
[#3473](https://github.com/Systemorph/MeshWeaver/issues/3473) every `SendEmail` overload took a
single `toAddress`, so `SendDocumentDispatch.ExportAndSend` did the only thing available to it —
`emails.ToObservable().SelectMany(to => hub.SendEmail(to, …))`, one mail per recipient. A To with
seven Cc, the ordinary shape of a business mail, went out as eight separate messages:

- no recipient could see who else received it, because each message's visible header named one person;
- Reply-All reached nobody but the sender, so the thread split into eight unrelated conversations;
- **Bcc had no expressible form at all** — sending separately to a blind recipient produces a message
whose header does not match what anyone else got.

`EmailMessage` carries the whole envelope — `To`, `Cc`, `Bcc`, `Subject`, `HtmlBody`, `Attachments`,
`Delivery` — and `IEmailSender.SendEmail(EmailMessage)` sends it as ONE mail:

```csharp
hub.SendEmail(new EmailMessage
{
To = ["client@example.com"],
Cc = ["colleague@example.com", "manager@example.com"],
Bcc = ["archive@example.com"],
Subject = "Q3 review deck",
HtmlBody = "<p>Attached.</p>",
Delivery = EmailDelivery.AsUser(objectId),
})
.Subscribe(ok => Log.LogInformation("sent: {Ok}", ok),
ex => Log.LogError(ex, "send failed"));
```

`VisibleRecipients` is To ⧺ Cc (de-duplicated, case-insensitively) — the header every reader sees.
`Bcc` is deliberately absent from it: a blind recipient appearing there is the disclosure the field
exists to prevent. `AllRecipients` adds Bcc, for *"the send reached these addresses"* reporting only.

🚨 **A sender that cannot carry copies REFUSES; it never drops them.** The default implementation
forwards to the existing single-address overload when the message fits one (`To` of exactly one, no
copies) — so every sender written before this member keeps serving every call it could already
serve — and otherwise faults with a `NotSupportedException` naming the counts. Delivering to fewer
people than the sender addressed is the same class of defect as stamping an undelivered mail `Sent`
(#2023): the person is told the thing they asked for happened.

The host's `NoOpEmailSender` is the one deliberate exception. It delivers nothing to anybody and
says so, so there is no smaller message for it to deliver silently; it accepts the full envelope,
names every count in its log line, and still refuses outright on a mail-enabled-but-unconfigured
install.

### 🚧 The consumers live in MeshWeaver.Plugins, and land after the pin moves

Both seams above are **contract-side only** in this repository. Everything a person can SEE is in
`MeshWeaver.Plugins`, which builds `-warnaserror` against a core checkout at `MW_PLATFORM_REF` — so
the consuming half cannot compile until that pin carries these members. The order is therefore
**core → pin → Plugins**, and it is the reason the members are additive rather than a signature
change: an old sender at the old pin keeps compiling and answering exactly as before.

What remains, precisely:

| File (MeshWeaver.Plugins) | Change |
|---|---|
| `src/MeshWeaver.Mail.MicrosoftGraph/GraphEmailSender.cs` | Override `ObserveSendAsCapability` off `IEaGraphAuth.GetConnection`, mapping `EaConnection.Connected/NotConnected/Undetermined` straight through — it is the only implementation that can OBSERVE the third state. Override `SendEmail(EmailMessage)` to build one Graph `Message` with `ToRecipients`/`CcRecipients`/`BccRecipients` (the SDK already supports all three). |
| `src/MeshWeaver.Markdown.Export/Layout/SendDocumentLayoutArea.cs` | At dialog open, replace the `CanSendAsUser` probe and its `.Catch(… => false)` with `host.Hub.ObserveSendAsCapability(...)`; render the Connect panel on `capability.OffersConnect`, and a third panel — `ui.sendDocument.connectUnknown` plus a `ui.sendDocument.checkAgain` retry — on `IsUndetermined`. **Leave `SubmitSend` alone**: asking which mailbox is already the right answer for an unknown. |
| `src/MeshWeaver.Markdown.Export/Handlers/SendDocumentDispatch.cs` | Replace `SendToAll`'s per-recipient `SelectMany` with ONE `hub.SendEmail(EmailMessage)`, and give `ExportAndSend` `cc`/`bcc` parameters beside `rawEmails`. |

The two localization keys the third panel needs (`ui.sendDocument.connectUnknown`,
`ui.sendDocument.checkAgain`) ship in this change, in both `en` and `de`, so the consumer half adds
no catalog entries of its own. They still need the mirror sync described in
[Localization](/Doc/Architecture/Localization) — the plugins repo's React catalog compares against a
PINNED core commit, so a key added here reddens nothing and leaves that mirror silently stale until
`npm run sync:i18n -- --ref <merged core sha>` runs.

🚨 **Mailbox data is queried LIVE from Graph and never replicated into the mesh.** Graph is the
system of record and always current; a mirror would buy nothing and would leave personal
correspondence at rest in the mesh. Recipients come from `/me/people`, a reply target from a live
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
Name: A check that could not run no longer says you are not connected
Category: Fix
Description: When the portal cannot check whether your mailbox is connected, it now says so instead of telling you that you never connected.
Icon: Sparkle
Order: -20260907
---

# A check that could not run no longer says you are not connected

Opening **Share ⇒ as email** starts a quick check: can this message go out from your own mailbox? If
the answer was no, the dialog said so plainly — *"Your Microsoft 365 mailbox is not connected yet"* —
and offered a Connect button.

The trouble was that the check had only two answers for three situations. "You have not connected"
and "the check did not finish" both arrived as *no*. So a brief hiccup — a slow moment, a connection
that dropped — showed people who **had** connected a panel telling them they had not, and invited
them through a setup step they had already done.

Now the check keeps the three situations apart. If it cannot reach an answer, the dialog says that:
it could not check just now, your draft is safe, and you can send either way. It no longer states
anything about your mailbox that it does not know.

Two things are deliberately unchanged. The Connect panel still appears, exactly as before, when the
check finishes and genuinely finds no connected mailbox — that sentence is true there, and it is the
one moment worth offering the setup. And pressing **Send** still asks which mailbox the message
should leave from whenever your own cannot be confirmed, rather than quietly sending from somewhere
else. Nothing sends from an identity the portal is not sure about.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
Name: A mail with people in copy goes out as one message
Category: Fix
Description: Copied recipients can now travel on the same message, so everyone sees who else received it and Reply-All reaches the group.
Icon: Sparkle
Order: -20260907
---

# A mail with people in copy goes out as one message

Mail sent from the portal could only ever be addressed to one person at a time. Sending to several
meant sending the message several times — one copy each, every one addressed to a single recipient.

That is not the same mail. Nobody could see who else had received it, so a client had no idea their
colleague was in the loop. Replying to everyone reached only the sender, and one conversation became
several unrelated ones. And a blind copy — a message someone receives without the other recipients
seeing it — had no way to be sent at all.

A message now carries its own recipients: **To**, **Cc** and **Bcc**, delivered as one mail. The
people in To and Cc can see each other, which is the whole point of copying someone in. The people in
Bcc receive it and nobody else knows, which is the whole point of that.

Where a message genuinely cannot be delivered that way, it is now **refused and reported** rather
than quietly sent to fewer people than were addressed. A mail that reaches half the people it names,
while telling the sender it went out, is worse than one that plainly did not go.
Loading
Loading