Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` 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
Expand Down
5 changes: 4 additions & 1 deletion docs/mcp-bettertunnels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packaging/BetterMail.Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<dict>
<key>CFBundleURLName</key><string>BetterMail mail links</string>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>CFBundleURLSchemes</key><array><string>mailto</string></array>
<key>CFBundleURLSchemes</key><array><string>mailto</string><string>bettermail</string></array>
</dict>
</array>
</dict>
Expand Down
2 changes: 1 addition & 1 deletion packaging/BetterMail.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion src/BetterMail.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
9 changes: 6 additions & 3 deletions src/BetterMail.App/BetterTunnelsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
1 change: 1 addition & 0 deletions src/BetterMail.App/ConversationThreadView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<Button Classes="command" Content="{Binding ToggleFlagText}" HorizontalContentAlignment="Left" Command="{Binding ToggleFlagCommand}" />
<Button Classes="command" Content="{Binding TogglePinText}" HorizontalContentAlignment="Left" Command="{Binding TogglePinCommand}" />
<Button Classes="command" Content="{Binding ToggleReadText}" HorizontalContentAlignment="Left" Command="{Binding ToggleReadCommand}" />
<Button Classes="command" Content="Save as .eml" HorizontalContentAlignment="Left" Command="{Binding ExportMailCommand}" />
Comment thread
bcbetterninja marked this conversation as resolved.
<Button Classes="command" Content="View headers" HorizontalContentAlignment="Left" Command="{Binding ViewHeadersCommand}" />
</StackPanel>
</Flyout>
Expand Down
4 changes: 4 additions & 0 deletions src/BetterMail.App/ConversationThreadViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public enum ConversationAction
ToggleFlag,
TogglePin,
ViewHeaders,
ExportMail,
Move,
CreateEvent
}
Expand Down Expand Up @@ -95,6 +96,7 @@ public ConversationThreadViewModel(
ToggleReadCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.ToggleRead), CanRunAction, allowConcurrent: true);
ToggleFlagCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.ToggleFlag), CanRunAction, allowConcurrent: true);
TogglePinCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.TogglePin), CanRunAction, allowConcurrent: true);
ExportMailCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.ExportMail), CanRunAction, allowConcurrent: true);
ViewHeadersCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.ViewHeaders), CanRunAction, allowConcurrent: true);
MoveToFolderCommand = new AsyncCommand<MailFolderItem>(MoveToFolderAsync, CanMoveToFolder, allowConcurrent: true);
OpenDraftCommand = new AsyncCommand<LocalDraft>(draft => _openDraft?.Invoke(draft) ?? Task.CompletedTask);
Expand All @@ -118,6 +120,7 @@ public ConversationThreadViewModel(
public ICommand ToggleReadCommand { get; }
public ICommand ToggleFlagCommand { get; }
public ICommand TogglePinCommand { get; }
public ICommand ExportMailCommand { get; }
public ICommand ViewHeadersCommand { get; }
public ICommand MoveToFolderCommand { get; }
public ICommand OpenDraftCommand { get; }
Expand Down Expand Up @@ -471,6 +474,7 @@ private void RefreshActionCommands()
((AsyncCommand)ToggleReadCommand).Refresh();
((AsyncCommand)ToggleFlagCommand).Refresh();
((AsyncCommand)TogglePinCommand).Refresh();
((AsyncCommand)ExportMailCommand).Refresh();
((AsyncCommand)ViewHeadersCommand).Refresh();
((AsyncCommand<MailFolderItem>)MoveToFolderCommand).Refresh();
}
Expand Down
11 changes: 9 additions & 2 deletions src/BetterMail.App/MailtoActivation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ public static void Register()
protocol.CreateSubKey(@"shell\open\command").SetValue(
"", $"{quote}{executable}{quote} {quote}%1{quote}");
}
using (var link = Registry.CurrentUser.CreateSubKey(@"Software\Classes\bettermail"))
{
link.SetValue("", "URL:BetterMail Protocol");
link.SetValue("URL Protocol", "");
link.CreateSubKey(@"shell\open\command").SetValue("", $"\"{executable}\" \"%1\"");
}
using (var capabilities = Registry.CurrentUser.CreateSubKey(CapabilitiesPath))
{
capabilities.SetValue("ApplicationName", ApplicationName);
Expand All @@ -235,6 +241,7 @@ public static void Unregister()

try
{
Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\bettermail", false);
Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\{ProgId}", false);
Registry.CurrentUser.DeleteSubKeyTree(@"Software\BetterCorp\BetterMail", false);
using var registered = Registry.CurrentUser.OpenSubKey(@"Software\RegisteredApplications", writable: true);
Expand Down Expand Up @@ -286,15 +293,15 @@ [Desktop Entry]
Exec=__APPIMAGE__ %u
Icon=BetterMail
Categories=Network;Email;
MimeType=x-scheme-handler/mailto;
MimeType=x-scheme-handler/mailto;x-scheme-handler/bettermail;
Terminal=false

""";
desktop = desktop.Replace(
"__APPIMAGE__", string.Concat((char)34, appImage, (char)34), StringComparison.Ordinal);
await File.WriteAllTextAsync(desktopPath, desktop);
using var process = Process.Start(new ProcessStartInfo(
"xdg-mime", "default bettermail.desktop x-scheme-handler/mailto")
"xdg-mime", "default bettermail.desktop x-scheme-handler/mailto x-scheme-handler/bettermail")
{
UseShellExecute = false
}) ?? throw new InvalidOperationException("xdg-mime is unavailable.");
Expand Down
1 change: 1 addition & 0 deletions src/BetterMail.App/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@
<Button Classes="command" Content="{Binding ToggleFlagText}" HorizontalContentAlignment="Left" Click="MessageFlagClicked" AutomationProperties.Name="{Binding ToggleFlagText}" />
<Button Classes="command" Content="{Binding TogglePinText}" HorizontalContentAlignment="Left" Command="{Binding TogglePinCommand}" AutomationProperties.Name="{Binding TogglePinText}" />
<Button Classes="command" Content="{Binding ToggleReadText}" HorizontalContentAlignment="Left" Click="MessageReadClicked" AutomationProperties.Name="{Binding ToggleReadText}" />
<Button Classes="command" Content="Save as .eml" HorizontalContentAlignment="Left" Command="{Binding ExportMailCommand}" AutomationProperties.Name="Save original email as .eml" />
<Button Classes="command" Content="View headers" HorizontalContentAlignment="Left" Command="{Binding ViewHeadersCommand}" AutomationProperties.Name="View internet message headers" />
</StackPanel>
</Flyout>
Expand Down
16 changes: 16 additions & 0 deletions src/BetterMail.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,7 @@ private void BindViewModel(MainWindowViewModel? viewModel)
_viewModel.ComposeRequested -= OpenCompose;
_viewModel.SharedMailboxRequested -= OpenSharedMailbox;
_viewModel.SearchFocusRequested -= FocusMailSearch;
_viewModel.SaveRawMailRequested = null;
_viewModel.HeadersRequested -= OpenHeaders;
_viewModel.AttachmentPreviewRequested -= PreviewAttachment;
_viewModel.SaveAttachmentsRequested -= SaveAllAttachments;
Expand All @@ -1046,6 +1047,7 @@ private void BindViewModel(MainWindowViewModel? viewModel)
_viewModel.ComposeRequested += OpenCompose;
_viewModel.SharedMailboxRequested += OpenSharedMailbox;
_viewModel.SearchFocusRequested += FocusMailSearch;
_viewModel.SaveRawMailRequested = SaveRawMailAsync;
_viewModel.HeadersRequested += OpenHeaders;
_viewModel.AttachmentPreviewRequested += PreviewAttachment;
_viewModel.SaveAttachmentsRequested += SaveAllAttachments;
Expand Down Expand Up @@ -1185,6 +1187,20 @@ private void OpenSharedMailbox(MailAccount account)
IndependentWindow.Show(window);
}

private async Task<bool> SaveRawMailAsync(byte[] bytes)
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Save original message", SuggestedFileName = "message.eml", DefaultExtension = "eml",
FileTypeChoices = [new FilePickerFileType("Email message") { Patterns = ["*.eml"] }]
});
if (file is null) return false;
await using var stream = await file.OpenWriteAsync();
stream.SetLength(0);
await stream.WriteAsync(bytes);
return true;
}

private void OpenHeaders(MailHeadersDocument document) =>
IndependentWindow.Show(new MailHeadersWindow(document));

Expand Down
40 changes: 40 additions & 0 deletions src/BetterMail.App/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ public MainWindowViewModel(
ReplyCommand = new AsyncCommand(ReplyAsync, CanReplyToSelectedMessage);
ReplyAllCommand = new AsyncCommand(ReplyAllAsync, CanReplyToSelectedMessage);
ForwardCommand = new AsyncCommand(ForwardAsync, CanReplyToSelectedMessage);
ExportMailCommand = new AsyncCommand(ExportSelectedMailAsync, CanViewHeaders);
ViewHeadersCommand = new AsyncCommand(ViewHeadersAsync, CanViewHeaders);
SelectNextMessageCommand = new AsyncCommand(
() => SelectAdjacentMessageAsync(1),
Expand Down Expand Up @@ -496,6 +497,8 @@ public string NextCalendarEventTime
public ICommand ReplyCommand { get; }
public ICommand ReplyAllCommand { get; }
public ICommand ForwardCommand { get; }
public ICommand ExportMailCommand { get; }
public Func<byte[], Task<bool>>? SaveRawMailRequested { get; set; }
public ICommand ViewHeadersCommand { get; }
public ICommand SelectNextMessageCommand { get; }
public ICommand SelectPreviousMessageCommand { get; }
Expand Down Expand Up @@ -4206,9 +4209,43 @@ private void RefreshMailActionCommands()
((AsyncCommand)ToggleFlagCommand).Refresh();
((AsyncCommand)TogglePinCommand).Refresh();
((AsyncCommand<MailFolderItem>)MoveToFolderCommand).Refresh();
((AsyncCommand)ExportMailCommand).Refresh();
((AsyncCommand)ViewHeadersCommand).Refresh();
}

public async Task OpenEvidenceLinkAsync(string recordId)
{
if (_store is null) return;
try
{
var document = await _store.GetEvidenceDocumentAsync(recordId);
var record = await _store.GetEvidenceMessageAsync(document?.MessageId ?? recordId);
if (record is null) { Error = "This BetterMail link is unavailable in this profile."; return; }
var message = await _store.GetMessageAsync(record.Message.MailboxId, record.Message.ProviderId) ?? record.Message;
ShowMailSearchResults(message, [message]);
}
catch (Exception exception) { Error = $"BetterMail link could not be opened: {exception.Message}"; }
}

private Task ExportSelectedMailAsync() => ExportMailAsync(ConversationThread.SelectedMessage?.Message ?? SelectedMessage);

private async Task ExportMailAsync(MailMessage? message)
{
if (message is null || _provider is null || _isMailActionRunning ||
!TryGetMessageContext(message, out var account, out var mailbox) || SaveRawMailRequested is not { } save) return;
BeginMailAction("Downloading original message...");
try
{
var bytes = await _provider.GetMimeMessageAsync(account, mailbox, message.ProviderId);
Status = await save(bytes) ? "Message saved as .eml" : "Message export canceled";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Error = $"Message export failed: {exception.Message}";
}
finally { EndMailAction(); }
}

private Task ViewHeadersAsync() =>
ViewHeadersAsync(ConversationThread.SelectedMessage?.Message ?? SelectedMessage);

Expand Down Expand Up @@ -5537,6 +5574,8 @@ private Task HandleConversationAction(ConversationActionRequest request) =>
ConversationAction.ReplyAll => ReplyAllToAsync(request.Message),
ConversationAction.Forward => ForwardMessageAsync(request.Message),
ConversationAction.CreateEvent => CreateEventFromEmailAsync(request.Message),
ConversationAction.ExportMail => ExportMailAsync(request.Message),
ConversationAction.ViewHeaders => ViewHeadersAsync(request.Message),
_ => Task.CompletedTask
};

Expand All @@ -5556,6 +5595,7 @@ internal IReadOnlyList<MailFolderItem> MoveFoldersFor(MailMessage message) =>
ConversationAction.ToggleRead => QueueMessageStateChangesAsync([request.Message], read: !request.Message.IsRead, accepted: request.Accepted),
ConversationAction.ToggleFlag => QueueMessageStateChangesAsync([request.Message], flagged: !request.Message.IsFlagged, accepted: request.Accepted),
ConversationAction.TogglePin => QueueMessageStateChangesAsync([request.Message], pinned: !request.Message.IsPinned, accepted: request.Accepted),
ConversationAction.ExportMail => ExportMailAsync(request.Message),
ConversationAction.ViewHeaders => ViewHeadersAsync(request.Message),
ConversationAction.Move when request.Destination is not null =>
MoveSnapshotToFolderAsync([request.Message], request.Destination, request.Accepted),
Expand Down
5 changes: 5 additions & 0 deletions src/BetterMail.App/McpEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ request.Host.Host is not ("127.0.0.1" or "localhost") || request.Host.Port != ex
await next(context);
});
_app.MapMcp(_endpointPath);
_app.MapGet(_endpointPath + "/evidence/records/{id}/raw", async (string id, CancellationToken cancellationToken) =>
{
try { return Results.File(await tools.DownloadRecordRawAsync(id, cancellationToken), "message/rfc822", "message.eml"); }
catch (ModelContextProtocol.McpException error) { return Results.Json(new { error = error.Message }, statusCode: 403); }
});
_app.MapGet(_endpointPath + "/evidence/records/{id}", async (string id, CancellationToken cancellationToken) =>
{
try { return Results.Json(await tools.ReadEvidence(id, cancellationToken)); }
Expand Down
Loading
Loading