From a1e320091843cda59d61d67f26e18a228c6c4aa6 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:23:53 +0000 Subject: [PATCH 1/4] Add original email export and authenticated message links --- README.md | 23 +++++++ packaging/BetterMail.Info.plist | 2 +- packaging/BetterMail.desktop | 2 +- src/BetterMail.App/App.axaml.cs | 6 +- src/BetterMail.App/BetterTunnelsClient.cs | 9 ++- .../ConversationThreadView.axaml | 1 + .../ConversationThreadViewModel.cs | 4 ++ src/BetterMail.App/MailtoActivation.cs | 7 +++ src/BetterMail.App/MainWindow.axaml | 1 + src/BetterMail.App/MainWindow.axaml.cs | 15 +++++ src/BetterMail.App/MainWindowViewModel.cs | 39 ++++++++++++ src/BetterMail.App/McpEndpoint.cs | 5 ++ .../McpMailTools.Capabilities.cs | 1 + src/BetterMail.App/McpMailTools.Source.cs | 52 ++++++++++++++++ src/BetterMail.App/McpMailTools.cs | 3 +- src/BetterMail.App/McpSettingsViewModel.cs | 3 +- src/BetterMail.App/Program.cs | 3 +- tests/BetterMail.Tests/EvidenceTests.cs | 60 +++++++++++++++++++ tests/BetterMail.Tests/McpTests.cs | 2 +- 19 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/BetterMail.App/McpMailTools.Source.cs diff --git a/README.md b/README.md index c627fdf..47b9849 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,29 @@ local-file fallback. Files under the budget stay ordinary attachments. `share_drive_file` also supports explicit organization or named-recipient audiences. Clients must obtain user authorization for sharing and deleting, and treat filenames and file contents as untrusted. +## Original messages, headers, and direct links + +Use **Save as .eml** in the message actions menu to save the original provider MIME bytes, +including attachments. **View headers** shows the provider's internet message headers. +Both actions require connectivity. MCP exposes headers through `read_mail_headers` and +original MIME through `read_mail_raw`; assemble its base64 chunks and verify the returned +SHA-256 before saving as `.eml`. Each chunk fetches the provider source again, so restart +if its hash or size changes between requests. + +Call `get_mail_link(mailboxId, messageId)` for a stable captured message reference: + +- `localUrl`: `bettermail://evidence/` opens the message in BetterMail's local profile. + Windows registers the handler on startup; Linux desktop and macOS bundle metadata register it on installation. +- `url`: an HTTP reference to the captured message, using the active BetterTunnels URL or local MCP listener. +- `rawUrl`: downloads the original provider `.eml` over that same endpoint. + +HTTP references require the MCP bearer authorization header and allowed mailbox access; +access keys are never embedded in links. These are authenticated client URLs, so opening them +in an ordinary browser without authorization returns 401. Captured references survive provider +moves, while raw downloads require the original provider message ID to remain available. +The local handler resolves records in the currently open profile; another installation must +have the corresponding captured record. Tunnel links work while the tunnel is connected. + ## MCP attachment search and evidence tools Use `index_attachments` through MCP to download and index cached mail: supply a message ID for one diff --git a/packaging/BetterMail.Info.plist b/packaging/BetterMail.Info.plist index d0c98b9..d44aa63 100644 --- a/packaging/BetterMail.Info.plist +++ b/packaging/BetterMail.Info.plist @@ -17,7 +17,7 @@ CFBundleURLNameBetterMail mail links CFBundleTypeRoleEditor - CFBundleURLSchemesmailto + CFBundleURLSchemesmailtobettermail diff --git a/packaging/BetterMail.desktop b/packaging/BetterMail.desktop index efa5e82..c18fcad 100644 --- a/packaging/BetterMail.desktop +++ b/packaging/BetterMail.desktop @@ -5,5 +5,5 @@ Comment=Fast local-first Microsoft 365 mail Exec=BetterMail %u Icon=BetterMail Categories=Network;Email; -MimeType=x-scheme-handler/mailto; +MimeType=x-scheme-handler/mailto;x-scheme-handler/bettermail; Terminal=false diff --git a/src/BetterMail.App/App.axaml.cs b/src/BetterMail.App/App.axaml.cs index 280198d..42ae3ad 100644 --- a/src/BetterMail.App/App.axaml.cs +++ b/src/BetterMail.App/App.axaml.cs @@ -230,7 +230,11 @@ private async Task HandleActivationAsync(string activation) _mainWindow.Show(); } _mainWindow.Activate(); - if (MailtoParser.TryParse(activation, out var request)) + if (BetterMail.Core.EvidenceLink.TryParse(activation, out var recordId)) + { + await _viewModel.OpenEvidenceLinkAsync(recordId); + } + else if (MailtoParser.TryParse(activation, out var request)) { await _viewModel.OpenComposeAsync(request); } diff --git a/src/BetterMail.App/BetterTunnelsClient.cs b/src/BetterMail.App/BetterTunnelsClient.cs index 84a629b..4b05393 100644 --- a/src/BetterMail.App/BetterTunnelsClient.cs +++ b/src/BetterMail.App/BetterTunnelsClient.cs @@ -246,12 +246,15 @@ async Task ForwardAsync(TunnelFrame frame, CancellationTokenSource requestLifeti internal static HttpRequestMessage CreateLocalRequest(TunnelFrame frame, Uri endpoint) { - // Exact private endpoint only. Never resolve attacker-provided paths into a local URL. - if (frame.Path != endpoint.AbsolutePath || frame.Method is not ("GET" or "POST" or "DELETE")) + // Only the private MCP endpoint and explicit read-only evidence routes may be forwarded. + var evidencePath = frame.Path?.StartsWith(endpoint.AbsolutePath + "/evidence/", StringComparison.Ordinal) == true + && System.Text.RegularExpressions.Regex.IsMatch(frame.Path[endpoint.AbsolutePath.Length..], + @"\A/evidence/(?:records/(?:[a-fA-F0-9]{32}|[a-fA-F0-9]{64})(?:/raw)?|files/(?:[a-fA-F0-9]{32}|[a-fA-F0-9]{64})|exports/(?:[a-fA-F0-9]{32}|[a-fA-F0-9]{64}))\z"); + if (!(frame.Path == endpoint.AbsolutePath && frame.Method is ("GET" or "POST" or "DELETE") || evidencePath && frame.Method == "GET")) throw new InvalidDataException("Invalid MCP request target."); var body = Convert.FromBase64String(frame.Body ?? ""); if (body.Length > 1024 * 1024) throw new InvalidDataException("MCP request is too large."); - var request = new HttpRequestMessage(new HttpMethod(frame.Method), endpoint) { Content = new ByteArrayContent(body) }; + var request = new HttpRequestMessage(new HttpMethod(frame.Method!), new Uri(endpoint.GetLeftPart(UriPartial.Authority) + frame.Path)) { Content = new ByteArrayContent(body) }; foreach (var (name, value) in frame.Headers ?? []) { if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) || name.Equals("Accept", StringComparison.OrdinalIgnoreCase) || diff --git a/src/BetterMail.App/ConversationThreadView.axaml b/src/BetterMail.App/ConversationThreadView.axaml index fe0af92..e72ce4a 100644 --- a/src/BetterMail.App/ConversationThreadView.axaml +++ b/src/BetterMail.App/ConversationThreadView.axaml @@ -60,6 +60,7 @@