From 11cef7e3d2c10cffe189406fdb507871f3ecda95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Wed, 12 Aug 2026 12:02:19 +0200 Subject: [PATCH] fix: exports failed on a test-only API, and a half-written email was lost on connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in "Share ⇒ as email", all user-visible. 1) SENDING WAS BROKEN IN PRODUCTION. The three export templates are .csx — compiled at RUNTIME by the kernel, invisible to dotnet build — and they called meshService.QueryAsync(...), which exists ONLY in MeshWeaver.Fixture, a test-only assembly whose own doc comment says production must not use it. Every export threw CompilationErrorException CS1061 the moment a user pressed Send. Root cause of the GAP, not just the call: KernelScriptReferences built the Roslyn reference set from AppDomain.CurrentDomain.GetAssemblies(), and a test process always has MeshWeaver.Fixture loaded (MonolithMeshTestBase : Fixture.TestBase). Its extensions sit in namespace MeshWeaver.Mesh, which the kernel imports for every script. So script compilation in tests was strictly MORE permissive than in production, and eleven tests that genuinely execute these templates all passed. The reference set now excludes test scaffolding, so a script compiles in a test exactly as it does in production and those eleven tests become real gates. The five call sites move to the reactive Query Initial-snapshot shape. 2) A FILLED-IN FORM CAME BACK EMPTY FROM CONSENT. Connecting Microsoft 365 is a server-side endpoint, so the connect button must navigate with forceLoad, which tears down the Blazor circuit — and the compose state lived in the layout area's /data store, keyed by a Guid minted per render. Nothing was persisted, so there was nothing to come back to. The state is now a mesh node and the form binds directly to it via a $meshNode DataContext: TextField, MeshNodePicker, TextArea and RadioGroup are unchanged and route their writes through GetMeshNodeStream(path).Update, debounced, per field. One source of truth — no /data replica, no save subscription. The draft is filed under its AUTHOR ({userId}/_Draft/{documentKey}), not under the shared document. A satellite under the document CANNOT be made private: permissions are additive along the path and the Postgres listing folds on main_node, so every reader of the document would inherit read on everyone's drafts through four independent paths, and BreaksInheritance is not honoured by the SQL fold at all. A draft holds recipient addresses and a personal message, so it goes where the framework already makes per-user state private, alongside {userId}/_Settings and {userId}/Feedback. It routes to the ordinary mesh_nodes table, as _Settings does — a dedicated satellite table would need a migration across every existing partition and buys no query shape. Lifecycle: created once per (author, document) behind the permission gate, restored on return, reset after 7 days untouched so a stale recipient cannot resurface, dropped on successful send and on Cancel (the explicit discard). 3) THE CONNECT STEP CAME LAST. Being sent through consent only after filling the whole form is the expensive order. The mailbox state is probed when the dialog opens and the connect offer goes above the fields — an offer, not a gate. Also: recipient suggestions now render "First Last ", so similarly-named contacts can be told apart and the sender sees where the mail is going. Composing now requires a signed-in identity. AGENTS.md's data-access table and the /code skill both still recommended the dead QueryAsync — corrected. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- clients/react/src/i18n/strings.de.json | 3 + clients/react/src/i18n/strings.en.json | 3 + content/ai/Skill/code.md | 17 +- .../Components/MeshNodePickerView.razor | 38 +++- .../Components/MeshNodePickerView.razor.css | 9 + ...mail-is-no-longer-lost-when-you-connect.md | 33 +++ .../KernelScriptReferences.cs | 39 ++++ .../MeshScriptEnvironment.cs | 20 ++ .../Configuration/EmailDraftNodeType.cs | 196 ++++++++++++++++++ .../Configuration/MarkdownExportExtensions.cs | 5 + src/MeshWeaver.Markdown.Export/EmailDraft.cs | 52 +++++ .../Layout/SendDocumentLayoutArea.cs | 167 ++++++++++----- .../Templates/ExportDocx.csx | 33 ++- .../Templates/ExportHtml.csx | 62 +++--- .../Templates/ExportPdf.csx | 62 +++--- .../Localization/strings.de.json | 3 + .../Localization/strings.en.json | 3 + .../ExportTemplateCompilationTest.cs | 92 ++++++++ .../SendDocumentDraftTest.cs | 143 +++++++++++++ .../EmailDraftPrivacyTest.cs | 97 +++++++++ 21 files changed, 938 insertions(+), 141 deletions(-) create mode 100644 src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-a-half-written-email-is-no-longer-lost-when-you-connect.md create mode 100644 src/MeshWeaver.Markdown.Export/Configuration/EmailDraftNodeType.cs create mode 100644 src/MeshWeaver.Markdown.Export/EmailDraft.cs create mode 100644 test/MeshWeaver.Hosting.Monolith.Test/ExportTemplateCompilationTest.cs create mode 100644 test/MeshWeaver.Hosting.Monolith.Test/SendDocumentDraftTest.cs create mode 100644 test/MeshWeaver.Security.Test/EmailDraftPrivacyTest.cs diff --git a/AGENTS.md b/AGENTS.md index 791c11993a..b30938e2af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -612,7 +612,7 @@ Never use `IMeshStorage` or `IMeshCatalog` directly — internal infrastructure | Operation | API | |---|---| -| Read (query) | `IMeshService.QueryAsync(...)` | +| Read (query) | `IMeshService.Query(request)` — reactive. 🚨 There is **no** `QueryAsync` on the production interface; it survives only as a test-only bridge in `MeshWeaver.Fixture`. One-shot snapshot = `.Where(c => c.ChangeType == QueryChangeType.Initial).Select(c => c.Items).FirstAsync()` | | Read (single node) | `workspace.GetMeshNodeStream(path)` | | Create/Delete | `meshService.CreateNode(node).Subscribe(...)` / `meshService.DeleteNode(path).Subscribe(...)` | | Update | `workspace.GetMeshNodeStream(path).Update(current => current with { … })` | diff --git a/clients/react/src/i18n/strings.de.json b/clients/react/src/i18n/strings.de.json index 7a2aac11ca..e8d7fa872c 100644 --- a/clients/react/src/i18n/strings.de.json +++ b/clients/react/src/i18n/strings.de.json @@ -585,6 +585,9 @@ "ui.sendDocument.attachedNote": "Anbei finden Sie das Dokument.", "ui.sendDocument.fromLabel": "Von", "ui.sendDocument.connect": "Microsoft 365 verbinden", + "ui.sendDocument.connectFirst": "Ihr Microsoft-365-Postfach ist noch nicht verbunden. Verbinden Sie es jetzt, um von Ihrer eigenen Adresse zu senden — bereits Eingegebenes bleibt erhalten.", + "ui.sendDocument.signInTitle": "Zum Teilen per E-Mail anmelden", + "ui.sendDocument.signInBody": "Das Teilen eines Dokuments per E-Mail erfordert ein angemeldetes Konto, damit Antworten Sie erreichen und Ihr Entwurf privat gespeichert wird.", "ui.sendDocument.fromSharedTitle": "Aus dem gemeinsamen Postfach senden?", "ui.sendDocument.fromSharedBody": "Ihr Microsoft-365-Postfach ist nicht verbunden, daher würde dies aus dem gemeinsamen Portal-Postfach statt von Ihrer eigenen Adresse gesendet.", "ui.sendDocument.sendFromShared": "Aus dem gemeinsamen Postfach senden", diff --git a/clients/react/src/i18n/strings.en.json b/clients/react/src/i18n/strings.en.json index ae28dc2270..54eaf13f57 100644 --- a/clients/react/src/i18n/strings.en.json +++ b/clients/react/src/i18n/strings.en.json @@ -585,6 +585,9 @@ "ui.sendDocument.attachedNote": "Please find the attached document.", "ui.sendDocument.fromLabel": "From", "ui.sendDocument.connect": "Connect Microsoft 365", + "ui.sendDocument.connectFirst": "Your Microsoft 365 mailbox is not connected yet. Connect it now to send from your own address — anything you have already typed is kept.", + "ui.sendDocument.signInTitle": "Sign in to share by email", + "ui.sendDocument.signInBody": "Sharing a document by email needs a signed-in account, so replies reach you and your draft is kept privately.", "ui.sendDocument.fromSharedTitle": "Send from the shared mailbox?", "ui.sendDocument.fromSharedBody": "Your Microsoft 365 mailbox is not connected, so this would go out from the shared portal mailbox rather than your own address.", "ui.sendDocument.sendFromShared": "Send from the shared mailbox", diff --git a/content/ai/Skill/code.md b/content/ai/Skill/code.md index 88c893bfc6..f609d7a1b2 100644 --- a/content/ai/Skill/code.md +++ b/content/ai/Skill/code.md @@ -220,7 +220,22 @@ stream.Subscribe(async node => { await mesh.UpdateNode(node with { ... }); }); var node = await Mesh.ReadNodeAsync(path); // ← doesn't exist; don't invent it ``` -**Rule of thumb for Scripts:** read known paths one-shot via `Mesh.GetMeshNode(path, timeout).ToTask()`; wait for state changes via `workspace.GetMeshNodeStream(path).Where(predicate).Take(1).Timeout(...)`. Use `mesh.QueryAsync(...)` / `mesh.Query(...)` only for searching / listing / counting (sets, not specific node content). Reach for `QueryAsync(path:X)` and you've written a stale-read bug. +**Rule of thumb for Scripts:** read known paths one-shot via `Mesh.GetMeshNode(path, timeout).ToTask()`; wait for state changes via `workspace.GetMeshNodeStream(path).Where(predicate).Take(1).Timeout(...)`. Use `mesh.Query(...)` only for searching / listing / counting (sets, not specific node content). Reach for a query on `path:X` and you've written a stale-read bug. + +> 🚨 **`QueryAsync` does not exist on the production `IMeshService`.** It survives only as a +> test-only bridge in `MeshWeaver.Fixture`, so a script calling it compiles in a test process (which +> loads that assembly) and fails at runtime in the portal with `CS1061`. That is exactly how every +> PDF/DOCX/email export died in production on 2026-08-12. The script reference set now excludes test +> scaffolding, so this fails identically everywhere — but write the reactive shape: +> +> ```csharp +> var items = await meshService +> .Query(MeshQueryRequest.FromQuery("path:" + p + " scope:descendants")) +> .Where(c => c.ChangeType == QueryChangeType.Initial) // filter on SHAPE, never a count +> .Select(c => c.Items) +> .FirstAsync() +> .ToTask(Ct); +> ``` # Decision Rule: NodeType vs Markdown diff --git a/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor b/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor index 4f982d417c..86be3203bd 100644 --- a/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor +++ b/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor @@ -16,7 +16,14 @@ @{ var selInitial = GetInitial(_selectedNode); } @RenderAvatar(_selectedNode, selInitial)
-
@(_selectedNode.Name ?? _selectedNode.Id)
+
+ @(_selectedNode.Name ?? _selectedNode.Id) + @{ var selEmail = GetEmail(_selectedNode); } + @if (!string.IsNullOrEmpty(selEmail)) + { + <@selEmail> + } +
@if (!string.IsNullOrEmpty(_selectedNode.Category)) { @@ -81,7 +88,14 @@ @onclick="() => SelectNode(node)"> @RenderAvatar(node, initial)
-
@(node.Name ?? node.Id)
+
+ @(node.Name ?? node.Id) + @{ var itemEmail = GetEmail(node); } + @if (!string.IsNullOrEmpty(itemEmail)) + { + <@itemEmail> + } +
@if (!string.IsNullOrEmpty(node.Category)) { @@ -101,6 +115,26 @@ return !string.IsNullOrEmpty(name) ? name[0].ToString().ToUpper() : "?"; } + /// + /// The node's email address, when it has one. Two similarly-named contacts are otherwise + /// indistinguishable in the dropdown, and someone picking a recipient for a mail wants to see + /// the address the mail will actually go to — so a suggestion reads "First Last <a@b.com>". + /// Probed off the raw content the way the avatar is: the picker's query carries no `select:`, + /// so content comes back whole. + /// + private static string? GetEmail(MeshNode node) + { + if (node.Content is System.Text.Json.JsonElement json + && json.ValueKind == System.Text.Json.JsonValueKind.Object) + { + if (json.TryGetProperty("email", out var e) && e.ValueKind == System.Text.Json.JsonValueKind.String) + return e.GetString(); + if (json.TryGetProperty("Email", out var E) && E.ValueKind == System.Text.Json.JsonValueKind.String) + return E.GetString(); + } + return null; + } + private string? GetImageUrl(MeshNode node) { if (node.Content is System.Text.Json.JsonElement json) diff --git a/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css b/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css index f63baf3c5b..6a13db130e 100644 --- a/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css +++ b/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css @@ -242,6 +242,15 @@ color: var(--neutral-foreground-hint, #666); } +/* The address beside a contact's name — "First Last ". Subordinate to the name (lighter, + regular weight) so the row still scans by name, but present so two similarly-named people can be + told apart and the sender can see exactly where the mail is going. */ +.mesh-node-picker-email { + font-weight: 400; + color: var(--neutral-foreground-hint, #666); + margin-left: 4px; +} + /* Open-upward dropdown — for bottom-anchored fields (e.g. the chat composer). Bound the height to the space available ABOVE the field so it doesn't grow up over the panel header (the chat-composer overlap). Whichever is smaller wins: the 300px cap or ~half the viewport. */ diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-a-half-written-email-is-no-longer-lost-when-you-connect.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-a-half-written-email-is-no-longer-lost-when-you-connect.md new file mode 100644 index 0000000000..873ad4b857 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-a-half-written-email-is-no-longer-lost-when-you-connect.md @@ -0,0 +1,33 @@ +--- +Name: A half-written email is no longer lost when you connect +Category: Fix +Description: Filling in a share-by-email form and then connecting Microsoft 365 emptied the form. Your draft is now kept, and sending works again. +Icon: Sparkle +Order: -20260813 +--- + +# A half-written email is no longer lost when you connect + +Sharing a document by email asked for the recipient, the subject and a message — and then, if your +Microsoft 365 mailbox was not connected yet, offered to connect it. Coming back from that sign-in, +the form was empty. Everything typed was gone, with no way to get it back. + +Connecting has to leave the portal for Microsoft's sign-in page, and until now the half-written mail +only existed in the page you left. Now it is kept for you: what you type is saved as you go, and the +form fills itself back in when you return. Closing the tab by accident no longer costs you the +message either. + +Two things changed alongside it, both about the same moment. If your mailbox is not connected, the +dialog says so **before** you start writing rather than after you press Send — so you are not asked +to authenticate at the point where it costs you the most. And the recipient list now shows each +person's email address next to their name, so two colleagues with similar names can be told apart +and you can see exactly where the mail is going. + +Your draft is private to you: it is stored in your own space, so colleagues who can read the +document cannot read what you were writing about it. It is cleared once the mail is sent, when you +cancel, and after a week untouched — an old recipient will not resurface weeks later. + +**Sending itself was also broken and is fixed.** Exporting or emailing a document failed outright +with a script error: the built-in export templates called a function that no longer exists outside +of our test suite. These templates are compiled while the portal runs, so no build could catch it — +they are now checked against the real portal on every change, which is what let this reach you. diff --git a/src/MeshWeaver.Kernel.Hub/KernelScriptReferences.cs b/src/MeshWeaver.Kernel.Hub/KernelScriptReferences.cs index 00a1ef87e4..f33709792c 100644 --- a/src/MeshWeaver.Kernel.Hub/KernelScriptReferences.cs +++ b/src/MeshWeaver.Kernel.Hub/KernelScriptReferences.cs @@ -58,12 +58,48 @@ internal static class KernelScriptReferences private static ImmutableArray CreateSharedSnapshot() => AppDomain.CurrentDomain.GetAssemblies() + .Where(IsProductionReferenceable) .Select(TryGetOrCreate) .Where(r => r is not null) .Select(r => r!) .Distinct() .ToImmutableArray(); + /// + /// 🚨 The script reference set must be the set a PRODUCTION portal would have — + /// never "whatever this process happens to have loaded". + /// + /// The defect this closes (2026-08-12, exports dead in prod): the + /// reference set is built from , and a TEST + /// process always has MeshWeaver.Fixture loaded (MonolithMeshTestBase + /// derives from Fixture.TestBase). That assembly declares, in namespace + /// MeshWeaver.Mesh — which puts + /// in scope for every script — the test-only IMeshService.QueryAsync<T> + /// bridge whose own doc comment says "Production code MUST NOT use these". + /// So the three export .csx templates bound to it, compiled green in ELEVEN + /// tests that genuinely execute them, and threw CS1061 on the first real export in + /// production. Test compilation was strictly MORE permissive than production, which + /// makes those tests unable to gate the thing they exist to gate. + /// + /// Excluding test scaffolding here makes script compilation in a test process + /// IDENTICAL to production, so every existing script test becomes a real gate for + /// dead-API references. This enforces a contract the Fixture already states about + /// itself; it is not a heuristic about what scripts "should" use. + /// + /// Note this bounds only the script reference set. Test assemblies keep + /// their ordinary project references, so test C# still calls the Fixture bridges + /// normally — it is only script text that loses the production-shadowing surface. + /// + private static bool IsProductionReferenceable(Assembly asm) + { + var name = asm.GetName().Name; + if (string.IsNullOrEmpty(name)) return true; + return !IsTestScaffolding(name); + } + + private static bool IsTestScaffolding(string assemblyName) + => MeshScriptEnvironment.IsTestScaffolding(assemblyName); + /// /// The shared reference for , or null when the assembly /// cannot be referenced (dynamic, byte-loaded, or its file was deleted — @@ -131,6 +167,9 @@ public static ImmutableArray GetReferences(IEnumerable SessionAssemblies(IServiceProvider s /// public static ImmutableArray References(IServiceProvider serviceProvider) => KernelScriptReferences.GetReferences(SessionAssemblies(serviceProvider)); + + /// + /// Whether an assembly is TEST SCAFFOLDING and must therefore be kept out of the script + /// reference set — identified by the naming convention this repo uses: the shared fixture, the + /// per-host test bases, and the test assemblies themselves. None exists in a deployed portal. + /// + /// 🚨 This is what keeps script compilation in a test IDENTICAL to production. The + /// reference set is built from the assemblies loaded in the process, and a test process always + /// has MeshWeaver.Fixture loaded — whose test-only IMeshService.QueryAsync lives + /// in the very namespace scripts import. Without this filter, tests compile scripts against APIs + /// production does not have: the export templates bound that method, passed eleven tests that + /// really executed them, and threw CS1061 on the first export a user attempted. + /// + /// The simple assembly name (no extension). + /// True when the assembly is test-only. + public static bool IsTestScaffolding(string assemblyName) + => assemblyName.Equals("MeshWeaver.Fixture", StringComparison.Ordinal) + || assemblyName.EndsWith(".TestBase", StringComparison.Ordinal) + || assemblyName.EndsWith(".Test", StringComparison.Ordinal) + || assemblyName.EndsWith(".Tests", StringComparison.Ordinal); } diff --git a/src/MeshWeaver.Markdown.Export/Configuration/EmailDraftNodeType.cs b/src/MeshWeaver.Markdown.Export/Configuration/EmailDraftNodeType.cs new file mode 100644 index 0000000000..991d8e6116 --- /dev/null +++ b/src/MeshWeaver.Markdown.Export/Configuration/EmailDraftNodeType.cs @@ -0,0 +1,196 @@ +using System.Reactive; +using System.Reactive.Linq; +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.Markdown.Export.Configuration; + +/// +/// The EmailDraft node type — a per-user, per-document draft of the "Share ⇒ as email" +/// compose form. +/// +/// 🚨 Where the draft lives, and why it is NOT under the document. The obvious place is +/// a satellite under the document being shared ({doc}/_Draft/…). That placement CANNOT be made +/// private, and a draft holds recipient addresses and a personal message. Access in this framework is +/// additive along the path and is folded in SQL on main_node: a satellite whose main node is +/// the document is readable by everyone who can read the document, through four independent read +/// paths (PermissionEvaluator's scope walk, the MeshNodeStreamCache gate, +/// SatelliteAccessRule, and the user_effective_permissions fold in +/// PostgreSqlSqlGenerator). No per-node owner flag, deny assignment or "private" visibility +/// exists to override that, and PartitionAccessPolicy.BreaksInheritance — the nearest thing — +/// is not honoured by the SQL fold at all, so listing/search would still leak. +/// ThreadComposerNodeType.PathForNode is the live precedent of that leak: one user's chat +/// draft under a shared node is readable today by any other reader of that node. +/// +/// So the draft is anchored under its AUTHOR — {userId}/_Draft/{documentKey} — +/// which is the framework's actual mechanism for per-user private state, shared by +/// {userId}/_Settings/Notifications, {userId}/_UserActivity/{key} and +/// {userId}/Feedback/{id}. Privacy is then structural rather than asserted: the self-scope +/// owner rule grants a user Admin at the scope equal to their own id, and no other regular user +/// holds any grant on that partition, so a draft is closed by default on every read path including +/// SQL listing. It is still a satellite anchored to a real owner — never a top-level node — and it +/// still names the document it belongs to, both in its path key and in +/// . +/// +/// Storage table: _Draft is deliberately NOT registered in +/// SatelliteTableMapping.Defaults, so it routes to the partition's ordinary +/// mesh_nodes table — exactly as the _Settings segment does. That is the right answer +/// here: drafts are few, per-user, and only ever read by exact path, so a dedicated satellite table +/// buys no query shape — while adding one WOULD require a schema migration across every already +/// provisioned partition (the V26_AddNotificationsSatelliteTable shape), since partition +/// provisioning is promise-cached and never re-run for existing partitions. +/// +public static class EmailDraftNodeType +{ + /// The NodeType value identifying email-draft nodes. + public const string NodeType = "EmailDraft"; + + /// The per-user satellite segment holding compose drafts. + public const string DraftSegment = "_Draft"; + + /// + /// How long an untouched draft is still offered back to its author. Past this, opening the + /// dialog starts from a clean form instead of resurrecting a months-old recipient — the draft + /// exists to survive a consent round trip and a closed tab, not to become a permanent shadow + /// copy of a mail nobody sent. Made explicit rather than implicit, per the lifecycle contract. + /// + public static TimeSpan MaxAge => TimeSpan.FromDays(7); + + /// Registers the built-in "EmailDraft" MeshNode on the mesh builder. + public static TBuilder AddEmailDraftType(this TBuilder builder) where TBuilder : MeshBuilder + { + builder.AddMeshNodes(CreateMeshNode()); + builder.AddAutocompleteExcludedTypes(NodeType); + builder.ConfigureHub(config => config.WithType(nameof(EmailDraft))); + return builder; + } + + /// Creates the MeshNode definition for the EmailDraft node type. + public static MeshNode CreateMeshNode() => new(NodeType) + { + Name = "Email Draft", + IsSatelliteType = true, + // A half-written mail is not content: it must never surface in search, in the + // create menu, or as agent context. + ExcludeFromContext = new HashSet { "search", "create" }, + HubConfiguration = config => config + .AddMeshDataSource(source => source + .WithContentType()) + }; + + /// The namespace holding one user's drafts: {userId}/_Draft. + public static string NamespaceFor(string userId) => $"{userId}/{DraftSegment}"; + + /// + /// The draft id for a document — its path with separators folded, the same encoding + /// _UserActivity uses to key a per-user satellite by node path. Deterministic, so + /// reopening the dialog for the same document reuses ONE node rather than accumulating. + /// + public static string DraftIdFor(string documentPath) => documentPath.Replace("/", "_"); + + /// The full draft path for (, ). + public static string PathFor(string userId, string documentPath) => + $"{NamespaceFor(userId)}/{DraftIdFor(documentPath)}"; + + /// + /// Create-on-absent (idempotent, reactive) of this user's draft for this document, emitting the + /// draft node path once it is bindable. + /// + /// Existence is read through GetQuery (empty-on-absent) rather than a point + /// GetMeshNodeStream probe of a maybe-absent path — probing an absent path is what + /// NotFound-storms a hub. + /// + /// An existing draft is returned untouched when it is still fresh, and RESET to a clean + /// form when it is older than , so a stale recipient can never resurface + /// weeks later. + /// + /// The hub whose service provider resolves the mesh service and workspace. + /// The author — the partition the draft is filed under. + /// The document the mail is about. + /// The clean form to seed (and to reset a stale draft to). + /// Optional logger. + /// A cold observable emitting the draft node path. Subscribe to run it. + public static IObservable EnsureExists( + IMessageHub hub, + string userId, + string documentPath, + EmailDraft defaults, + ILogger? logger = null) + { + var path = PathFor(userId, documentPath); + var meshService = hub.ServiceProvider.GetService(); + if (meshService is null || string.IsNullOrEmpty(userId)) + return Observable.Return(path); + + MeshNode BuildNode() => new(DraftIdFor(documentPath), NamespaceFor(userId)) + { + NodeType = NodeType, + Name = $"Email draft: {documentPath}", + State = MeshNodeState.Active, + Content = defaults with { DocumentPath = documentPath }, + }; + + return hub.GetWorkspace() + .GetQuery($"{NodeType}|{path}", + $"path:{path} nodeType:{NodeType} select:path,id,namespace,name,nodeType,content,lastModified") + .Take(1) + .SelectMany(nodes => + { + var existing = nodes.FirstOrDefault(n => + string.Equals(n.NodeType, NodeType, StringComparison.OrdinalIgnoreCase)); + if (existing is null) + { + logger?.LogInformation("Seeding email draft for {User} on {Document}", userId, documentPath); + return meshService.CreateNode(BuildNode()) + .Select(_ => path) + // Idempotent: a concurrent first-writer won the create race. + .Catch(ex => IsAlreadyExists(ex) + ? Observable.Return(path) + : Observable.Throw(ex)); + } + + if (existing.LastModified != default + && DateTimeOffset.UtcNow - existing.LastModified <= MaxAge) + return Observable.Return(path); + + logger?.LogInformation( + "Resetting stale email draft for {User} on {Document} (last touched {LastModified})", + userId, documentPath, existing.LastModified); + return hub.GetWorkspace().GetMeshNodeStream(path) + .Update(node => node with { Content = defaults with { DocumentPath = documentPath } }) + .Select(_ => path); + }); + } + + /// + /// Drops the draft — after a successful send, or when the author explicitly discards it. A + /// missing node is success: discarding twice, or discarding a draft the send already removed, + /// is not an error. + /// + /// The hub whose service provider resolves the mesh service. + /// The draft node path. + /// A cold observable completing when the draft is gone. Subscribe to run it. + public static IObservable Discard(IMessageHub hub, string draftPath) + { + var meshService = hub.ServiceProvider.GetService(); + if (meshService is null || string.IsNullOrEmpty(draftPath)) + return Observable.Return(Unit.Default); + + return meshService.DeleteNode(draftPath) + .Select(_ => Unit.Default) + .Catch(ex => IsNotFound(ex) + ? Observable.Return(Unit.Default) + : Observable.Throw(ex)); + } + + private static bool IsAlreadyExists(Exception ex) => + ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase); + + private static bool IsNotFound(Exception ex) => + ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/MeshWeaver.Markdown.Export/Configuration/MarkdownExportExtensions.cs b/src/MeshWeaver.Markdown.Export/Configuration/MarkdownExportExtensions.cs index 036e2d7125..5a6f822664 100644 --- a/src/MeshWeaver.Markdown.Export/Configuration/MarkdownExportExtensions.cs +++ b/src/MeshWeaver.Markdown.Export/Configuration/MarkdownExportExtensions.cs @@ -116,6 +116,11 @@ public static TBuilder AddMarkdownExport( // grant, and either call alone must land it exactly once. builder.AddMeshNodesIfAbsent(ScriptTemplates.PublicExecuteGrant()); + // The per-user compose draft that makes the share dialog survive a full page navigation + // (the Microsoft 365 consent round trip). See EmailDraftNodeType for why it is filed under + // the AUTHOR rather than under the document. + builder.AddEmailDraftType(); + // Menu items, layout views, and the export request handler must live on the // node hubs (one per Markdown node) — that's where layout rendering runs and where // the user's click navigates. Registering on the mesh hub via ConfigureHub would diff --git a/src/MeshWeaver.Markdown.Export/EmailDraft.cs b/src/MeshWeaver.Markdown.Export/EmailDraft.cs new file mode 100644 index 0000000000..3db5c3376e --- /dev/null +++ b/src/MeshWeaver.Markdown.Export/EmailDraft.cs @@ -0,0 +1,52 @@ +using System.ComponentModel; + +namespace MeshWeaver.Markdown.Export; + +/// +/// A half-written "Share ⇒ as email" compose form, persisted as a mesh node so that leaving the +/// page cannot destroy it. +/// +/// Why this type exists. The compose form used to live only in the layout area's +/// /data store — i.e. in Blazor circuit memory. Connecting Microsoft 365 is a server-side +/// endpoint, so the connect button navigates with forceLoad: true; that tears the circuit +/// down, and the user came back from consent to a form that had silently emptied itself. Persisting +/// the in-progress state as a node makes the round trip irrelevant: the form re-reads its own +/// state, because the state was never in the circuit to begin with. +/// +/// The field names are the form's binding contract. Each property is addressed by the +/// matching camelCase JsonPointerReference in +/// SendDocumentLayoutArea.BuildSendForm; renaming one silently unbinds that field. +/// +public record EmailDraft +{ + /// The raw recipient address — the primary field of the form. + [Description("Recipient email address")] + public string Email { get; init; } = ""; + + /// PATH of a picked mesh User node, when the user picked one instead of typing + /// an address. The dispatcher resolves it to an address at send time. + [Description("Recipient mesh user")] + public string Recipient { get; init; } = ""; + + /// Mail subject. + [Description("Subject")] + public string Subject { get; init; } = ""; + + /// The personal message that precedes the document in the mail body. + [Description("Message")] + public string Message { get; init; } = ""; + + /// + /// How the document travels — body or attachment. Kept as the raw form value + /// rather than an enum so the existing RadioGroupControl option contract is unchanged. + /// + [Description("Delivery")] + public string Delivery { get; init; } = "body"; + + /// + /// The document this draft composes a mail for. Recorded for provenance and so an orphaned + /// draft can be traced back to its subject; the binding key is the node path itself. + /// + [Browsable(false)] + public string DocumentPath { get; init; } = ""; +} diff --git a/src/MeshWeaver.Markdown.Export/Layout/SendDocumentLayoutArea.cs b/src/MeshWeaver.Markdown.Export/Layout/SendDocumentLayoutArea.cs index 5ea8bae0eb..5f7df7a9f4 100644 --- a/src/MeshWeaver.Markdown.Export/Layout/SendDocumentLayoutArea.cs +++ b/src/MeshWeaver.Markdown.Export/Layout/SendDocumentLayoutArea.cs @@ -73,33 +73,55 @@ public static class SendDocumentLayoutArea { var hubPath = host.Hub.Address.ToString(); var nodeName = hubPath.Contains('/') ? hubPath[(hubPath.LastIndexOf('/') + 1)..] : hubPath; - - // Seed the form ONCE, outside the reactive projection. The capability probe below re-emits - // (false, then the real answer), so the form re-renders — and a fresh formId per render - // would wipe whatever the user had already typed. - var formId = $"send_document_form_{Guid.NewGuid().AsString()}"; - host.UpdateData(formId, NewForm(host, nodeName)); + var logger = Logger(host); var sender = Signed(host); - // Probed at paint time for ONE purpose: whether to show the "From: you" line. It never - // produces a connect button any more, so a false answer costs the user nothing — the form - // is fully usable either way and the identity question is put at send time instead. - var canSendAsUser = (string.IsNullOrEmpty(sender.ObjectId) - ? Observable.Return(false) - : host.Hub.CanSendAsUser(sender.ObjectId) - .Catch((Exception _) => Observable.Return(false))) + // Composing requires an identity — the draft is filed under the author's own partition + // (that is what makes it private), and a mail with no sender to reply to should not leave + // the shared mailbox in the first place. + if (string.IsNullOrEmpty(sender.ObjectId)) + return Observable.Return(Notice(host, + host.Localize("ui.sendDocument.signInTitle"), + host.Localize("ui.sendDocument.signInBody"))); + + // Whether this user's own mailbox is connected. Probed when the dialog OPENS, because the + // answer decides whether the user is asked to connect BEFORE composing — being sent through + // consent only after filling the whole form is what made the round trip so expensive. + var canSendAsUser = (host.Hub.CanSendAsUser(sender.ObjectId) + .Catch((Exception _) => Observable.Return(false))) .StartWith(false) .DistinctUntilChanged(); + // 🚨 The compose state is a NODE, not circuit memory. Connecting Microsoft 365 is a + // server-side endpoint, so the connect button must navigate with forceLoad — which tears + // down the Blazor circuit. Anything held in the layout area's /data store dies with it, + // which is exactly how a fully filled-in form used to come back empty from consent. + // Bound to the node stream, the form re-reads its own state and the round trip is a no-op. + // EnsureExists emits a single path and CombineLatest holds it across the capability probe's + // re-emissions — so the draft is created once per (author, document), never once per render. + // Gated BEHIND the permission check: someone who cannot read the document gets no draft node. + var draft = EmailDraftNodeType.EnsureExists( + host.Hub, sender.ObjectId, hubPath, NewDraft(host, nodeName), logger); + return host.Hub.CheckPermission(hubPath, Permission.Read) - .CombineLatest(canSendAsUser, (canRead, asUser) => canRead - ? (UiControl?)BuildSendForm(host, hubPath, nodeName, formId, asUser, sender) - : (UiControl?)Controls.Stack.WithWidth("100%").WithStyle("padding: 24px;") - .WithView(Controls.H2(host.Localize("error.accessDenied")).WithStyle("margin: 0 0 16px 0;")) - .WithView(Controls.Markdown(host.Localize("ui.noPermissionToSend")))); + .SelectMany(canRead => canRead + ? draft.CombineLatest(canSendAsUser, (draftPath, asUser) => + (UiControl?)BuildSendForm(host, hubPath, nodeName, draftPath, asUser, sender)) + : Observable.Return(Notice(host, + host.Localize("error.accessDenied"), + host.Localize("ui.noPermissionToSend")))); } + private static ILogger? Logger(LayoutAreaHost host) => + host.Hub.ServiceProvider.GetService() + ?.CreateLogger(typeof(SendDocumentLayoutArea).FullName!); + + private static UiControl Notice(LayoutAreaHost host, string title, string body) => + Controls.Stack.WithWidth("100%").WithStyle("padding: 24px;") + .WithView(Controls.H2(title).WithStyle("margin: 0 0 16px 0;")) + .WithView(Controls.Markdown(body)); + /// The signed-in person — who the mail should come from, and who replies must reach. private static (string ObjectId, string Email, string Name) Signed(LayoutAreaHost host) { @@ -108,36 +130,61 @@ private static (string ObjectId, string Email, string Name) Signed(LayoutAreaHos return (ctx?.ObjectId ?? string.Empty, ctx?.Email ?? string.Empty, ctx?.Name ?? string.Empty); } - private static Dictionary NewForm(LayoutAreaHost host, string nodeName) => + /// The clean form — what a first-time draft is seeded with, and what a stale one is + /// reset to. + private static EmailDraft NewDraft(LayoutAreaHost host, string nodeName) => new() { - ["recipient"] = "", - ["email"] = "", - ["subject"] = $"{host.Localize("ui.sendDocument.defaultSubject")}: {nodeName}", - ["message"] = host.Localize("ui.sendDocument.defaultMessage"), + Recipient = "", + Email = "", + Subject = $"{host.Localize("ui.sendDocument.defaultSubject")}: {nodeName}", + Message = host.Localize("ui.sendDocument.defaultMessage"), // Reading the document IN the message is the better default: nothing to open, and no // attachment for a mail gateway to strip. Attachment stays one click away. - ["delivery"] = DeliveryBody, + Delivery = DeliveryBody, }; private static UiControl BuildSendForm( - LayoutAreaHost host, string hubPath, string nodeName, string formId, + LayoutAreaHost host, string hubPath, string nodeName, string draftPath, bool canSendAsUser, (string ObjectId, string Email, string Name) sender) { - var dataContext = LayoutAreaReference.GetDataPointer(formId); + // 🚨 THE fix for the lost form: a node-bound DataContext. Every control below is unchanged + // — TextField, MeshNodePicker, TextArea and RadioGroup all route their writes through + // FormComponentBase → BlazorView.UpdatePointer → GetMeshNodeStream(path).Update, debounced, + // per field. One source of truth (the node stream), no /data replica, no save subscription. + var dataContext = LayoutAreaReference.GetMeshNodeDataContext(draftPath, bindContent: true); var stack = Controls.Stack.WithWidth("100%").WithStyle("padding: 24px; max-width: 640px;"); stack = stack.WithView(Controls.H2($"{host.Localize("ui.sendDocument.title")}: “{nodeName}”") .WithStyle("margin: 0 0 12px 0;")); - // The ONLY identity text before sending, and only when there IS something reassuring to say: - // this mail leaves from your address. Not connected ⇒ nothing here at all; the user gets to - // fill the form without being told about a mailbox they have not asked about yet. + // Connected ⇒ one reassuring line naming the address the mail leaves from. if (canSendAsUser && !string.IsNullOrEmpty(sender.Email)) stack = stack.WithView(Controls.Markdown( $"{host.Localize("ui.sendDocument.fromLabel")}: {sender.Email}") .WithStyle("margin: 0 0 16px 0; opacity: 0.7; font-size: 0.9em;")); + // NOT connected ⇒ the connect step goes FIRST, before the fields. Discovering the + // authentication requirement only after filling in recipient, subject and message is the + // expensive order, and it is the order that lost people's work. It stays an OFFER, not a + // gate: the form below is fully usable, and sending without connecting simply goes from the + // shared mailbox with a reply-to. Either way the draft is already on its node, so taking + // the round trip now costs nothing. + else if (!canSendAsUser && ConnectUrl(host) is { } connectUrl) + stack = stack.WithView(Controls.Stack + .WithWidth("100%") + .WithStyle("padding: 12px 16px; margin: 0 0 16px 0; border-radius: 6px; " + + "background: var(--neutral-layer-2);") + .WithView(Controls.Markdown(host.Localize("ui.sendDocument.connectFirst")) + .WithStyle("margin: 0 0 8px 0; font-size: 0.9em;")) + .WithView(Controls.Button(host.Localize("ui.sendDocument.connect")) + .WithAppearance(Appearance.Accent) + .WithClickAction((Action)(c => + // 🚨 forceLoad: this leaves the Blazor app for a server-side MVC endpoint. + // The circuit dies here — which is safe now only because the compose state + // lives on the draft node rather than in this circuit. + c.NavigateTo(connectUrl, forceLoad: true))))); + // PRIMARY recipient: a plain email address. This is what sharing a document normally means, // and it is the field the user reaches for first. stack = stack.WithView(new TextFieldControl(new JsonPointerReference("email")) @@ -196,29 +243,52 @@ private static UiControl BuildSendForm( .WithStyle("gap: 8px;") .WithView(Controls.Button(host.Localize("common.send")) .WithAppearance(Appearance.Accent) - .WithClickAction(actx => SubmitSend(actx, host, hubPath, formId))) + .WithClickAction(actx => SubmitSend(actx, host, hubPath, draftPath))) + // Cancel IS the discard affordance: it drops the draft and leaves. Abandoning the page + // any other way (closing the tab, navigating off, going through consent) deliberately + // KEEPS it — that is the whole point of persisting it. .WithView(Controls.Button(host.Localize("common.cancel")) .WithAppearance(Appearance.Neutral) - .WithNavigateToHref(MeshNodeLayoutAreas.BuildUrl(hubPath, MeshNodeLayoutAreas.OverviewArea)))); + .WithClickAction((Action)(c => + { + EmailDraftNodeType.Discard(host.Hub, draftPath) + .Subscribe(_ => { }, ex => Logger(host)?.LogWarning( + ex, "SendDocument: discarding draft {Path} failed", draftPath)); + c.NavigateTo(MeshNodeLayoutAreas.BuildUrl(hubPath, MeshNodeLayoutAreas.OverviewArea)); + })))); return stack; } - private static void SubmitSend(UiActionContext actx, LayoutAreaHost host, string hubPath, string formId) + /// + /// The consent URL for this deployment, with a return URL that lands back on THIS dialog — + /// or null when the deployment has no connect flow at all. + /// + private static string? ConnectUrl(LayoutAreaHost host) + { + var connectHref = host.Hub.ConnectAsUserHref(); + if (string.IsNullOrEmpty(connectHref)) + return null; + var returnUrl = MeshNodeLayoutAreas.BuildUrl(host.Hub.Address.ToString(), SendArea); + return $"{connectHref}?returnUrl={Uri.EscapeDataString(returnUrl)}"; + } + + private static void SubmitSend(UiActionContext actx, LayoutAreaHost host, string hubPath, string draftPath) { - var logger = host.Hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(SendDocumentLayoutArea).FullName!); + var logger = Logger(host); - actx.Host.Stream.GetDataStream>(formId) + // Read the state from the DRAFT NODE — the same single source of truth the form writes to. + // Not from a /data replica, which would not have survived a consent round trip. + host.Workspace.GetMeshNodeStream(draftPath) + .Where(n => n is not null) .Take(1) + .Select(n => n!.ContentAs(host.Hub.JsonSerializerOptions) ?? new EmailDraft()) .Subscribe(form => { - string Get(string key) => form.GetValueOrDefault(key)?.ToString()?.Trim() ?? ""; - - var recipient = Get("recipient"); - var email = Get("email"); - var subject = Get("subject"); - var message = Get("message"); + var recipient = form.Recipient?.Trim() ?? ""; + var email = form.Email?.Trim() ?? ""; + var subject = form.Subject?.Trim() ?? ""; + var message = form.Message?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(recipient) && string.IsNullOrWhiteSpace(email)) { @@ -235,7 +305,7 @@ private static void SubmitSend(UiActionContext actx, LayoutAreaHost host, string // Body delivery needs the email-safe HTML export — the only format that is inline // -CSS/table-based AND that resolves embedded layout areas. Attachment keeps PDF. - var asBody = !string.Equals(Get("delivery"), DeliveryAttachment, StringComparison.Ordinal); + var asBody = !string.Equals(form.Delivery, DeliveryAttachment, StringComparison.Ordinal); var delivery = asBody ? DocumentDelivery.EmailBody : DocumentDelivery.Attachment; var options = new DocumentExportOptions { @@ -275,9 +345,16 @@ void Dispatch(EmailDelivery identity) => result => { if (result.Success) + { + // The mail is out — the draft has served its purpose and must not + // resurface the next time this document is shared. + EmailDraftNodeType.Discard(host.Hub, draftPath) + .Subscribe(_ => { }, ex => logger?.LogWarning( + ex, "SendDocument: discarding sent draft {Path} failed", draftPath)); ShowDialog(actx, host.Localize("ui.sendDocument.sent"), $"**{hubPath.Split('/').Last()}** →\n\n" + string.Join("\n", result.SentTo.Select(r => $"- {r}"))); + } else ShowDialog(actx, host.Localize("ui.sendDocument.failed"), result.Error ?? host.Localize("ui.sendDocument.failedBody")); @@ -307,8 +384,6 @@ void Dispatch(EmailDelivery identity) => private static void AskWhichMailbox( UiActionContext actx, LayoutAreaHost host, string replyTo, Action sendFromShared) { - var connectHref = host.Hub.ConnectAsUserHref(); - var actions = Controls.Stack .WithOrientation(Orientation.Horizontal) .WithStyle("gap: 8px; flex-wrap: wrap;") @@ -324,10 +399,8 @@ private static void AskWhichMailbox( sendFromShared(); }))); - if (!string.IsNullOrEmpty(connectHref)) + if (ConnectUrl(host) is { } url) { - var returnUrl = MeshNodeLayoutAreas.BuildUrl(host.Hub.Address.ToString(), SendArea); - var url = $"{connectHref}?returnUrl={Uri.EscapeDataString(returnUrl)}"; actions = actions.WithView(Controls.Button(host.Localize("ui.sendDocument.connect")) .WithAppearance(Appearance.Accent) .WithClickAction((Action)(c => diff --git a/src/MeshWeaver.Markdown.Export/Templates/ExportDocx.csx b/src/MeshWeaver.Markdown.Export/Templates/ExportDocx.csx index 2ac806c7b0..b308e770b6 100644 --- a/src/MeshWeaver.Markdown.Export/Templates/ExportDocx.csx +++ b/src/MeshWeaver.Markdown.Export/Templates/ExportDocx.csx @@ -64,27 +64,24 @@ if (options.IncludeChildren) Log.LogInformation("Collecting descendants"); var meshService = Mesh.ServiceProvider.GetRequiredService(); var rootDepth = sourcePath.Count(c => c == '/'); - var enumerator = meshService - .QueryAsync("path:" + sourcePath + " scope:descendants") - .GetAsyncEnumerator(Ct); - try + // One-shot snapshot off the LIVE query surface: filter on the emission SHAPE + // (Initial), never on a count — a change feed can race the initial-snapshot path. + var descendants = await meshService + .Query(MeshQueryRequest.FromQuery("path:" + sourcePath + " scope:descendants")) + .Where(c => c.ChangeType == QueryChangeType.Initial) + .Select(c => c.Items) + .FirstAsync() + .ToTask(Ct); + foreach (var desc in descendants) { - while (await enumerator.MoveNextAsync()) + if (options.MaxDepth > 0) { - var desc = enumerator.Current; - if (options.MaxDepth > 0) - { - var depth = desc.Path.Count(c => c == '/') - rootDepth; - if (depth > options.MaxDepth) continue; - } - var md = ExtractMarkdown(desc); - if (!string.IsNullOrWhiteSpace(md)) - chapters.Add(new ExportChapter(desc.Name ?? desc.Id, md, desc.Path)); + var depth = desc.Path.Count(c => c == '/') - rootDepth; + if (depth > options.MaxDepth) continue; } - } - finally - { - await enumerator.DisposeAsync(); + var md = ExtractMarkdown(desc); + if (!string.IsNullOrWhiteSpace(md)) + chapters.Add(new ExportChapter(desc.Name ?? desc.Id, md, desc.Path)); } Log.LogInformation("Collected {Count} chapters", chapters.Count); } diff --git a/src/MeshWeaver.Markdown.Export/Templates/ExportHtml.csx b/src/MeshWeaver.Markdown.Export/Templates/ExportHtml.csx index 0a894d9650..a5d5768917 100644 --- a/src/MeshWeaver.Markdown.Export/Templates/ExportHtml.csx +++ b/src/MeshWeaver.Markdown.Export/Templates/ExportHtml.csx @@ -93,22 +93,17 @@ if (isDeck) else if (!string.IsNullOrWhiteSpace(query)) { var deckQueryService = Mesh.ServiceProvider.GetRequiredService(); - var matched = new List(); - var slideEnumerator = deckQueryService.QueryAsync(query).GetAsyncEnumerator(Ct); - try - { - while (await slideEnumerator.MoveNextAsync()) - { - var n = slideEnumerator.Current; - if (string.Equals(n.Path, sourcePath, StringComparison.Ordinal)) continue; - if (n.Segments.Skip(1).Any(s => s.StartsWith('_'))) continue; - matched.Add(n); - } - } - finally - { - await slideEnumerator.DisposeAsync(); - } + // One-shot snapshot off the LIVE query surface: filter on the emission SHAPE + // (Initial), never on a count — a change feed can race the initial-snapshot path. + var slideMatches = await deckQueryService + .Query(MeshQueryRequest.FromQuery(query)) + .Where(c => c.ChangeType == QueryChangeType.Initial) + .Select(c => c.Items) + .FirstAsync() + .ToTask(Ct); + var matched = slideMatches + .Where(n => !string.Equals(n.Path, sourcePath, StringComparison.Ordinal)) + .Where(n => !n.Segments.Skip(1).Any(s => s.StartsWith('_'))); slides = matched .OrderBy(n => n.Order ?? int.MaxValue) .ThenBy(n => n.Path, StringComparer.Ordinal) @@ -129,29 +124,24 @@ else if (options.IncludeChildren) Log.LogInformation("Collecting descendants"); var meshService = Mesh.ServiceProvider.GetRequiredService(); var rootDepth = sourcePath.Count(c => c == '/'); - var enumerator = meshService - .QueryAsync("path:" + sourcePath + " scope:descendants") - .GetAsyncEnumerator(Ct); - try + var descendants = await meshService + .Query(MeshQueryRequest.FromQuery("path:" + sourcePath + " scope:descendants")) + .Where(c => c.ChangeType == QueryChangeType.Initial) + .Select(c => c.Items) + .FirstAsync() + .ToTask(Ct); + foreach (var desc in descendants) { - while (await enumerator.MoveNextAsync()) + if (options.MaxDepth > 0) { - var desc = enumerator.Current; - if (options.MaxDepth > 0) - { - var depth = desc.Path.Count(c => c == '/') - rootDepth; - if (depth > options.MaxDepth) continue; - } - var body = ExtractMarkdown(desc); - if (string.IsNullOrWhiteSpace(body)) continue; - markdown.AppendLine().AppendLine(); - markdown.Append("## ").AppendLine(desc.Name ?? desc.Id); - markdown.AppendLine().Append(body); + var depth = desc.Path.Count(c => c == '/') - rootDepth; + if (depth > options.MaxDepth) continue; } - } - finally - { - await enumerator.DisposeAsync(); + var body = ExtractMarkdown(desc); + if (string.IsNullOrWhiteSpace(body)) continue; + markdown.AppendLine().AppendLine(); + markdown.Append("## ").AppendLine(desc.Name ?? desc.Id); + markdown.AppendLine().Append(body); } } diff --git a/src/MeshWeaver.Markdown.Export/Templates/ExportPdf.csx b/src/MeshWeaver.Markdown.Export/Templates/ExportPdf.csx index 28a8d94d1f..1dca8e330f 100644 --- a/src/MeshWeaver.Markdown.Export/Templates/ExportPdf.csx +++ b/src/MeshWeaver.Markdown.Export/Templates/ExportPdf.csx @@ -93,24 +93,19 @@ if (rootNode.NodeType == DeckNodeType.NodeType) else if (!string.IsNullOrWhiteSpace(query)) { var meshService = Mesh.ServiceProvider.GetRequiredService(); - var matched = new List(); - var enumerator = meshService.QueryAsync(query).GetAsyncEnumerator(Ct); - try - { - while (await enumerator.MoveNextAsync()) - { - var n = enumerator.Current; - // Drop the deck root itself and any '_'-prefixed governance node — same - // filtering the live query binding applies (see DeckLayoutAreas.ObserveQuerySlides). - if (string.Equals(n.Path, sourcePath, StringComparison.Ordinal)) continue; - if (n.Segments.Skip(1).Any(s => s.StartsWith('_'))) continue; - matched.Add(n); - } - } - finally - { - await enumerator.DisposeAsync(); - } + // One-shot snapshot off the LIVE query surface: filter on the emission SHAPE + // (Initial), never on a count — a change feed can race the initial-snapshot path. + var slideMatches = await meshService + .Query(MeshQueryRequest.FromQuery(query)) + .Where(c => c.ChangeType == QueryChangeType.Initial) + .Select(c => c.Items) + .FirstAsync() + .ToTask(Ct); + // Drop the deck root itself and any '_'-prefixed governance node — same + // filtering the live query binding applies (see DeckLayoutAreas.ObserveQuerySlides). + var matched = slideMatches + .Where(n => !string.Equals(n.Path, sourcePath, StringComparison.Ordinal)) + .Where(n => !n.Segments.Skip(1).Any(s => s.StartsWith('_'))); slideNodes = matched .OrderBy(n => n.Order ?? int.MaxValue) .ThenBy(n => n.Path, StringComparer.Ordinal) @@ -230,27 +225,22 @@ chapters = new List Log.LogInformation("Collecting descendants"); var meshService = Mesh.ServiceProvider.GetRequiredService(); var rootDepth = sourcePath.Count(c => c == '/'); - var enumerator = meshService - .QueryAsync("path:" + sourcePath + " scope:descendants") - .GetAsyncEnumerator(Ct); - try + var descendants = await meshService + .Query(MeshQueryRequest.FromQuery("path:" + sourcePath + " scope:descendants")) + .Where(c => c.ChangeType == QueryChangeType.Initial) + .Select(c => c.Items) + .FirstAsync() + .ToTask(Ct); + foreach (var desc in descendants) { - while (await enumerator.MoveNextAsync()) + if (options.MaxDepth > 0) { - var desc = enumerator.Current; - if (options.MaxDepth > 0) - { - var depth = desc.Path.Count(c => c == '/') - rootDepth; - if (depth > options.MaxDepth) continue; - } - var md = ExtractMarkdown(desc); - if (!string.IsNullOrWhiteSpace(md)) - chapters.Add(new ExportChapter(desc.Name ?? desc.Id, md, desc.Path)); + var depth = desc.Path.Count(c => c == '/') - rootDepth; + if (depth > options.MaxDepth) continue; } - } - finally - { - await enumerator.DisposeAsync(); + var md = ExtractMarkdown(desc); + if (!string.IsNullOrWhiteSpace(md)) + chapters.Add(new ExportChapter(desc.Name ?? desc.Id, md, desc.Path)); } Log.LogInformation("Collected {Count} chapters", chapters.Count); } diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json index 7a2aac11ca..e8d7fa872c 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json @@ -585,6 +585,9 @@ "ui.sendDocument.attachedNote": "Anbei finden Sie das Dokument.", "ui.sendDocument.fromLabel": "Von", "ui.sendDocument.connect": "Microsoft 365 verbinden", + "ui.sendDocument.connectFirst": "Ihr Microsoft-365-Postfach ist noch nicht verbunden. Verbinden Sie es jetzt, um von Ihrer eigenen Adresse zu senden — bereits Eingegebenes bleibt erhalten.", + "ui.sendDocument.signInTitle": "Zum Teilen per E-Mail anmelden", + "ui.sendDocument.signInBody": "Das Teilen eines Dokuments per E-Mail erfordert ein angemeldetes Konto, damit Antworten Sie erreichen und Ihr Entwurf privat gespeichert wird.", "ui.sendDocument.fromSharedTitle": "Aus dem gemeinsamen Postfach senden?", "ui.sendDocument.fromSharedBody": "Ihr Microsoft-365-Postfach ist nicht verbunden, daher würde dies aus dem gemeinsamen Portal-Postfach statt von Ihrer eigenen Adresse gesendet.", "ui.sendDocument.sendFromShared": "Aus dem gemeinsamen Postfach senden", diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json index ae28dc2270..54eaf13f57 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json @@ -585,6 +585,9 @@ "ui.sendDocument.attachedNote": "Please find the attached document.", "ui.sendDocument.fromLabel": "From", "ui.sendDocument.connect": "Connect Microsoft 365", + "ui.sendDocument.connectFirst": "Your Microsoft 365 mailbox is not connected yet. Connect it now to send from your own address — anything you have already typed is kept.", + "ui.sendDocument.signInTitle": "Sign in to share by email", + "ui.sendDocument.signInBody": "Sharing a document by email needs a signed-in account, so replies reach you and your draft is kept privately.", "ui.sendDocument.fromSharedTitle": "Send from the shared mailbox?", "ui.sendDocument.fromSharedBody": "Your Microsoft 365 mailbox is not connected, so this would go out from the shared portal mailbox rather than your own address.", "ui.sendDocument.sendFromShared": "Send from the shared mailbox", diff --git a/test/MeshWeaver.Hosting.Monolith.Test/ExportTemplateCompilationTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/ExportTemplateCompilationTest.cs new file mode 100644 index 0000000000..6bab47d7b9 --- /dev/null +++ b/test/MeshWeaver.Hosting.Monolith.Test/ExportTemplateCompilationTest.cs @@ -0,0 +1,92 @@ +using System.Linq; +using System.Threading.Tasks; +using MeshWeaver.Fixture; +using MeshWeaver.Graph; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Kernel.Hub; +using MeshWeaver.Markdown.Export; +using MeshWeaver.Mesh; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Xunit; + +namespace MeshWeaver.Hosting.Monolith.Test; + +/// +/// 🚨 The gate that was missing when every PDF / DOCX / email export died in production. +/// +/// What happened. The three export templates are .csx — C# compiled at RUNTIME +/// by the kernel, invisible to dotnet build. They called +/// meshService.QueryAsync<MeshNode>(query), an extension that exists ONLY in +/// MeshWeaver.Fixture — a test-only assembly whose own doc comment says "Production code MUST +/// NOT use these". The production IMeshService has no such method, so every real export threw +/// CompilationErrorException CS1061 the moment a user pressed Send. +/// +/// Why eleven passing tests did not catch it. The kernel builds its Roslyn reference set +/// from AppDomain.CurrentDomain.GetAssemblies(), and a test process ALWAYS has +/// MeshWeaver.Fixture loaded (MonolithMeshTestBase derives from +/// Fixture.TestBase). Its extensions sit in namespace MeshWeaver.Mesh, which the kernel +/// imports for every script. So script compilation in tests was strictly MORE PERMISSIVE than in +/// production, and every test that genuinely executed a template compiled against an API no deployed +/// portal has. +/// +/// The fix this test guards. KernelScriptReferences.IsProductionReferenceable now +/// drops test scaffolding from the script reference set, so a script compiles in a test exactly as it +/// does in production. pins that +/// directly; pins the templates +/// themselves — including branches (deck query, descendants) that no runtime test reaches. +/// +public class ExportTemplateCompilationTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + public static IEnumerable Templates() => + MarkdownExportTemplates.GetStaticNodes() + .Where(n => n.Content is CodeConfiguration) + .Select(n => new object[] { n.Path }) + .ToArray(); + + [Theory(Timeout = 30000)] + [MemberData(nameof(Templates))] + public void ExportTemplates_CompileAgainstTheProductionSurface(string templatePath) + { + var node = MarkdownExportTemplates.GetStaticNodes() + .Single(n => string.Equals(n.Path, templatePath, StringComparison.Ordinal)); + var code = ((CodeConfiguration)node.Content!).Code; + code.Should().NotBeNullOrWhiteSpace("the template must carry its script source"); + + var options = ScriptOptions.Default + .WithReferences(MeshScriptEnvironment.References(Mesh.ServiceProvider)) + .WithMetadataResolver(MeshScriptEnvironment.MetadataResolver) + .WithImports(MeshScriptEnvironment.Imports); + + var script = CSharpScript.Create(code!, options, MeshScriptEnvironment.GlobalsType); + var errors = script.GetCompilation().GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString()) + .ToArray(); + + errors.Should().BeEmpty( + $"the export template {templatePath} is compiled at RUNTIME and is invisible to " + + "dotnet build — a reference to an API that does not exist on the production surface " + + "only ever surfaces as a failed export in front of a user"); + } + + [Fact(Timeout = 30000)] + public void ScriptReferenceSet_ExcludesTestOnlyAssemblies() + { + var referenced = MeshScriptEnvironment.References(Mesh.ServiceProvider) + .OfType() + .Select(r => Path.GetFileNameWithoutExtension(r.FilePath) ?? "") + .ToArray(); + + // The Fixture is unquestionably loaded in this process — this test class derives from a type + // in it — so its absence from the reference set proves the filter, not a lucky environment. + typeof(MeshWeaver.Fixture.TestBase).Assembly.GetName().Name.Should().Be("MeshWeaver.Fixture"); + + referenced.Should().NotContain( + n => MeshScriptEnvironment.IsTestScaffolding(n), + "a script must compile in a test EXACTLY as it does in production; letting test-only " + + "assemblies into the reference set is what allowed the export templates to bind " + + "MeshWeaver.Fixture's QueryAsync and ship broken"); + } +} diff --git a/test/MeshWeaver.Hosting.Monolith.Test/SendDocumentDraftTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/SendDocumentDraftTest.cs new file mode 100644 index 0000000000..d3f335d9ce --- /dev/null +++ b/test/MeshWeaver.Hosting.Monolith.Test/SendDocumentDraftTest.cs @@ -0,0 +1,143 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Markdown; +using MeshWeaver.Markdown.Export; +using MeshWeaver.Markdown.Export.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.Hosting.Monolith.Test; + +/// +/// The acceptance test for the lost compose form. +/// +/// The bug. Someone filled in recipient, subject and message in "Share ⇒ as email", +/// was sent through Microsoft 365 consent, and came back to an empty form. The connect button must +/// navigate with forceLoad: true — consent is a server-side MVC endpoint that an in-circuit +/// Blazor navigation cannot reach — and a full navigation TEARS DOWN THE CIRCUIT. The compose state +/// lived in the layout area's /data store, i.e. circuit memory, keyed by a +/// Guid.NewGuid() minted per render. So the return trip re-rendered, minted a fresh key, and +/// re-seeded a blank form. Nothing was ever persisted, so there was nothing to come back to. +/// +/// What these tests prove. That the state now lives on a mesh node, so a circuit +/// teardown is irrelevant — and that the draft is private to its author, which is why it is filed +/// under the author rather than under the shared document. +/// +public class SendDocumentDraftTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + => base.ConfigureMesh(builder).AddMarkdownExport(); + + /// + /// 🚨 THE round-trip test: fill the draft, blow the circuit away, come back — the three fields + /// the user typed are still there. + /// + /// "Blowing the circuit away" is modelled the only way it is observable server-side: the + /// draft path is a pure function of (author, document) rather than a per-render Guid, and the + /// state is read back through a FRESH stream handle rather than the one that wrote it. That is + /// exactly what the returning request does — no circuit, no layout-area data store, no memory of + /// the previous render survives the consent navigation. + /// + [Fact(Timeout = 60000)] + public async Task Draft_SurvivesTheConsentRoundTrip() + { + var doc = await SeedDocument(); + var me = TestUsers.Admin.ObjectId!; + + var defaults = new EmailDraft { Subject = "Shared with you: report", Message = "Hi,", Delivery = "body" }; + var draftPath = await EmailDraftNodeType + .EnsureExists(Mesh, me, doc, defaults) + .Should().Within(20.Seconds()).Emit(); + + draftPath.Should().Be($"{me}/_Draft/{doc.Replace("/", "_")}", + "the draft is keyed by (author, document) — a per-render id is precisely what could not " + + "survive the navigation"); + + // ── The user fills the form in. Every field write goes through the node stream, which is + // what the node-bound form controls do per keystroke (debounced). + await Mesh.GetWorkspace().GetMeshNodeStream(draftPath) + .Update(node => node with + { + Content = new EmailDraft + { + Email = "someone@example.com", + Subject = "Q3 numbers, as promised", + Message = "Hi Anna,\n\nHere is the report we discussed.", + Delivery = "attachment", + DocumentPath = doc + } + }) + .Should().Within(20.Seconds()).Emit(); + + // ── forceLoad → the circuit dies. Nothing in memory carries over: the returning render + // resolves the draft path from scratch and opens a NEW stream handle on it. + var restoredPath = await EmailDraftNodeType + .EnsureExists(Mesh, me, doc, defaults) + .Should().Within(20.Seconds()).Emit(); + restoredPath.Should().Be(draftPath, "the returning render must find the same draft"); + + var restored = await Mesh.GetWorkspace().GetMeshNodeStream(restoredPath) + .Where(n => n is not null) + .Select(n => n!.ContentAs(Mesh.JsonSerializerOptions)) + .Should().Within(20.Seconds()).Match(d => d is not null && d.Email == "someone@example.com"); + + restored!.Email.Should().Be("someone@example.com", "the recipient must survive consent"); + restored.Subject.Should().Be("Q3 numbers, as promised", "the subject must survive consent"); + restored.Message.Should().Be("Hi Anna,\n\nHere is the report we discussed.", + "the message must survive consent — this is the field that cost the user the most work"); + restored.Delivery.Should().Be("attachment", "the delivery choice must survive consent too"); + } + + /// + /// The draft is dropped once the mail is out, so sharing the same document again starts clean + /// instead of resurrecting an old recipient. Discarding is idempotent — a second discard (or one + /// racing the send's own) is not an error. + /// + [Fact(Timeout = 60000)] + public async Task Draft_IsDiscardedAfterSending_AndDiscardIsIdempotent() + { + var doc = await SeedDocument(); + var me = TestUsers.Admin.ObjectId!; + + var draftPath = await EmailDraftNodeType + .EnsureExists(Mesh, me, doc, new EmailDraft { Subject = "s" }) + .Should().Within(20.Seconds()).Emit(); + + await EmailDraftNodeType.Discard(Mesh, draftPath).Should().Within(20.Seconds()).Emit(); + await EmailDraftNodeType.Discard(Mesh, draftPath).Should().Within(20.Seconds()).Emit(); + + var remaining = await Mesh.GetWorkspace() + .GetQuery($"drafts|{draftPath}", $"path:{draftPath} nodeType:{EmailDraftNodeType.NodeType}") + .Should().Within(20.Seconds()).Emit(); + remaining.Should().BeEmpty("a sent draft must not resurface the next time the document is shared"); + } + + private async Task SeedDocument() + { + var space = $"Space{Guid.NewGuid():N}"[..16]; + await NodeFactory.CreateNode(MeshNode.FromPath(space) with + { + Name = "Draft Test Space", + NodeType = SpaceNodeType.NodeType, + Content = new Space() + }).Should().Emit(); + + var doc = $"{space}/report"; + await NodeFactory.CreateNode(MeshNode.FromPath(doc) with + { + Name = "Report", + NodeType = "Markdown", + Content = new MarkdownContent { Content = "# Report\n\nBody." } + }).Should().Emit(); + return doc; + } +} diff --git a/test/MeshWeaver.Security.Test/EmailDraftPrivacyTest.cs b/test/MeshWeaver.Security.Test/EmailDraftPrivacyTest.cs new file mode 100644 index 0000000000..b09b2225e8 --- /dev/null +++ b/test/MeshWeaver.Security.Test/EmailDraftPrivacyTest.cs @@ -0,0 +1,97 @@ +using System.Threading.Tasks; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Hosting.Security; +using MeshWeaver.Markdown.Export.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Messaging; +using Xunit; + +namespace MeshWeaver.Security.Test; + +/// +/// A "Share ⇒ as email" compose draft holds recipient addresses and a personal message. It is +/// PRIVATE to its author, and these tests are what pin that. +/// +/// Why this decided where the draft lives. The intuitive home for it is a satellite +/// under the document being shared ({doc}/_Draft/…). That placement cannot be made private: +/// permissions are additive along the path (PermissionEvaluator.GetScopeHierarchy unions +/// every ancestor scope), the stream-cache gate probes the PATH, and the Postgres listing folds +/// visibility on main_node — so every colleague who can read the document would inherit read +/// on everyone's drafts, through four independent paths. There is no per-node owner flag, no +/// restricting assignment, and PartitionAccessPolicy.BreaksInheritance — the closest thing — +/// is not honoured by the SQL fold at all, so search would still leak. +/// (ThreadComposerNodeType.PathForNode is the live precedent of exactly that leak.) +/// +/// So the draft is anchored under its AUTHOR — {userId}/_Draft/{documentKey} — +/// the framework's actual per-user privacy mechanism, shared with {userId}/_Settings/…, +/// {userId}/_UserActivity/… and {userId}/Feedback/…. Privacy is then structural, not +/// asserted: the self-scope-owner rule grants a user rights at the scope equal to their own id, and +/// nobody else holds a grant there. The draft still names the document it belongs to, in its path +/// key and in EmailDraft.DocumentPath. +/// +public class EmailDraftPrivacyTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + private const string Document = "ACME/QuarterlyReport"; + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => + ConfigureMeshBase(builder) + .AddRowLevelSecurity() + // Bob genuinely CAN read the shared document — that is what makes the negative below a + // real assertion instead of "bob can see nothing anywhere". + .AddMeshNodes( + AssignmentNodeFactory.UserRole("Bob_DocReader", "Viewer", Document, accessObject: "bob")); + + // Granular permissions — skip the blanket PublicAdminAccess the base seeds, which would give + // every user Read on everything and make these assertions pass vacuously. + protected override Task SetupAccessRightsAsync() => Task.CompletedTask; + + /// + /// 🚨 The assertion the brief demanded: a colleague who genuinely CAN read the document still + /// cannot read someone else's draft about it. Bob is granted Reader on the document explicitly, + /// so this is a real negative — not "bob can see nothing anywhere". + /// + [Fact(Timeout = 20000)] + public async Task ReaderOfTheDocument_CannotReadAnotherUsersDraft() + { + // Bob really can read the document — otherwise the negative below proves nothing. + await Mesh.GetEffectivePermissions(Document, "bob") + .Should().Match(p => (p & Permission.Read) == Permission.Read); + + // …and still has nothing on Alice's draft about that very document. + var alicesDraft = EmailDraftNodeType.PathFor("alice", Document); + await Mesh.GetEffectivePermissions(alicesDraft, "bob") + .Should().Match(p => (p & Permission.Read) != Permission.Read); + } + + /// The author reaches her own draft with no explicit grant — the self-scope-owner rule. + /// Without this the test above could pass simply because the path resolves to nothing. + [Fact(Timeout = 20000)] + public async Task Author_ReachesTheirOwnDraft_WithoutAnyGrant() + { + var alicesDraft = EmailDraftNodeType.PathFor("alice", Document); + await Mesh.GetEffectivePermissions(alicesDraft, "alice") + .Should().Match(p => (p & Permission.Read) == Permission.Read + && (p & Permission.Update) == Permission.Update); + } + + /// + /// The placement is the mechanism, so pin the shape of the path itself: a draft must be filed + /// under its AUTHOR's partition. If someone later "tidies" it to sit under the document, the + /// privacy tests above would still pass in a blanket-grant harness — this one would not. + /// + [Fact(Timeout = 20000)] + public void DraftPath_IsAnchoredUnderTheAuthor_NotTheDocument() + { + var path = EmailDraftNodeType.PathFor("alice", Document); + + path.Should().StartWith("alice/", + "privacy comes from the draft living in the author's own partition"); + path.Should().Be($"alice/{EmailDraftNodeType.DraftSegment}/ACME_QuarterlyReport", + "the document is the KEY of the draft, not its parent"); + path.Should().NotStartWith(Document, + "filed under the shared document, every reader of that document would inherit read on " + + "the draft — the leak this placement exists to avoid"); + } +}