From be48393f4d0123071f869a313c9253c6d1fa5b6a Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:22:47 +0000 Subject: [PATCH 1/2] Separate Tunnels/Web settings and add Microsoft 365 mail webhooks --- README.md | 8 +- docs/mail-webhooks.md | 37 ++ docs/mcp-bettertunnels.md | 110 +++-- src/BetterMail.App/App.axaml.cs | 7 +- src/BetterMail.App/BetterTunnelsClient.cs | 6 +- src/BetterMail.App/MailWebhookEndpoint.cs | 101 ++++ .../MainWindowViewModel.Sync.cs | 17 +- src/BetterMail.App/MainWindowViewModel.cs | 16 +- .../McpSettingsViewModel.BetterTunnels.cs | 132 ------ src/BetterMail.App/McpSettingsViewModel.cs | 41 +- src/BetterMail.App/SettingsView.axaml | 58 ++- src/BetterMail.App/SettingsView.axaml.cs | 10 + .../TunnelsSettingsViewModel.cs | 435 ++++++++++++++++++ src/BetterMail.Core/EncryptedMailStore.Mcp.cs | 5 +- src/BetterMail.Core/MailProviderRouter.cs | 13 +- src/BetterMail.Core/MailWebhooks.cs | 88 ++++ .../Microsoft365MailProvider.Webhooks.cs | 49 ++ .../Microsoft365MailProvider.cs | 2 +- tests/BetterMail.Tests/MailWebhookTests.cs | 311 +++++++++++++ tests/BetterMail.Tests/McpTests.cs | 13 +- .../Microsoft365WebhookTests.cs | 67 +++ tools/BetterMail.UiPreview/Program.Tunnels.cs | 99 ++++ tools/BetterMail.UiPreview/Program.cs | 2 +- 23 files changed, 1392 insertions(+), 235 deletions(-) create mode 100644 docs/mail-webhooks.md create mode 100644 src/BetterMail.App/MailWebhookEndpoint.cs delete mode 100644 src/BetterMail.App/McpSettingsViewModel.BetterTunnels.cs create mode 100644 src/BetterMail.App/TunnelsSettingsViewModel.cs create mode 100644 src/BetterMail.Core/MailWebhooks.cs create mode 100644 src/BetterMail.Microsoft365/Microsoft365MailProvider.Webhooks.cs create mode 100644 tests/BetterMail.Tests/MailWebhookTests.cs create mode 100644 tests/BetterMail.Tests/Microsoft365WebhookTests.cs create mode 100644 tools/BetterMail.UiPreview/Program.Tunnels.cs diff --git a/README.md b/README.md index 47b9849..3359dae 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,11 @@ Save and restart the MCP connection. In **ChatGPT desktop > Settings > MCP serve Attaching `@BetterMail` as a desktop window selects computer control, which is separate from MCP. ChatGPT in a browser does not read this local MCP configuration. -For a public HTTPS endpoint, use **Settings > MCP > BetterTunnels public MCP link**. -Sign in with a Senior account, then select **Create public link**. See -[BetterTunnels MCP setup](docs/mcp-bettertunnels.md) for connection and account details. +For public HTTPS connectivity, use **Settings > Tunnels/Web**. Sign in with a Senior +account and select **Connect**, then enable **Public MCP access** in MCP settings. +Account mail webhooks use the same connection and can be enabled independently under +**Settings > Accounts**. See [BetterTunnels setup](docs/mcp-bettertunnels.md) and +[mail webhooks](docs/mail-webhooks.md) for supported providers and recovery behavior. **Replace access key** immediately revokes the previous key for new requests. Update the header in your client and restart its connection after replacing the key. diff --git a/docs/mail-webhooks.md b/docs/mail-webhooks.md new file mode 100644 index 0000000..2686539 --- /dev/null +++ b/docs/mail-webhooks.md @@ -0,0 +1,37 @@ +# Mail webhook delivery + +**Settings > Accounts > Enable mail webhooks** opts each supported account into +provider change notifications. **Manage Tunnels/Web** opens the shared BetterTunnels +connection settings. A connected Senior account and an open, online BetterMail app +are required. Remote MCP does not need to be enabled. + +Microsoft 365 signed-in mailboxes are supported. Their normal delegated mail token +creates a basic Graph subscription to `me/messages` for created, updated, and deleted +messages. Shared mailboxes continue polling: their subscriptions require application +permissions, which BetterMail's delegated sign-in does not provide. Gmail continues +polling; Gmail push requires Google Cloud Pub/Sub provisioning, which is not yet +implemented. + +Notifications contain change metadata, not downloaded message content. BetterMail +validates the subscription/client secret, persists a versioned pending-refresh marker, +and acknowledges the webhook before running incremental sync for the signed-in +mailbox. Existing delta/history cursors remain authoritative. Pending work is retained +while the app is busy, on refresh failure, and across restart; an event received during +a refresh cannot be cleared by the earlier refresh completing. + +Subscriptions renew before expiry and respond to lifecycle reauthorization/removal +notifications. Reconnection, expired subscriptions, and changed notification URLs are +reconciled automatically. Disabling account webhooks removes the remote subscription; +removing an account attempts cleanup before signing out. Disconnecting Tunnels/Web +preserves the account's opt-in choice and stops public delivery. If remote cleanup +fails, local validation still rejects disabled accounts and remote subscriptions expire. + +Periodic mail sync remains enabled at its existing interval as recovery for missed, +delayed, or unavailable webhook delivery. Webhooks do not guarantee instant mail. + +Provider references: + +- [Microsoft Graph Outlook change notifications](https://learn.microsoft.com/en-us/graph/outlook-change-notifications-overview) +- [Graph webhook validation and acknowledgement](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks) +- [Graph subscription lifetime and latency](https://learn.microsoft.com/en-us/graph/api/resources/subscription) +- [Gmail push and Pub/Sub requirements](https://developers.google.com/workspace/gmail/api/guides/push) diff --git a/docs/mcp-bettertunnels.md b/docs/mcp-bettertunnels.md index c821d1f..03e50f5 100644 --- a/docs/mcp-bettertunnels.md +++ b/docs/mcp-bettertunnels.md @@ -1,67 +1,63 @@ -# BetterTunnels MCP setup +# BetterTunnels public connectivity -BetterMail includes BetterTunnels under **Settings > MCP > BetterTunnels public MCP link**. -A **Senior** account is required for a stable HTTPS link without visitor sign-in. -Local MCP does not require a BetterTunnels account. +BetterMail manages BetterTunnels in **Settings > Tunnels/Web**. A **Senior** account +is required for public HTTPS access without visitor sign-in. BetterMail must remain +open and online; the tunnel does not host mail independently of the app. ## Connect -1. Enable MCP, choose allowed mailboxes and permissions, and **Apply MCP settings**. -2. Select **Sign in to BetterTunnels** and complete sign-in in your browser. - BetterMail checks your account package with BetterTunnels. -3. Select **Create public link** to explicitly enable remote access. -4. Once connected, select **Copy public link**. Add that entire HTTPS URL as a - Streamable HTTP MCP server in your client. -5. Set the HTTP header name to `Authorization`. Use **Copy header value** for its - value (`Bearer ` followed by your current BetterMail access key). -6. Save and restart your client's MCP connection. - -The URL ends in `/bm/`. That private path is generated once -from 32 cryptographically random bytes and saved in the encrypted mail database. -Restarts, app updates, port changes, and access-key rotation preserve it. The MCP -key is never included in the URL. Changing the local port can change the tunnel -hostname, so copy the new complete URL afterward. - -Attaching the BetterMail desktop window selects computer control; it does not -register an MCP server. Use your client's MCP server setup instead. +1. Open **Settings > Tunnels/Web**, select **Sign in to BetterTunnels**, and complete + browser sign-in. BetterMail checks your account package. +2. Select **Connect**. The page shows the public connection status and which features + use it. Enabled connections resume on startup and reconnect after network failures. +3. Enable the features you want separately: + - **MCP:** enable the local endpoint, choose permitted mailboxes/accounts and + permissions, enable **Public MCP access**, and apply MCP settings. Copy the + complete public MCP URL from the MCP page. Configure your MCP client's + `Authorization` header with **Copy header value** (`Bearer ` plus the access key). + - **Accounts:** enable **mail webhooks** for each supported account. See + [mail webhook delivery](mail-webhooks.md). + +Both feature pages link to Tunnels/Web. Public MCP, account webhooks, both, or neither +can be enabled. Local MCP works independently of public connectivity. ## Stop, reconnect, and sign out -- **Stop public link** immediately disconnects remote access and disables automatic - reconnection. Local MCP stays available. It also cancels pending browser sign-in. -- **Sign out** stops the link and removes BetterMail's saved BetterTunnels credential. -- Disabling MCP or exiting BetterMail disconnects the tunnel. An explicitly enabled - public link resumes when MCP runs again, after verifying Senior access. -- Temporary network failures reconnect automatically. The app checks Senior access - on every reconnect and once per minute while connected; an invalid session or - lost entitlement stops access. If the account session expires or the network's - public IP changes, sign in again and create the link again. -- BetterMail must remain open and online. A stable hostname does not host mail - independently of the app. +- Disabling public MCP removes public MCP access while leaving webhooks connected. +- Disabling an account's webhooks removes its subscription while leaving MCP connected. +- **Disconnect** stops both public features and disables automatic reconnection. + Their individual choices are preserved. Local MCP and periodic mail sync continue. +- **Sign out** also removes the saved BetterTunnels credential. +- Temporary network failures reconnect automatically. BetterMail checks Senior access + on each reconnect and once per minute while connected. Lost entitlement or an + invalid session stops the connection. If the session expires or your public IP + changes, sign in again and connect. +- Closing BetterMail disconnects public access. On resume, normal sync catches up and + enabled webhook subscriptions are renewed or recreated as necessary. + +Existing enabled public MCP links migrate with public MCP access enabled. Their +private path and saved tunnel identity remain stable. Changing the local MCP port +does not change the tunnel identity. MCP permissions and the bearer key still apply. ## Security and implementation -BetterTunnels credentials are stored in the encrypted mail database separately -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 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. -Clients must support server-side Streamable HTTP and a configured authorization -header; this does not enable direct cross-origin browser JavaScript access. - -Requests and frames are bounded, HTTP responses stream through the tunnel, and -cancellation/shutdown closes pending requests. HTTP redirects are not followed. -The integration does not expose other local services or relay arbitrary WebSockets. - -Automated tests use a local BetterTunnels protocol server and a real BetterMail MCP -listener to verify sign-in, Senior gating, authenticated initialization, bad-key and -Origin rejection, key rotation, path restrictions, reconnect, and entitlement loss. -A live Senior-account browser sign-in and external ChatGPT connection still need -verification against the hosted service. +BetterTunnels credentials and webhook subscription secrets are stored in the encrypted +mail database, separately from the rotatable MCP key. No CLI installation, child +process, token arguments, or plaintext token file is required. BetterMail uses .NET +HTTP/WebSocket clients with the BetterTunnels v1 HTTP relay protocol. + +The shared connection forwards only explicitly permitted routes to fixed loopback +targets. Public MCP must be enabled independently; evidence routes remain GET-only +with validated record IDs. Webhooks have their own POST-only route and provider +validation, without access to MCP tools or its key. Only the webhook validation +query `validationToken` is permitted; arbitrary paths and query strings are rejected. +Origin headers are preserved and arbitrary browser origins are rejected. + +Requests/frames are bounded, responses stream through the relay, and shutdown closes +pending requests. Redirects are not followed. Other local services and arbitrary +WebSockets are not exposed. + +Automated tests use local HTTP/WebSocket servers and real listeners to check all four +feature combinations, independent disablement, subscription lifecycle recovery, +validation, bearer authentication, forwarding restrictions, reconnect, and entitlement +loss. Hosted BetterTunnels/Graph delivery still needs verification with a real account. diff --git a/src/BetterMail.App/App.axaml.cs b/src/BetterMail.App/App.axaml.cs index 42ae3ad..7b42079 100644 --- a/src/BetterMail.App/App.axaml.cs +++ b/src/BetterMail.App/App.axaml.cs @@ -189,6 +189,7 @@ private async Task StartMainWindowAsync( mainWindow.Show(); startupWindow.Close(); await viewModel.InitializeAsync(); + await viewModel.Tunnels.InitializeAsync(); await viewModel.Mcp.InitializeAsync(); await mainWindow.RestorePreviewWindowsAsync(); _ready = true; @@ -311,7 +312,11 @@ private async Task ShowDefaultMailPromptAsync() private async Task DisposeStoreAsync() { - if (_viewModel is { } viewModel) await viewModel.Mcp.DisposeAsync(); + if (_viewModel is { } viewModel) + { + await viewModel.Tunnels.DisposeAsync(); + await viewModel.Mcp.DisposeAsync(); + } var store = Interlocked.Exchange(ref _store, null); if (store is not null) { diff --git a/src/BetterMail.App/BetterTunnelsClient.cs b/src/BetterMail.App/BetterTunnelsClient.cs index 4b05393..f7460d4 100644 --- a/src/BetterMail.App/BetterTunnelsClient.cs +++ b/src/BetterMail.App/BetterTunnelsClient.cs @@ -14,9 +14,11 @@ internal sealed class BetterTunnelsClient : IDisposable private readonly HttpClient _api = new(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(20) }; private readonly HttpClient _local = new(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false, UseCookies = false }) { Timeout = Timeout.InfiniteTimeSpan }; private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + private readonly Func? _createRequest; - internal BetterTunnelsClient(Uri? server = null) + internal BetterTunnelsClient(Uri? server = null, Func? createRequest = null) { + _createRequest = createRequest; _server = server ?? new("https://connect.tunnels.betterportal.dev"); _api.DefaultRequestHeaders.UserAgent.ParseAdd("BetterMail/1.0 BetterTunnels/1.0.21"); } @@ -217,7 +219,7 @@ async Task ForwardAsync(TunnelFrame frame, CancellationTokenSource requestLifeti { try { - using var request = CreateLocalRequest(frame, endpoint); + using var request = _createRequest is null ? CreateLocalRequest(frame, endpoint) : _createRequest(frame); using var response = await _local.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestLifetime.Token); var headers = response.Headers.Concat(response.Content.Headers).ToDictionary(pair => pair.Key.ToLowerInvariant(), pair => string.Join(", ", pair.Value)); await SendAsync(new { type = "response.start", frame.RequestId, status = (int)response.StatusCode, headers }, requestLifetime.Token); diff --git a/src/BetterMail.App/MailWebhookEndpoint.cs b/src/BetterMail.App/MailWebhookEndpoint.cs new file mode 100644 index 0000000..afa971f --- /dev/null +++ b/src/BetterMail.App/MailWebhookEndpoint.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; + +namespace BetterMail.App; + +internal sealed class MailWebhookEndpoint : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly string _path; + public Uri Address => new(_app.Urls.Single() + _path); + + public MailWebhookEndpoint(string path, Func enabled, + Func> receive) + { + _path = path; + var builder = McpEndpoint.CreateHostBuilder(); + builder.WebHost.ConfigureKestrel(options => + { + options.Listen(IPAddress.Loopback, 0); + options.Limits.MaxRequestBodySize = 1024 * 1024; + }); + _app = builder.Build(); + _app.MapPost(path, async context => + { + var request = context.Request; + if (!enabled() || context.Connection.RemoteIpAddress is not { } remote || !IPAddress.IsLoopback(remote) || + request.Host.Host != "127.0.0.1" || request.Host.Port != context.Connection.LocalPort || request.Headers.ContainsKey("Origin")) + { + context.Response.StatusCode = 403; + return; + } + context.Response.Headers.CacheControl = "no-store"; + if (request.Query.Count != 0) + { + if (request.Query.Count != 1 || !request.Query.TryGetValue("validationToken", out var token) || + token.Count != 1 || token.ToString().Length is 0 or > 2048) + { + context.Response.StatusCode = 400; + return; + } + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync(token.ToString(), context.RequestAborted); + return; + } + try + { + using var document = await JsonDocument.ParseAsync(request.Body, + new JsonDocumentOptions { MaxDepth = 16 }, context.RequestAborted); + if (document.RootElement.ValueKind != JsonValueKind.Object || !document.RootElement.TryGetProperty("value", out var value) || value.ValueKind != JsonValueKind.Array || + value.GetArrayLength() is 0 or > 100) + { + context.Response.StatusCode = 400; + return; + } + // Every accepted event is durably marked before acknowledging the delivery. + context.Response.StatusCode = await receive(value, context.RequestAborted) ? 202 : 403; + } + catch (JsonException) { context.Response.StatusCode = 400; } + }); + } + + internal static HttpRequestMessage CreateLocalRequest(TunnelFrame frame, Uri endpoint) + { + var target = frame.Path ?? ""; + var question = target.IndexOf('?'); + var path = question < 0 ? target : target[..question]; + var query = question < 0 ? "" : target[question..]; + if (frame.Method != "POST" || path != endpoint.AbsolutePath || target.Length > 4096 || target.Contains('#')) + throw new InvalidDataException("Invalid webhook request target."); + if (query.Length != 0) + { + var values = QueryHelpers.ParseQuery(query); + if (values.Count != 1 || !values.TryGetValue("validationToken", out var token) || token.Count != 1 || + token.ToString().Length is 0 or > 2048) + throw new InvalidDataException("Invalid webhook validation query."); + } + var body = Convert.FromBase64String(frame.Body ?? ""); + if (body.Length > 1024 * 1024) throw new InvalidDataException("Webhook request is too large."); + var request = new HttpRequestMessage(HttpMethod.Post, new Uri(endpoint.AbsoluteUri + query)) + { Content = new ByteArrayContent(body) }; + foreach (var (name, value) in frame.Headers ?? []) + { + if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) request.Content.Headers.TryAddWithoutValidation(name, value); + else if (name.Equals("Origin", StringComparison.OrdinalIgnoreCase)) request.Headers.TryAddWithoutValidation(name, value); + } + request.Headers.Host = endpoint.Authority; + return request; + } + + public Task StartAsync(CancellationToken ct = default) => _app.StartAsync(ct); + public async ValueTask DisposeAsync() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + try { await _app.StopAsync(timeout.Token); } + finally { await _app.DisposeAsync(); } + } +} diff --git a/src/BetterMail.App/MainWindowViewModel.Sync.cs b/src/BetterMail.App/MainWindowViewModel.Sync.cs index e8b4ffa..306816e 100644 --- a/src/BetterMail.App/MainWindowViewModel.Sync.cs +++ b/src/BetterMail.App/MainWindowViewModel.Sync.cs @@ -53,18 +53,22 @@ await store.SaveSyncHealthAsync(new(account.AccountId, mailbox.Id, mailbox.Addre ownFailures.IsEmpty ? null : string.Join(Environment.NewLine, ownFailures))); } - private async Task SyncAsync() + private async Task SyncAsync() => await SyncAccountsAsync(); + + private Task SyncWebhookAccountAsync(MailAccount account) => SyncAccountsAsync(account); + + private async Task SyncAccountsAsync(MailAccount? webhookAccount = null) { var provider = _provider; var store = _store; if (IsBusy || provider is null || store is null || Accounts.Count == 0) { - return; + return false; } Interlocked.Exchange(ref _syncPending, 1); if (Interlocked.CompareExchange(ref _syncRunning, 1, 0) != 0) { - return; + return false; } StopContactPhotoSync(); @@ -96,7 +100,10 @@ private async Task SyncAsync() if (BusyActions.Any(action => action.Kind != MailActionKind.Send && action.Error is not null)) actions.Detail = "Needs attention — review Busy"; var engine = new SyncEngine(provider, store); - var mailboxes = Mailboxes.ToArray(); + var scopedAccount = webhookAccount; + webhookAccount = null; // Any additional full-sync request must retain its full scope. + var mailboxes = Mailboxes.Where(mailbox => scopedAccount is null || + (mailbox.AccountId == scopedAccount.AccountId && !mailbox.IsShared)).ToArray(); await RunSyncStepAsync(mail, async () => { var work = (from account in Accounts.ToArray() @@ -174,7 +181,9 @@ await Task.WhenAll(work.Select(async item => _ = SyncAsync(); } else StartContactPhotoSync(); + Tunnels.NotifySyncIdle(); } + return mailFailures.IsEmpty; } private CancellationTokenSource? _contactPhotoSyncCancellation; diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs index f5b9b54..28bb6e7 100644 --- a/src/BetterMail.App/MainWindowViewModel.cs +++ b/src/BetterMail.App/MainWindowViewModel.cs @@ -333,11 +333,16 @@ public MainWindowViewModel( SettingsTabs.Add(new("Signatures")); SettingsTabs.Add(new("Accounts")); SettingsTabs.Add(new("MCP")); + SettingsTabs.Add(new("Tunnels/Web")); SettingsTabs.Add(new("About")); var evidence = _store is null ? null : new EvidenceService(_store, () => _provider, new AttachmentTextExtractor(EvidenceOcr.RecognizeAsync)); + Tunnels = new(_store, () => _provider as IMailWebhookProvider, + async account => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => SyncWebhookAccountAsync(account))); + Tunnels.Accounts.CollectionChanged += (_, _) => RaisePropertyChanged(nameof(SettingsAccounts)); + Accounts.CollectionChanged += (_, _) => _ = Tunnels.RefreshAccountsAsync(); Mcp = new(_store, async () => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(RefreshMcpChangesAsync), - async (sender, id, message) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => QueueSendAsync(sender, id, message)), evidence, () => _workspaceProvider, () => _provider); + async (sender, id, message) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => QueueSendAsync(sender, id, message)), evidence, () => _workspaceProvider, () => _provider, Tunnels); _selectedSettingsTab = SettingsTabs[0]; Drafts.CollectionChanged += (_, _) => RaiseDraftState(); BusyActions.CollectionChanged += (_, _) => { RaiseDraftState(); MailActionStateChanged(); }; @@ -377,7 +382,7 @@ public MainWindowViewModel( .Select(mailbox => new SharedMailboxSettingsItem( mailbox, permission => ChangeSharedMailboxPermissionAsync(mailbox, permission))) - .ToArray())); + .ToArray(), Tunnels.ForAccount(account))); public ObservableCollection Messages { get; } = []; public ObservableCollection SelectedMessages { get; } = []; public ObservableCollection MailQuickActions { get; } = []; @@ -1308,6 +1313,7 @@ public SettingsTabItem? SelectedSettingsTab RaisePropertyChanged(nameof(ShowSignatureSettings)); RaisePropertyChanged(nameof(ShowAccountSettings)); RaisePropertyChanged(nameof(ShowMcpSettings)); + RaisePropertyChanged(nameof(ShowTunnelsSettings)); RaisePropertyChanged(nameof(ShowAboutSettings)); } } @@ -1319,6 +1325,8 @@ public SettingsTabItem? SelectedSettingsTab public bool ShowAccountSettings => SelectedSettingsTab?.Name == "Accounts"; public bool ShowMcpSettings => SelectedSettingsTab?.Name == "MCP"; public McpSettingsViewModel Mcp { get; } + public TunnelsSettingsViewModel Tunnels { get; } + public bool ShowTunnelsSettings => SelectedSettingsTab?.Name == "Tunnels/Web"; public bool ShowAboutSettings => SelectedSettingsTab?.Name == "About"; public SignatureItem? SelectedSignature @@ -1655,6 +1663,7 @@ private void RefreshMailProviderRouter() if (_mailProviders.Count > 0) { _provider = new MailProviderRouter(_mailProviders.Select(static pair => (pair.Key, pair.Value))); + _ = Tunnels.RefreshAccountsAsync(); RefreshMailActionCommands(); } } @@ -1756,6 +1765,7 @@ private async Task RemovePendingAccountAsync() StopContactPhotoSync(); await RunBusyAsync("Removing account…", async () => { + await Tunnels.RemoveAccountAsync(account); if (_accountProviders.TryGetValue(account.ProviderId, out var accountProvider)) { await accountProvider.SignOutAsync(account.AccountId); @@ -5739,7 +5749,7 @@ public sealed class MailboxFolderGroup(Mailbox mailbox, IReadOnlyList AccountColors.For(Mailbox.Id); } -public sealed record AccountSettingsItem(MailAccount Account, IReadOnlyList SharedMailboxes) +public sealed record AccountSettingsItem(MailAccount Account, IReadOnlyList SharedMailboxes, WebhookAccountChoice? Webhooks = null) { public string DisplayName => Account.DisplayName; public string EmailAddress => Account.EmailAddress; diff --git a/src/BetterMail.App/McpSettingsViewModel.BetterTunnels.cs b/src/BetterMail.App/McpSettingsViewModel.BetterTunnels.cs deleted file mode 100644 index 3588d47..0000000 --- a/src/BetterMail.App/McpSettingsViewModel.BetterTunnels.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Diagnostics; -using BetterMail.Core; - -namespace BetterMail.App; - -public sealed partial class McpSettingsViewModel -{ - private CancellationTokenSource? _loginCancellation; - private CancellationTokenSource? _tunnelCancellation; - private Task? _tunnelTask; - private string _tunnelStatus = "Public link stopped. A BetterTunnels Senior account is required."; - private string _publicEndpointUrl = ""; - public string TunnelStatus { get => _tunnelStatus; private set => SetProperty(ref _tunnelStatus, value); } - public string PublicEndpointUrl { get => _publicEndpointUrl; private set => SetProperty(ref _publicEndpointUrl, value); } - public AsyncCommand SignInTunnelCommand { get; } - public AsyncCommand StartTunnelCommand { get; } - public AsyncCommand StopTunnelCommand { get; } - public AsyncCommand SignOutTunnelCommand { get; } - - private void RefreshTunnelCommands() - { - SignInTunnelCommand.Refresh(); - StartTunnelCommand.Refresh(); - StopTunnelCommand.Refresh(); - SignOutTunnelCommand.Refresh(); - } - - private async Task SignInTunnelAsync() - { - using var cancellation = new CancellationTokenSource(); - _loginCancellation = cancellation; - try - { - TunnelStatus = "Complete BetterTunnels sign-in in your browser. Stop public link cancels sign-in."; - using var client = new BetterTunnelsClient(); - var account = await client.SignInAsync(uri => Process.Start(new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true }), cancellation.Token); - var package = await client.GetPackageAsync(account.Token, cancellation.Token); - await _gate.WaitAsync(cancellation.Token); - try - { - if (_store is null || _disposed) return; - await DisconnectTunnelAsync(); - await _store.SaveBetterTunnelsConfigurationAsync(account, cancellation.Token); - TunnelStatus = $"Signed in {account.Email} · {package}. " + (package == "senior" ? "Create a public link to enable remote MCP access." : "Public links require the Senior package."); - } - finally { _gate.Release(); } - } - catch (OperationCanceledException) { if (!_disposed) TunnelStatus = "BetterTunnels sign-in cancelled or expired."; } - catch (UnauthorizedAccessException exception) { TunnelStatus = exception.Message; } - catch (Exception) { TunnelStatus = "Could not sign in to BetterTunnels. Try again."; } - finally { _loginCancellation = null; } - } - - private async Task StartTunnelAsync() - { - await _gate.WaitAsync(); - try - { - if (_store is null || _disposed) return; - if (_endpoint is null || !_active.Enabled) { TunnelStatus = "Enable MCP and apply settings before creating a public link."; return; } - var account = await _store.GetBetterTunnelsConfigurationAsync(); - if (account.Token.Length == 0) { TunnelStatus = "Sign in to a BetterTunnels Senior account first."; return; } - using var client = new BetterTunnelsClient(); - TunnelStatus = "Checking Senior access…"; - if (await client.GetPackageAsync(account.Token, CancellationToken.None) != "senior") - { - await DisconnectTunnelAsync(); - TunnelStatus = "Public MCP links require the BetterTunnels Senior package."; - return; - } - await _store.SaveBetterTunnelsConfigurationAsync(account with { Enabled = true }); - await RestoreTunnelAsync(); - } - catch (UnauthorizedAccessException exception) { TunnelStatus = exception.Message; } - catch (Exception) { TunnelStatus = "Could not create the public link. Check your connection and try again."; } - finally { _gate.Release(); } - } - - private async Task RestoreTunnelAsync() - { - await DisconnectTunnelAsync(); - var account = await _store!.GetBetterTunnelsConfigurationAsync(); - if (!account.Enabled || account.Token.Length == 0 || _endpoint is null) return; - var cancellation = new CancellationTokenSource(); - _tunnelCancellation = cancellation; - var endpoint = new Uri(_endpoint.Address); - _tunnelTask = RunTunnelAsync(); - async Task RunTunnelAsync() - { - using var client = new BetterTunnelsClient(); - await client.RunAsync(account.Token, endpoint, (status, url) => - { - if (_tunnelCancellation != cancellation || _disposed) return; - TunnelStatus = status; - PublicEndpointUrl = url; - }, cancellation.Token); - } - } - - private async Task StopTunnelAsync(bool signOut) - { - _loginCancellation?.Cancel(); - // Revoke the connection immediately, even if another settings operation is awaiting the network. - _tunnelCancellation?.Cancel(); - await _gate.WaitAsync(); - try - { - if (_store is null || _disposed) return; - await DisconnectTunnelAsync(); - var account = await _store.GetBetterTunnelsConfigurationAsync(); - await _store.SaveBetterTunnelsConfigurationAsync(signOut ? new() : account with { Enabled = false }); - TunnelStatus = signOut ? "Signed out of BetterTunnels. Public link stopped." : "Public link stopped."; - } - catch (Exception) { TunnelStatus = "Public link stopped, but its saved setting could not be updated. Try again."; } - finally { _gate.Release(); } - } - - private async Task DisconnectTunnelAsync() - { - var cancellation = _tunnelCancellation; - _tunnelCancellation = null; - if (cancellation is not null) - { - await cancellation.CancelAsync(); - if (_tunnelTask is not null) await _tunnelTask; - cancellation.Dispose(); - } - _tunnelTask = null; - PublicEndpointUrl = ""; - TunnelStatus = "Public link stopped."; - } -} diff --git a/src/BetterMail.App/McpSettingsViewModel.cs b/src/BetterMail.App/McpSettingsViewModel.cs index 07db6ab..07ee7b0 100644 --- a/src/BetterMail.App/McpSettingsViewModel.cs +++ b/src/BetterMail.App/McpSettingsViewModel.cs @@ -39,8 +39,14 @@ public sealed partial class McpSettingsViewModel : ViewModelBase, IAsyncDisposab private string _status = "Disabled"; private bool _disposed; private bool _initialized; + private bool _publicEnabled; + private readonly bool _ownsTunnels; + public TunnelsSettingsViewModel Tunnels { get; } + public string PublicEndpointUrl => Tunnels.PublicMcpUrl; + public string PublicStatus => Tunnels.Status; + public bool PublicEnabled { get => _publicEnabled; set => SetProperty(ref _publicEnabled, value); } - public McpSettingsViewModel(EncryptedMailStore? store, Func refreshAndSync, Func queueSend, EvidenceService? evidence = null, Func? filesProvider = null, Func? mailProvider = null) + public McpSettingsViewModel(EncryptedMailStore? store, Func refreshAndSync, Func queueSend, EvidenceService? evidence = null, Func? filesProvider = null, Func? mailProvider = null, TunnelsSettingsViewModel? tunnels = null) { _store = store; _refreshAndSync = refreshAndSync; @@ -50,10 +56,9 @@ public McpSettingsViewModel(EncryptedMailStore? store, Func refreshAndSync _mailProvider = mailProvider; ApplyCommand = new(ApplyAsync, () => IsAvailable); RotateKeyCommand = new(RotateKeyAsync, () => IsAvailable); - SignInTunnelCommand = new(SignInTunnelAsync, () => IsAvailable); - StartTunnelCommand = new(StartTunnelAsync, () => IsAvailable); - StopTunnelCommand = new(() => StopTunnelAsync(false), () => IsAvailable); - SignOutTunnelCommand = new(() => StopTunnelAsync(true), () => IsAvailable); + _ownsTunnels = tunnels is null; + Tunnels = tunnels ?? new(store); + Tunnels.PropertyChanged += TunnelsChanged; } public bool IsAvailable => _store is not null && _initialized && !_disposed; @@ -80,6 +85,14 @@ public async Task InitializeAsync() _endpointPath = saved.EndpointPath; RaisePropertyChanged(nameof(AccessKey)); RaisePropertyChanged(nameof(EndpointUrl)); + if (_ownsTunnels) await Tunnels.InitializeAsync(); + // Existing explicitly enabled public links retain public MCP access on upgrade. + PublicEnabled = saved.Configuration.PublicEnabled ?? (await _store.GetBetterTunnelsConfigurationAsync()).Enabled; + if (saved.Configuration.PublicEnabled is null) + { + saved = (saved.Configuration with { PublicEnabled = PublicEnabled }, saved.AccessKey, saved.EndpointPath); + await _store.SaveMcpConfigurationAsync(saved.Configuration); + } Enabled = saved.Configuration.Enabled; Port = saved.Configuration.Port; AllowWrites = saved.Configuration.AllowWrites; @@ -92,7 +105,6 @@ public async Task InitializeAsync() RaisePropertyChanged(nameof(IsAvailable)); ApplyCommand.Refresh(); RotateKeyCommand.Refresh(); - RefreshTunnelCommands(); } catch (Exception exception) { Status = "MCP unavailable: " + exception.Message; } } @@ -121,7 +133,7 @@ public async Task RefreshMailboxesAsync(string[]? selected = null) private Task ApplyAsync() => ReconfigureAsync(new(Enabled, Port, AllowWrites, AllowWrites && AllowSending, Mailboxes.Where(item => item.IsSelected).Select(item => item.Id).ToArray(), Drives.Where(item => item.IsSelected).Select(item => item.Id).ToArray(), - Workspaces.Where(item => item.IsSelected).Select(item => item.Id).ToArray()), persist: true); + Workspaces.Where(item => item.IsSelected).Select(item => item.Id).ToArray(), PublicEnabled), persist: true); private async Task ReconfigureAsync(McpConfiguration configuration, bool persist) { @@ -131,7 +143,7 @@ private async Task ReconfigureAsync(McpConfiguration configuration, bool persist if (_store is null || _disposed) return; // Revoke new requests before waiting for the old listener to drain. Volatile.Write(ref _active, new()); - await DisconnectTunnelAsync(); + Tunnels.ConfigureMcp(null); if (_endpoint is { } previous) { _endpoint = null; @@ -151,7 +163,7 @@ private async Task ReconfigureAsync(McpConfiguration configuration, bool persist _endpoint = new(tools, configuration.Port, _endpointPath, () => Volatile.Read(ref _active).Enabled, () => Volatile.Read(ref _accessKey)); await _endpoint.StartAsync(); Status = $"Listening at {_endpoint.Address} · {configuration.MailboxIds?.Length ?? 0} allowed mailboxes"; - await RestoreTunnelAsync(); + Tunnels.ConfigureMcp((configuration.PublicEnabled ?? PublicEnabled) ? new Uri(_endpoint.Address) : null); } catch (Exception exception) { @@ -182,13 +194,13 @@ private async Task RotateKeyAsync() public async ValueTask DisposeAsync() { - _loginCancellation?.Cancel(); + Tunnels.PropertyChanged -= TunnelsChanged; Volatile.Write(ref _active, new()); await _gate.WaitAsync(); try { _disposed = true; - await DisconnectTunnelAsync(); + Tunnels.ConfigureMcp(null); if (_endpoint is { } endpoint) { _endpoint = null; @@ -196,5 +208,12 @@ public async ValueTask DisposeAsync() } } finally { _gate.Release(); } + if (_ownsTunnels) await Tunnels.DisposeAsync(); + } + + private void TunnelsChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args) + { + RaisePropertyChanged(nameof(PublicEndpointUrl)); + RaisePropertyChanged(nameof(PublicStatus)); } } diff --git a/src/BetterMail.App/SettingsView.axaml b/src/BetterMail.App/SettingsView.axaml index 11140fd..f8e8dc2 100644 --- a/src/BetterMail.App/SettingsView.axaml +++ b/src/BetterMail.App/SettingsView.axaml @@ -117,23 +117,54 @@ - - + + + + + -