diff --git a/AGENTS.md b/AGENTS.md index a8cece8dba..54b8c3a864 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -622,7 +622,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"); + } +}