From 77053117ccf375f6fb960b4e811da61710fb9a70 Mon Sep 17 00:00:00 2001 From: Chris Muench Date: Thu, 30 Jul 2026 20:47:02 -0700 Subject: [PATCH] Add direct-to-Google-Workspace and direct-to-Microsoft-365 relay delivery Routing rules can now point a domain at the recipient's own inbound mail server instead of a paid relay: GoogleWorkspace delivers unauthenticated to Google's unified inbound endpoint (smtp.google.com, fixed for every Workspace domain), and Microsoft365 delivers unauthenticated to a tenant-supplied *.mail.protection.outlook.com host. Both reuse the existing MailKit SMTP transport (extracted into SmtpDelivery) and are wired into SmtpPortGuard so the existing Azure port-25 block still applies. Co-Authored-By: Claude Sonnet 5 --- docs/SPEC.md | 20 +++++++ .../Providers/RelayProviderType.cs | 10 ++++ src/Dispatch.Core/Relays/RelaySettings.cs | 6 ++ src/Dispatch.Core/Relays/SmtpPortGuard.cs | 4 ++ .../GoogleWorkspaceProvider.cs | 22 +++++++ .../Microsoft365Provider.cs | 30 ++++++++++ .../RelayProviderFactory.cs | 2 + src/Dispatch.Providers/SmtpDelivery.cs | 57 +++++++++++++++++++ src/Dispatch.Providers/SmtpProvider.cs | 44 +------------- src/Dispatch.UI/src/lib/providers.ts | 24 +++++++- .../RelayProviderSchemaTests.cs | 22 +++++++ .../Dispatch.Core.Tests/SmtpPortGuardTests.cs | 10 ++++ .../Microsoft365ProviderTests.cs | 31 ++++++++++ .../RelayProviderFactoryTests.cs | 2 + 14 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 src/Dispatch.Providers/GoogleWorkspaceProvider.cs create mode 100644 src/Dispatch.Providers/Microsoft365Provider.cs create mode 100644 src/Dispatch.Providers/SmtpDelivery.cs create mode 100644 tests/Dispatch.Core.Tests/RelayProviderSchemaTests.cs create mode 100644 tests/Dispatch.Providers.Tests/Microsoft365ProviderTests.cs diff --git a/docs/SPEC.md b/docs/SPEC.md index 1373893c..33e1824d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1212,6 +1212,26 @@ public interface IRelayProvider A provider that never delivers externally. It captures each message to `spool/captured/` (viewable in the **Local Inbox** page) and logs it as delivered. Ideal for local development so no real emails are sent. (The out-of-the-box default relay is *Unconfigured* - it refuses to relay until a provider is chosen, so mail is never silently delivered or discarded.) +### 8.6 Google Workspace (Direct) + +| Field | Detail | +|---|---| +| Mechanism | MailKit SmtpClient, delivering directly to Google Workspace's unified inbound endpoint | +| Auth | None - unauthenticated MTA-to-MTA delivery, like any internet mail server | +| Required settings | None - host is fixed at `smtp.google.com`, port 25, STARTTLS | +| Notes | Google consolidated inbound MX onto one hostname shared by every Workspace domain. Point a routing rule at this relay for a recipient domain actually hosted on Google Workspace instead of paying for a smart-host relay - the message goes straight to the recipient's real mailbox. | + +### 8.7 Microsoft 365 (Direct) + +| Field | Detail | +|---|---| +| Mechanism | MailKit SmtpClient, delivering directly to a Microsoft 365 tenant's inbound endpoint | +| Auth | None - unauthenticated MTA-to-MTA delivery, like any internet mail server | +| Required settings | Host (the tenant's `*.mail.protection.outlook.com` MX hostname, found in the M365 admin center under Settings → Domains). Port 25 and STARTTLS are fixed. | +| Notes | Unlike Google Workspace, Microsoft 365's inbound endpoint is tenant-specific, so it can't be assumed - the admin must supply it. | + +Both direct-delivery providers always connect on outbound port 25, which Azure blocks - saving either relay while running in Azure is rejected with the same message shown for a port-25 generic SMTP relay. + --- ## 9. Embedded Web UI diff --git a/src/Dispatch.Core/Providers/RelayProviderType.cs b/src/Dispatch.Core/Providers/RelayProviderType.cs index 2f68f081..5f11de33 100644 --- a/src/Dispatch.Core/Providers/RelayProviderType.cs +++ b/src/Dispatch.Core/Providers/RelayProviderType.cs @@ -21,4 +21,14 @@ public enum RelayProviderType Smtp2Go, Maileroo, Bird, + + /// Direct, unauthenticated delivery to Google Workspace's unified inbound mail endpoint + /// (smtp.google.com). Every Workspace domain shares this same endpoint, so there is nothing per-domain + /// to configure - only use it for domains actually hosted on Google Workspace. + GoogleWorkspace, + + /// Direct, unauthenticated delivery to a Microsoft 365 tenant's inbound mail endpoint + /// (<tenant>.mail.protection.outlook.com). Unlike Google Workspace this is tenant-specific, so the + /// hostname must be typed in. + Microsoft365, } diff --git a/src/Dispatch.Core/Relays/RelaySettings.cs b/src/Dispatch.Core/Relays/RelaySettings.cs index 36fd4ea8..528915ec 100644 --- a/src/Dispatch.Core/Relays/RelaySettings.cs +++ b/src/Dispatch.Core/Relays/RelaySettings.cs @@ -75,6 +75,12 @@ public static class RelayProviderSchema new("WorkspaceId", "bird.workspace_id", Secret: false, Required: true), new("ChannelId", "bird.channel_id", Secret: false, Required: true), ], + // Fixed endpoint, shared by every Workspace domain - nothing to configure. + RelayProviderType.GoogleWorkspace => [], + RelayProviderType.Microsoft365 => + [ + new("Host", "m365.host", Secret: false, Required: true), + ], _ => [], }; } diff --git a/src/Dispatch.Core/Relays/SmtpPortGuard.cs b/src/Dispatch.Core/Relays/SmtpPortGuard.cs index 39b5c7c4..a128f069 100644 --- a/src/Dispatch.Core/Relays/SmtpPortGuard.cs +++ b/src/Dispatch.Core/Relays/SmtpPortGuard.cs @@ -16,6 +16,10 @@ public static class SmtpPortGuard /// public static bool UsesOutboundPort25(RelayProviderType provider, IReadOnlyDictionary settings) { + // Direct MX delivery (Google Workspace / Microsoft 365) always connects on 25 - it isn't + // configurable, so there's no port setting to inspect. + if (provider is RelayProviderType.GoogleWorkspace or RelayProviderType.Microsoft365) return true; + if (provider != RelayProviderType.Smtp) return false; var raw = settings.TryGetValue("Port", out var value) ? value : null; var port = int.TryParse(raw, out var p) ? p : 25; // matches SmtpProvider: blank -> 25 diff --git a/src/Dispatch.Providers/GoogleWorkspaceProvider.cs b/src/Dispatch.Providers/GoogleWorkspaceProvider.cs new file mode 100644 index 00000000..d4de9e02 --- /dev/null +++ b/src/Dispatch.Providers/GoogleWorkspaceProvider.cs @@ -0,0 +1,22 @@ +using Dispatch.Core.Providers; +using MailKit.Security; + +namespace Dispatch.Providers; + +/// +/// Direct, unauthenticated delivery to Google Workspace's unified inbound mail endpoint (spec §8.6). Google +/// consolidated inbound MX onto a single hostname for every Workspace domain, so unlike Microsoft 365 there +/// is nothing tenant-specific to configure - just point a routing rule at this relay for the recipient +/// domain instead of paying for a smart-host relay. Only use this for domains actually hosted on Google +/// Workspace; RCPT TO acceptance at the endpoint is what enforces that, not this code. +/// +public sealed class GoogleWorkspaceProvider : IRelayProvider +{ + private const string Host = "smtp.google.com"; + private const int Port = 25; + + public string Name => "GoogleWorkspace"; + + public Task SendAsync(RelayMessage message, CancellationToken ct) => + SmtpDelivery.SendAsync(Host, Port, SecureSocketOptions.StartTls, user: null, pass: null, message, ct); +} diff --git a/src/Dispatch.Providers/Microsoft365Provider.cs b/src/Dispatch.Providers/Microsoft365Provider.cs new file mode 100644 index 00000000..70f1b354 --- /dev/null +++ b/src/Dispatch.Providers/Microsoft365Provider.cs @@ -0,0 +1,30 @@ +using Dispatch.Core.Providers; +using MailKit.Security; + +namespace Dispatch.Providers; + +/// +/// Direct, unauthenticated delivery to a Microsoft 365 tenant's inbound mail endpoint (spec §8.7) - the +/// same idea as , but the endpoint is tenant-specific +/// (<tenant>.mail.protection.outlook.com), so it has to be typed in rather than assumed. Settings: Host. +/// Only use this for domains actually hosted on Microsoft 365; RCPT TO acceptance at the endpoint is what +/// enforces that, not this code. +/// +public sealed class Microsoft365Provider(RelayConfig config) : IRelayProvider +{ + private const int Port = 25; + + public string Name => "Microsoft365"; + + public Task SendAsync(RelayMessage message, CancellationToken ct) + { + var host = Setting("Host"); + if (string.IsNullOrWhiteSpace(host)) + throw new InvalidOperationException("Microsoft 365 relay 'Host' is not configured."); + + return SmtpDelivery.SendAsync(host, Port, SecureSocketOptions.StartTls, user: null, pass: null, message, ct); + } + + private string? Setting(string key) => + config.Settings.TryGetValue(key, out var v) ? v : null; +} diff --git a/src/Dispatch.Providers/RelayProviderFactory.cs b/src/Dispatch.Providers/RelayProviderFactory.cs index f27d1d3e..bb598fd1 100644 --- a/src/Dispatch.Providers/RelayProviderFactory.cs +++ b/src/Dispatch.Providers/RelayProviderFactory.cs @@ -27,6 +27,8 @@ public sealed class RelayProviderFactory( RelayProviderType.Smtp2Go => new Smtp2GoProvider(config, httpClientFactory.CreateClient("smtp2go")), RelayProviderType.Maileroo => new MailerooProvider(config, httpClientFactory.CreateClient("maileroo")), RelayProviderType.Bird => new BirdProvider(config, httpClientFactory.CreateClient("bird")), + RelayProviderType.GoogleWorkspace => new GoogleWorkspaceProvider(), + RelayProviderType.Microsoft365 => new Microsoft365Provider(config), _ => throw new NotSupportedException($"Relay provider '{config.Provider}' is not supported."), }; } diff --git a/src/Dispatch.Providers/SmtpDelivery.cs b/src/Dispatch.Providers/SmtpDelivery.cs new file mode 100644 index 00000000..86df656f --- /dev/null +++ b/src/Dispatch.Providers/SmtpDelivery.cs @@ -0,0 +1,57 @@ +using Dispatch.Core.Providers; +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; + +namespace Dispatch.Providers; + +/// +/// Shared MailKit connect+send core for every SMTP-transport relay provider (generic SMTP, Google +/// Workspace direct, Microsoft 365 direct) - only the effective host/port/TLS/credentials differ per +/// provider; the connection, envelope handling, and error classification are identical. +/// +internal static class SmtpDelivery +{ + public static async Task SendAsync( + string host, int port, SecureSocketOptions secure, string? user, string? pass, + RelayMessage message, CancellationToken ct) + { + using var client = new SmtpClient(); + try + { + await client.ConnectAsync(host, port, secure, ct); + if (!string.IsNullOrEmpty(user)) + await client.AuthenticateAsync(user, pass ?? "", ct); + + // Deliver to the SMTP envelope recipients (MAIL FROM / RCPT TO), not whatever the message headers + // happen to list - otherwise MailKit derives recipients from To/Cc/Bcc headers and silently drops + // Bcc recipients (which are envelope-only, never in the headers) and any header/envelope mismatch. + var recipients = (message.ToAddresses.Count > 0 + ? message.ToAddresses + : message.Message.To.Mailboxes.Select(m => m.Address)) + .Select(MailboxAddress.Parse).ToList(); + + string response; + if (recipients.Count > 0) + { + var sender = !string.IsNullOrWhiteSpace(message.FromAddress) + ? MailboxAddress.Parse(message.FromAddress) + : message.Message.From.Mailboxes.FirstOrDefault() + ?? throw new InvalidOperationException("Message has no sender address."); + response = await client.SendAsync(message.Message, sender, recipients, ct); + } + else + { + response = await client.SendAsync(message.Message, ct); + } + await client.DisconnectAsync(quit: true, ct); + + // Spec §11.6 detail format (250 + server response line). + return RelayResult.Success(detail: $"250 {response}"); + } + catch (Exception ex) when (SmtpProvider.IsTransient(ex)) + { + throw new TransientRelayException(ex.Message, ex); + } + } +} diff --git a/src/Dispatch.Providers/SmtpProvider.cs b/src/Dispatch.Providers/SmtpProvider.cs index e583fa6f..46aca2cc 100644 --- a/src/Dispatch.Providers/SmtpProvider.cs +++ b/src/Dispatch.Providers/SmtpProvider.cs @@ -1,7 +1,6 @@ using Dispatch.Core.Providers; using MailKit.Net.Smtp; using MailKit.Security; -using MimeKit; using System.Net.Sockets; namespace Dispatch.Providers; @@ -19,54 +18,15 @@ public sealed class SmtpProvider : IRelayProvider public string Name => "Smtp"; - public async Task SendAsync(RelayMessage message, CancellationToken ct) + public Task SendAsync(RelayMessage message, CancellationToken ct) { var host = Setting("Host"); if (string.IsNullOrWhiteSpace(host)) throw new InvalidOperationException("SMTP relay 'Host' is not configured."); var port = int.TryParse(Setting("Port"), out var p) ? p : 25; - var user = Setting("Username"); - var pass = Setting("Password"); var secure = ParseTls(Setting("TlsMode")); - - using var client = new SmtpClient(); - try - { - await client.ConnectAsync(host, port, secure, ct); - if (!string.IsNullOrEmpty(user)) - await client.AuthenticateAsync(user, pass ?? "", ct); - - // Deliver to the SMTP envelope recipients (MAIL FROM / RCPT TO), not whatever the message headers - // happen to list - otherwise MailKit derives recipients from To/Cc/Bcc headers and silently drops - // Bcc recipients (which are envelope-only, never in the headers) and any header/envelope mismatch. - var recipients = (message.ToAddresses.Count > 0 - ? message.ToAddresses - : message.Message.To.Mailboxes.Select(m => m.Address)) - .Select(MailboxAddress.Parse).ToList(); - - string response; - if (recipients.Count > 0) - { - var sender = !string.IsNullOrWhiteSpace(message.FromAddress) - ? MailboxAddress.Parse(message.FromAddress) - : message.Message.From.Mailboxes.FirstOrDefault() - ?? throw new InvalidOperationException("Message has no sender address."); - response = await client.SendAsync(message.Message, sender, recipients, ct); - } - else - { - response = await client.SendAsync(message.Message, ct); - } - await client.DisconnectAsync(quit: true, ct); - - // Spec §11.6 detail format (250 + server response line). - return RelayResult.Success(detail: $"250 {response}"); - } - catch (Exception ex) when (IsTransient(ex)) - { - throw new TransientRelayException(ex.Message, ex); - } + return SmtpDelivery.SendAsync(host, port, secure, Setting("Username"), Setting("Password"), message, ct); } private string? Setting(string key) => diff --git a/src/Dispatch.UI/src/lib/providers.ts b/src/Dispatch.UI/src/lib/providers.ts index d3ee4cde..9f2ce923 100644 --- a/src/Dispatch.UI/src/lib/providers.ts +++ b/src/Dispatch.UI/src/lib/providers.ts @@ -55,6 +55,18 @@ export const PROVIDER_FIELDS: Record = { { name: "WorkspaceId", secret: false, required: true }, { name: "ChannelId", secret: false, required: true, placeholder: "your email channel id" }, ], + // Direct MX delivery - no relay account, no credentials. Google's inbound endpoint is fixed and shared by + // every Workspace domain, so there's nothing to fill in. + GoogleWorkspace: [], + // Microsoft 365's inbound endpoint is tenant-specific, so (unlike Google) the hostname must be typed in. + Microsoft365: [ + { + name: "Host", secret: false, required: true, + label: "Inbound mail hostname", + placeholder: "yourtenant-com.mail.protection.outlook.com", + help: "Find this in the Microsoft 365 admin center under Settings → Domains → (your domain) → DNS records - it always ends in .mail.protection.outlook.com.", + }, + ], }; // Azure requires the test From to be one of the resource's configured MailFrom addresses (ACS has no @@ -84,6 +96,8 @@ export const PROVIDER_BRAND: Record = { AzureCommunication: "https://learn.microsoft.com/azure/communication-services/quickstarts/email/send-email", }; -// Display order for provider pickers (real deliverable providers first; Local/SMTP last). +// Display order for provider pickers (real deliverable providers first; direct-MX options ahead of the +// generic/manual smart-host fallback; Local last). export const PROVIDER_ORDER = [ - "Mailgun", "SendGrid", "AmazonSes", "Postmark", "Resend", "SparkPost", "Bird", "Smtp2Go", "Maileroo", "AzureCommunication", "Smtp", "Local", + "Mailgun", "SendGrid", "AmazonSes", "Postmark", "Resend", "SparkPost", "Bird", "Smtp2Go", "Maileroo", "AzureCommunication", + "GoogleWorkspace", "Microsoft365", "Smtp", "Local", ]; // Friendly labels for the provider picker (enum name -> display name). @@ -119,6 +135,10 @@ export const PROVIDER_LABELS: Record = { Smtp2Go: "SMTP2GO", Maileroo: "Maileroo", AzureCommunication: "Azure Communication Services", + // Deliberately worded to not be confused with the SMTP_PRESETS "Gmail / Google Workspace" / "Microsoft 365" + // entries in Relays.tsx, which relay through a real, authenticated mailbox rather than deliver directly. + GoogleWorkspace: "Google Workspace (direct delivery, no account needed)", + Microsoft365: "Microsoft 365 (direct delivery, no account needed)", Smtp: "Generic SMTP host", Local: "Local (developer capture - no external delivery)", }; diff --git a/tests/Dispatch.Core.Tests/RelayProviderSchemaTests.cs b/tests/Dispatch.Core.Tests/RelayProviderSchemaTests.cs new file mode 100644 index 00000000..721f0a57 --- /dev/null +++ b/tests/Dispatch.Core.Tests/RelayProviderSchemaTests.cs @@ -0,0 +1,22 @@ +using Dispatch.Core.Providers; +using Dispatch.Core.Relays; + +namespace Dispatch.Core.Tests; + +public class RelayProviderSchemaTests +{ + [Fact] + public void GoogleWorkspace_has_no_configurable_fields() + { + Assert.Empty(RelayProviderSchema.For(RelayProviderType.GoogleWorkspace)); + } + + [Fact] + public void Microsoft365_requires_only_a_host() + { + var field = Assert.Single(RelayProviderSchema.For(RelayProviderType.Microsoft365)); + Assert.Equal("Host", field.Name); + Assert.True(field.Required); + Assert.False(field.Secret); + } +} diff --git a/tests/Dispatch.Core.Tests/SmtpPortGuardTests.cs b/tests/Dispatch.Core.Tests/SmtpPortGuardTests.cs index ab7cf047..3a401c97 100644 --- a/tests/Dispatch.Core.Tests/SmtpPortGuardTests.cs +++ b/tests/Dispatch.Core.Tests/SmtpPortGuardTests.cs @@ -35,4 +35,14 @@ public void Non_smtp_providers_are_never_flagged(RelayProviderType provider) var settings = new Dictionary { ["Port"] = "25" }; Assert.False(SmtpPortGuard.UsesOutboundPort25(provider, settings)); } + + [Theory] + [InlineData(RelayProviderType.GoogleWorkspace)] + [InlineData(RelayProviderType.Microsoft365)] + public void Direct_mx_providers_are_always_flagged(RelayProviderType provider) + { + // Direct MX delivery has no configurable port - it's always 25, whatever settings happen to hold. + Assert.True(SmtpPortGuard.UsesOutboundPort25(provider, new Dictionary())); + Assert.True(SmtpPortGuard.UsesOutboundPort25(provider, new Dictionary { ["Port"] = "587" })); + } } diff --git a/tests/Dispatch.Providers.Tests/Microsoft365ProviderTests.cs b/tests/Dispatch.Providers.Tests/Microsoft365ProviderTests.cs new file mode 100644 index 00000000..48cfb0d6 --- /dev/null +++ b/tests/Dispatch.Providers.Tests/Microsoft365ProviderTests.cs @@ -0,0 +1,31 @@ +using Dispatch.Core.Providers; +using Dispatch.Providers; +using MimeKit; + +namespace Dispatch.Providers.Tests; + +// Microsoft 365's endpoint is tenant-specific, so unlike GoogleWorkspaceProvider there's a required setting +// to validate. The guard runs before any network I/O, so this is testable without a live server. +public class Microsoft365ProviderTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task SendAsync_throws_when_host_is_not_configured(string? host) + { + var settings = new Dictionary { ["Host"] = host }; + var config = new RelayConfig { Provider = RelayProviderType.Microsoft365, Settings = settings }; + var provider = new Microsoft365Provider(config); + var message = new RelayMessage { Message = new MimeMessage(), FromAddress = "a@example.com", ToAddresses = ["b@example.com"] }; + + await Assert.ThrowsAsync(() => provider.SendAsync(message, CancellationToken.None)); + } + + [Fact] + public void Name_is_Microsoft365() + { + var config = new RelayConfig { Provider = RelayProviderType.Microsoft365 }; + Assert.Equal("Microsoft365", new Microsoft365Provider(config).Name); + } +} diff --git a/tests/Dispatch.Providers.Tests/RelayProviderFactoryTests.cs b/tests/Dispatch.Providers.Tests/RelayProviderFactoryTests.cs index 86ed963c..304d1a04 100644 --- a/tests/Dispatch.Providers.Tests/RelayProviderFactoryTests.cs +++ b/tests/Dispatch.Providers.Tests/RelayProviderFactoryTests.cs @@ -25,6 +25,8 @@ private static RelayProviderFactory Factory() [InlineData(RelayProviderType.Smtp2Go, typeof(Smtp2GoProvider))] [InlineData(RelayProviderType.Maileroo, typeof(MailerooProvider))] [InlineData(RelayProviderType.Bird, typeof(BirdProvider))] + [InlineData(RelayProviderType.GoogleWorkspace, typeof(GoogleWorkspaceProvider))] + [InlineData(RelayProviderType.Microsoft365, typeof(Microsoft365Provider))] public void Builds_expected_provider_type(RelayProviderType provider, Type expected) { var built = Factory().Build(new RelayConfig { Provider = provider });