diff --git a/docs/SPEC.md b/docs/SPEC.md
index 1373893..33e1824 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 2f68f08..5f11de3 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 36fd4ea..528915e 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 39b5c7c..a128f06 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 0000000..d4de9e0
--- /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 0000000..70f1b35
--- /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 f27d1d3..bb598fd 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 0000000..86df656
--- /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 e583fa6..46aca2c 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 d3ee4cd..9f2ce92 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 0000000..721f0a5
--- /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 ab7cf04..3a401c9 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 0000000..48cfb0d
--- /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 86ed963..304d1a0 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 });