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/docs/mcp-bettertunnels.md b/docs/mcp-bettertunnels.md index 81082c0..c821d1f 100644 --- a/docs/mcp-bettertunnels.md +++ b/docs/mcp-bettertunnels.md @@ -46,7 +46,10 @@ from the rotatable MCP key. No CLI installation, child process, token arguments, or plaintext token file is required. BetterMail uses .NET's HTTP and WebSocket clients with the BetterTunnels v1 HTTP relay protocol. -Forwarding is restricted to the exact private MCP path and fixed loopback target. +Forwarding is restricted to the private MCP path and explicit GET routes below it +for evidence records, original `.eml` downloads, captured files, and exports. Record +IDs must be hexadecimal; arbitrary paths, traversal, query strings, and writes to +these download routes are rejected. The loopback target remains fixed. The relay sets the local Host header correctly, preserving MCP's loopback checks. MCP still verifies the client's bearer key, allowed mailboxes, and write/send permissions. Origin headers are preserved: an arbitrary browser origin is rejected. 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 @@