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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions docs/mail-webhooks.md
Original file line number Diff line number Diff line change
@@ -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)
110 changes: 53 additions & 57 deletions docs/mcp-bettertunnels.md
Original file line number Diff line number Diff line change
@@ -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/<installation-token>`. 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.
7 changes: 6 additions & 1 deletion src/BetterMail.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand Down
6 changes: 4 additions & 2 deletions src/BetterMail.App/BetterTunnelsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TunnelFrame, HttpRequestMessage>? _createRequest;

internal BetterTunnelsClient(Uri? server = null)
internal BetterTunnelsClient(Uri? server = null, Func<TunnelFrame, HttpRequestMessage>? createRequest = null)
{
_createRequest = createRequest;
_server = server ?? new("https://connect.tunnels.betterportal.dev");
_api.DefaultRequestHeaders.UserAgent.ParseAdd("BetterMail/1.0 BetterTunnels/1.0.21");
}
Expand Down Expand Up @@ -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);
Expand Down
101 changes: 101 additions & 0 deletions src/BetterMail.App/MailWebhookEndpoint.cs
Original file line number Diff line number Diff line change
@@ -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<bool> enabled,
Func<JsonElement, CancellationToken, Task<bool>> 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(); }
}
}
Loading
Loading