Repository navigation
feat(email): Share ⇒ as email — send a document as the message, in the user's own name - #1222
Conversation
…ws resolved
"Send to contacts" could only ever send a PDF attachment: it exported the node to a
file, attached it, and put a one-line covering note in the body. Asked to email a
document, a user had to copy-paste it — and lost exactly the parts that mattered.
The node menu entry (same block as Export to PDF / DOCX, now "Email this document")
gains a delivery choice. DocumentDelivery.EmailBody runs the SAME node => file
pipeline with a new ExportFormat.Html — Templates/Export/Html — and uses the result
as the email body, prepending the sender's note into it. Attachment delivery keeps
the old behaviour. One entry, not two: the capability absorbs the existing one.
Embedded layout areas now survive the export. A document can embed a live view with
@@(...), and every export so far dropped it: the PDF/DOCX templates parse markdown
with a pipeline that has never heard of the embed syntax, so an embed is printed as
its LITERAL SOURCE TEXT; the pixel path emits the placeholder div a browser would
have filled and prints a blank. EmailAreaResolver opens each area's synchronization
stream server-side under the caller's AccessContext and EmailControlRenderer
serializes the settled control tree to static markup — no browser involved. A live
area has no completion signal (OgCard emits placeholders and fills them in as each
fetch lands), so the snapshot is taken on quiescence, never on the first emission.
The output targets the Word engine Outlook renders with, because each of these is a
defect seen in a real inbox:
- per-cell <td>/<th> widths, since Word IGNORES <colgroup>;
- column widths proportional to content volume, sqrt-damped with a floor and cap,
so a prose column cannot starve the short ones;
- table layout only (no flex/grid), inline CSS only (head styles get stripped);
- inline <svg> removed — Word paints it as a broken-image box;
- absolute URLs, since a mail client has no page origin;
- pictures converted to cid: inline parts (EmailAttachment.ContentId), because
data: URIs are stripped by classic Outlook and remote images sit behind
"Download pictures".
Two traps worth naming, both silent and both fixed here: HtmlAgilityPack caches a
node's rendered markup, so `attribute.Value = x` mutates the DOM without changing
the serialized output (use SetAttributeValue); and on Unix
Uri.TryCreate("/Some/Page", UriKind.Absolute) succeeds as an implicit file: URI, so
the obvious "is it already absolute" guard ships root-relative links dead — while
returning false on Windows, hiding it.
Localized en+de in the Hub catalogs and the byte-identical react vendored copies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an email-first export path that sends a document as email-safe HTML (optionally as the message body), including server-side resolution of embedded layout areas that previously exported as literal @@(...) text or blank placeholders.
Changes:
- Introduces
ExportFormat.Htmland an embeddedExportHtml.csxtemplate to render “email-safe” HTML with layout-area embeds resolved. - Adds an email HTML pipeline (area resolution, sanitization, table sizing, image
cid:inlining) and delivery selection (AttachmentvsEmailBody). - Updates UI + localization (menu label, send dialog, export dialog hint/button) and adds end-to-end tests + docs/What’s New.
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Monolith.Test/EmailDocumentExportTest.cs | End-to-end tests for emailing a document as HTML body, including embed resolution and mail-safety constraints. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.en.json | Adds/updates English UI strings for “Email this document” and export/email dialogs. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.de.json | Adds/updates German UI strings for the new email/export UX. |
| src/MeshWeaver.Mesh.Contract/EmailAttachment.cs | Extends attachments to support inline ContentId parts (cid:) with IsInline. |
| src/MeshWeaver.Markdown.Export/Templates/ExportHtml.csx | New export script producing email-safe HTML, including embed resolution via the email composer. |
| src/MeshWeaver.Markdown.Export/MeshWeaver.Markdown.Export.csproj | Adds HtmlAgilityPack and embeds the new HTML export template resource. |
| src/MeshWeaver.Markdown.Export/MarkdownExportTemplates.cs | Registers the HTML export template node alongside PDF/DOCX. |
| src/MeshWeaver.Markdown.Export/Layout/SendDocumentLayoutArea.cs | Updates send-to-contacts dialog to support body-vs-attachment delivery and localization. |
| src/MeshWeaver.Markdown.Export/Layout/MarkdownExportMenuProvider.cs | Positions “Email this document” alongside export menu items. |
| src/MeshWeaver.Markdown.Export/Layout/ExportDocumentLayoutArea.cs | Adds an HTML export layout area route and defaulting behavior. |
| src/MeshWeaver.Markdown.Export/Handlers/SendDocumentDispatch.cs | Adds DocumentDelivery option; supports embedding HTML as body with image inlining. |
| src/MeshWeaver.Markdown.Export/Handlers/ExportDocumentHandler.cs | Wires ExportFormat.Html to the correct template. |
| src/MeshWeaver.Markdown.Export/Email/EmailTableSizer.cs | Adds Word-friendly per-cell table width sizing for markdown tables. |
| src/MeshWeaver.Markdown.Export/Email/EmailStyles.cs | Defines inline-CSS tokens used by email renderer output. |
| src/MeshWeaver.Markdown.Export/Email/EmailNode.cs | Provides a small HTML node model for safe serialization (escaping centralized). |
| src/MeshWeaver.Markdown.Export/Email/EmailImageInliner.cs | Converts embedded images into cid: inline parts; reports remote images. |
| src/MeshWeaver.Markdown.Export/Email/EmailHtmlSanitizer.cs | Sanitizes rendered HTML for email clients and absolutizes URLs safely. |
| src/MeshWeaver.Markdown.Export/Email/EmailHtmlOptions.cs | Config knobs for email HTML rendering (base URL, settle/timeout, layout sizes). |
| src/MeshWeaver.Markdown.Export/Email/EmailDocumentComposer.cs | Orchestrates markdown render → area resolve → sanitize → wrap as email-safe document. |
| src/MeshWeaver.Markdown.Export/Email/EmailControlRenderer.cs | Renders settled layout-area control trees into static email HTML markup. |
| src/MeshWeaver.Markdown.Export/Email/EmailAreaResolver.cs | Finds layout-area placeholders and replaces them with rendered static markup. |
| src/MeshWeaver.Markdown.Export/Configuration/MarkdownExportExtensions.cs | Registers the HTML export layout view in the markdown export module. |
| src/MeshWeaver.Markdown.Export/Configuration/ExportFormat.cs | Adds Html format with documentation about email-safe constraints and embed resolution. |
| src/MeshWeaver.Markdown.Export/Configuration/DocumentExportOptions.cs | Adds BaseUrl for absolutizing links/images in HTML email exports. |
| src/MeshWeaver.Markdown.Export/Configuration/DocumentDelivery.cs | New enum for Attachment vs EmailBody delivery semantics. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-11-email-a-document-instead-of-attaching-it.md | What’s New entry for the feature. |
| src/MeshWeaver.Documentation/Data/Architecture/SendingEmail.md | Documents the new “send as body” pipeline, constraints, and embed resolution. |
| src/MeshWeaver.Blazor/Components/ExportDocumentView.razor | Updates export dialog for HTML (hint + hides non-applicable paged options); localizes button label. |
| memex/Memex.Portal.Shared/Email/GraphEmailSender.cs | Maps ContentId/IsInline onto Microsoft Graph attachments for inline rendering. |
| clients/react/src/i18n/strings.en.json | Vendored React i18n strings kept in sync with hub localization. |
| clients/react/src/i18n/strings.de.json | Vendored React i18n strings kept in sync with hub localization. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| @@ -64,41 +74,63 @@ private static UiControl BuildSendForm(LayoutAreaHost host, string hubPath) | |||
| ["recipient"] = "", | |||
| ["email"] = "", | |||
| ["subject"] = $"Shared with you: {nodeName}", | |||
| if (delivery == DocumentDelivery.EmailBody) | ||
| { | ||
| var documentHtml = EmailDocumentComposer.PrependIntro( | ||
| Encoding.UTF8.GetString(rendered.Content), htmlBody); |
| MarkdownControl markdown => | ||
| Observable.Return(new Rendered( | ||
| markdown, | ||
| EmailNode.Raw(Markdig.Markdown.ToHtml(AsText(markdown.Markdown))))), | ||
|
|
| return placeholders | ||
| .ToObservable() | ||
| .SelectMany(placeholder => RenderPlaceholder(placeholder, hub, options, caller, logger) | ||
| .Select(markup => (placeholder, markup))) | ||
| .ToList() |
Test Results (shard 1)1 291 tests +56 1 291 ✅ +56 5m 57s ⏱️ -31s Results for commit f931149. ± Comparison against base commit eb283c4. This pull request removes 161 and adds 217 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 2)2 037 tests - 52 2 035 ✅ - 52 7m 39s ⏱️ +33s Results for commit f931149. ± Comparison against base commit eb283c4. This pull request removes 213 and adds 161 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
…rtal
Sharing a document is a PERSONAL act, so the mail now goes out from the person's own
mailbox using their delegated personal-assistant credential (EaCredential, Graph
/me/sendMail). The recipient sees the person, replies come back to them naturally,
and the message lands in their own Sent Items. Sending a client-facing proposal from
a generic portal address is exactly the surprise this prevents.
No new consent step is needed: EaGraphAuth.Scopes ALREADY requests delegated
Mail.Send alongside Mail.ReadWrite/Calendars.ReadWrite, so connecting the personal
assistant grants it. "Connected" and "can send as themselves" are the same condition.
The identity is never implicit:
- IEmailSender gains SendEmail(..., EmailDelivery) and CanSendAsUser(objectId),
both DEFAULT-implemented so existing senders (no-op, SMTP, test doubles) compile
and behave unchanged.
- The dialog PROBES CanSendAsUser before composing and states the From line.
- Not connected ⇒ no silent fallback. It offers /auth/ea/connect; the shared
mailbox is only ever an explicitly chosen second option, and then Reply-To is set
to the user so a reply still reaches a human.
- Submit re-probes at send time — a credential can lapse between opening the dialog
and pressing send — and stops rather than substituting a different sender.
- A failed delegated send surfaces; it is NOT downgraded to a shared-mailbox send.
Graph is queried LIVE and never replicated into the mesh: it is the system of record
and always current, so a mirror would buy nothing while leaving personal
correspondence at rest in the mesh. The mesh may hold at most an InternetMessageId /
ConversationId reference. (The existing inbound mail→agent channel is a separate,
deliberate path and is untouched.)
Also ships the /share-email Skill so an agent can drive the capability and inherits
the hard-won constraints: the identity rules, the Word-engine HTML rules, the
live-not-replicated rule, and the honest statement that PDF/DOCX export still does
NOT render embedded layout areas. Registered in both expected-id test literals
(BuiltInSkillCatalog is not on main yet); a malformed skill file would be silently
skipped, so the parse is covered by the passing catalog tests.
Menu entry renamed to the user's own wording, "Share ⇒ as email", en+de, with the
byte-identical react vendored catalogs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetDiagnostics_BrokenNodeType_ReturnsErrorStatus asserted "Error" from a SINGLE GetDiagnostics read taken right after forcing compilation. "Compiling" is a legitimate intermediate state — Roslyn runs off the calling thread and the Error status is written when it finishes — so the assert is a race the test loses whenever the machine is loaded. CI shard 3 saw exactly that: "Expected Error … but found Compiling", with the CS0246 for ThisTypeDoesNotExist logged moments later, 10.7s into the test. It passes locally (1147-test assembly, DOTNET_PROCESSOR_COUNT=4) because a faster machine finishes the compile before the read — which is what made it a CI-only failure rather than an obvious one. Fixed by waiting on the actual condition: poll GetDiagnostics until the status is terminal, bounded by a Timeout. Polling rather than a stream because GetDiagnostics is request/response — the shape WritingTests.md prescribes for exactly this case. The status probe swallows a malformed payload so a transient response keeps the poll going instead of faulting it. Not a widened threshold and not a sleep: the assertion is unchanged and still requires Error: only the "when do we read it" bug is fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exception said the Chromium path merely lacked cover pages/TOC/headers. The harder fact is that Chromium is not in the deployed base image at all (deploy/base-images/portal-ai/Dockerfile has no browser package - verified), so removing QuestPDF today would leave the portal with NO PDF renderer rather than a less capable one. Shipping Chromium in the base image is a deployment decision with size/attack-surface/build-time consequences and needs to be made on its own merits, not as a side effect of a licence cleanup. Records the sequencing (#1222 -> #1234 -> base-image decision -> removal) and dates the entry, so the exception reads as tracked debt with a blocker rather than an approval that quietly became permanent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…84d00b1e521 # Conflicts: # test/MeshWeaver.AI.Test/SkillHarnessImportSourceTest.cs # test/MeshWeaver.AI.Test/SkillNodeTypeTest.cs
… the diff The built-in-catalog assertion compares two 21-entry collections, and the collection assertion truncates BOTH sides after ten entries. A one-skill difference therefore prints two identical-looking prefixes ending in "…" — in the CI log AND in the trx — so the failure reports only that two collections differ, which is the information you already had. Diagnosing a CI-only mismatch from that is impossible: the artifacts cannot tell you which id is missing. AssertMatches now reports the set difference by name (missing / unexpected / both counts / the full shipped set) and points at the mechanism — a skill ships by its FILE via the glob in MeshWeaver.AI.csproj — so the next occurrence is a one-line diagnosis rather than an archaeology session. Also compares ORDINALLY. The previous OrderBy(x => x) is culture-sensitive, and a culture-sensitive sort treats the hyphen in og-card / share-email / pull-request as ignorable punctuation, which makes the expected ORDER depend on the machine's locale. (Verified not to be the current failure — the ids sort identically both ways and under DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 — but a latent locale dependency in a shared expectation is worth removing while here.) The assertion is not weakened: exact set and exact order are still required. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…84d00b1e521 # Conflicts: # test/MeshWeaver.AI.Test/BuiltInSkillCatalog.cs # test/MeshWeaver.AI.Test/SkillHarnessImportSourceTest.cs # test/MeshWeaver.AI.Test/SkillNodeTypeTest.cs
Conflict resolution: accept main's deletion of BuiltInSkillCatalog.cs and its file-derived skill expectations (#1225) — the share-email id I had added to that literal is now derived from content/ai/Skill/*.md, so nothing to carry over.
Why
Asked to email a document, a user had two bad options: send a PDF attachment (the node menu's
send entry was hardcoded to
ExportFormat.Pdfwith a one-line covering note), or copy-paste — whichloses exactly the parts that matter. And whichever they chose, it went out from a generic portal
address.
The export also silently drops embedded views. A document can embed a live layout area with
@@("area:OgCard?urls=…"). Every export before this one lost it:@@(…)DocumentBuilderbuilds its own Markdig pipeline (UseAdvancedExtensions().UsePageBreaks()) withoutLayoutAreaMarkdownExtension, so the embed is never even recognised as a block.Share ⇒ as email
One node-menu entry, in the same group as Export to PDF / DOCX — the former "Send to contacts", grown
up rather than a second confusable item.
It comes from the person
Sharing is a personal act, so the mail goes out as the signed-in user via their delegated
personal-assistant credential (
EaCredential, Graph/me/sendMail). Recipient sees the person,replies come back to them,
SaveToSentItemsso it is in their own Sent Items.No new consent step:
EaGraphAuth.Scopesalready requests delegatedMail.Send(alongsideMail.ReadWrite,Calendars.ReadWrite,offline_access). Connecting the personal assistant grantsit — "connected" and "can send as themselves" are the same condition. Nothing to provision, no admin
consent needed.
The identity is never implicit:
IEmailSendergainsSendEmail(…, EmailDelivery)andCanSendAsUser(objectId)— bothdefault-implemented, so the no-op sender, SMTP senders and test doubles compile and behave
unchanged.
/auth/ea/connectas the primary action; theshared mailbox is only ever an explicitly chosen second option, and then Reply-To is set to the
user so replies still reach a human.
stops rather than substituting a different sender. A failed delegated send surfaces; it is not
downgraded.
Graph is queried live, never replicated
Graph is the system of record and always current; a mirror buys nothing and leaves personal
correspondence at rest in the mesh. The mesh holds at most an
InternetMessageId/ConversationIdreference. The existing inbound mail→agent channel is a separate, deliberate path — untouched.
Embedded areas are resolved server-side
EmailAreaResolveropens each area's synchronization stream — the same one the browser subscribesto, under the caller's
AccessContext— andEmailControlRendererserializes the settled controltree to static markup. No browser.
A live area has no completion signal (
OgCardemits placeholder cards and fills each in as itsfetch lands), so the tree is snapshotted once quiescent, or at the deadline. Taking the first
emission would export the placeholders.
Built for the Word engine
Outlook on Windows renders through Word. Every rule is a defect seen in a real inbox:
<td>/<th>widths — Word IGNORES<colgroup>;starve the short ones;
<svg>stripped — Word paints a broken-image box, so an omitted icon beats a broken one;cid:inline parts (EmailAttachment.ContentId+IsInline):data:URIs arestripped by classic Outlook and remote images sit behind "Download pictures".
Two silent traps fixed here
attribute.Value = xmutates the DOM but not theoutput. Use
SetAttributeValue. (Tell: table widths landed, link rewriting didn't.)Uri.TryCreate("/Some/Page", UriKind.Absolute)returns TRUE on Unix — an implicitfile:URI— so the obvious "already absolute?" guard ships root-relative links dead in the inbox, while
returning
falseon Windows and hiding it.EmailHtmlSanitizer.HasSchemeinspects the scheme.Skill
content/ai/Skill/share-email.mdships with the feature so an agent can drive it and inherits theconstraints: identity rules, the Word-engine HTML rules, live-not-replicated, and the honest
statement that PDF/DOCX still do not render layout areas. Registered in both expected-id test
literals (
BuiltInSkillCatalogis not on main yet).Tests
EmailDocumentExportTest— no mocking; assertions are on the mail a capturingIEmailSenderactually received (it implements the identity overload, so a sender that ignored
EmailDeliverywould fail):
OgCardarea arrives as a real card, neverlayout-area;<script>/<style>/<link>/<svg>/display:grid/flex;hrefabsolute (regex-asserted);No.column;data:image becomes acid:part, a remote image is reported;SendAsUserObjectId), and the shared-mailbox fallbackcarries Reply-To.
Green:
EmailDocumentExportTest,SendDocumentToContactsTest,SkillNodeTypeTest,SkillHarnessImportSourceTest,LocalizationTest,WhatsNewEntryIntegrityTest,DocumentationLinkIntegrityTest.Deployment note
Email is live on memex (
ns memex):Email__Enabled=true, Graph, shared mailboxmemex@systemorph.com. That mailbox is now the fallback, not the default.Follow-up
Graph contacts autocomplete (
GET /me/people?$search=, live, debounced, no replication) — samecredential, same consent, so it needs no new provisioning. Reply-into-an-existing-conversation
(
POST /me/messages/{id}/createReply, letting Graph supply the threading headers) is designed forin the Skill and comes next.
🤖 Generated with Claude Code