Skip to content
Draft
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
20 changes: 20 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/Dispatch.Core/Providers/RelayProviderType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,14 @@ public enum RelayProviderType
Smtp2Go,
Maileroo,
Bird,

/// <summary>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.</summary>
GoogleWorkspace,

/// <summary>Direct, unauthenticated delivery to a Microsoft 365 tenant's inbound mail endpoint
/// (&lt;tenant&gt;.mail.protection.outlook.com). Unlike Google Workspace this is tenant-specific, so the
/// hostname must be typed in.</summary>
Microsoft365,
}
6 changes: 6 additions & 0 deletions src/Dispatch.Core/Relays/RelaySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
_ => [],
};
}
Expand Down
4 changes: 4 additions & 0 deletions src/Dispatch.Core/Relays/SmtpPortGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ public static class SmtpPortGuard
/// </summary>
public static bool UsesOutboundPort25(RelayProviderType provider, IReadOnlyDictionary<string, string?> 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
Expand Down
22 changes: 22 additions & 0 deletions src/Dispatch.Providers/GoogleWorkspaceProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Dispatch.Core.Providers;
using MailKit.Security;

namespace Dispatch.Providers;

/// <summary>
/// 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.
/// </summary>
public sealed class GoogleWorkspaceProvider : IRelayProvider
{
private const string Host = "smtp.google.com";
private const int Port = 25;

public string Name => "GoogleWorkspace";

public Task<RelayResult> SendAsync(RelayMessage message, CancellationToken ct) =>
SmtpDelivery.SendAsync(Host, Port, SecureSocketOptions.StartTls, user: null, pass: null, message, ct);
}
30 changes: 30 additions & 0 deletions src/Dispatch.Providers/Microsoft365Provider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Dispatch.Core.Providers;
using MailKit.Security;

namespace Dispatch.Providers;

/// <summary>
/// Direct, unauthenticated delivery to a Microsoft 365 tenant's inbound mail endpoint (spec §8.7) - the
/// same idea as <see cref="GoogleWorkspaceProvider"/>, but the endpoint is tenant-specific
/// (&lt;tenant&gt;.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.
/// </summary>
public sealed class Microsoft365Provider(RelayConfig config) : IRelayProvider
{
private const int Port = 25;

public string Name => "Microsoft365";

public Task<RelayResult> 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;
}
2 changes: 2 additions & 0 deletions src/Dispatch.Providers/RelayProviderFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
};
}
57 changes: 57 additions & 0 deletions src/Dispatch.Providers/SmtpDelivery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Dispatch.Core.Providers;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

namespace Dispatch.Providers;

/// <summary>
/// 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.
/// </summary>
internal static class SmtpDelivery
{
public static async Task<RelayResult> 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);
}
}
}
44 changes: 2 additions & 42 deletions src/Dispatch.Providers/SmtpProvider.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Dispatch.Core.Providers;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using System.Net.Sockets;

namespace Dispatch.Providers;
Expand All @@ -19,54 +18,15 @@ public sealed class SmtpProvider : IRelayProvider

public string Name => "Smtp";

public async Task<RelayResult> SendAsync(RelayMessage message, CancellationToken ct)
public Task<RelayResult> 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) =>
Expand Down
24 changes: 22 additions & 2 deletions src/Dispatch.UI/src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ export const PROVIDER_FIELDS: Record<string, ProviderField[]> = {
{ 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
Expand Down Expand Up @@ -84,6 +96,8 @@ export const PROVIDER_BRAND: Record<string, { bg: string; fg?: string; mark: str
Smtp2Go: { bg: "#00A4E4", mark: "S2" },
Maileroo: { bg: "#4F46E5", mark: "ML" },
AzureCommunication: { bg: "#0078D4", mark: "AZ" },
GoogleWorkspace: { bg: "#4285F4", mark: "GW" },
Microsoft365: { bg: "#7719AA", mark: "365" },
Smtp: { bg: "#64748B", mark: "@" },
Local: { bg: "#475569", mark: "DEV" },
};
Expand All @@ -102,9 +116,11 @@ export const PROVIDER_DOCS: Record<string, string> = {
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).
Expand All @@ -119,6 +135,10 @@ export const PROVIDER_LABELS: Record<string, string> = {
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)",
};
22 changes: 22 additions & 0 deletions tests/Dispatch.Core.Tests/RelayProviderSchemaTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions tests/Dispatch.Core.Tests/SmtpPortGuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,14 @@ public void Non_smtp_providers_are_never_flagged(RelayProviderType provider)
var settings = new Dictionary<string, string?> { ["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<string, string?>()));
Assert.True(SmtpPortGuard.UsesOutboundPort25(provider, new Dictionary<string, string?> { ["Port"] = "587" }));
}
}
31 changes: 31 additions & 0 deletions tests/Dispatch.Providers.Tests/Microsoft365ProviderTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?> { ["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<InvalidOperationException>(() => 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);
}
}
2 changes: 2 additions & 0 deletions tests/Dispatch.Providers.Tests/RelayProviderFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading