This commit is contained in:
Kevin Kai Berthold 2025-10-27 15:36:41 +01:00
commit dfa2fe0de4
14 changed files with 1483 additions and 0 deletions

363
.gitignore vendored Normal file
View file

@ -0,0 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
# *.pubxml
# *.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

30
smtp-relay.sln Normal file
View file

@ -0,0 +1,30 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34902.65
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7162BB38-DF40-4438-827C-05A55268EB23}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "smtprelay", "src\smtprelay\smtprelay.csproj", "{F1834A4A-8E76-61A2-D639-D02937B8885C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F1834A4A-8E76-61A2-D639-D02937B8885C} = {7162BB38-DF40-4438-827C-05A55268EB23}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {91A1FDC3-5235-4A64-AFB8-BF772349C4E7}
EndGlobalSection
EndGlobal

7
src/smtprelay/Common.cs Normal file
View file

@ -0,0 +1,7 @@
namespace SmtpRelay;
internal static class Common
{
public static DirectoryInfo SpoolDir => new(Path.Combine(AppContext.BaseDirectory, "spool"));
public static DirectoryInfo SentDir => new(Path.Combine(AppContext.BaseDirectory, "sent"));
}

View file

@ -0,0 +1,11 @@
namespace SmtpRelay.Graph;
public class GraphConfig
{
public string? TenantId { get; set; }
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? SenderUpn { get; set; }
public bool SaveToSentItems { get; set; } = false;
public bool Bypass { get; set; } = false;
}

View file

@ -0,0 +1,9 @@
using System.Text.RegularExpressions;
namespace SmtpRelay.Graph;
public static partial class GraphHelper
{
[GeneratedRegex("^\\S+@\\S+\\.\\S+$", RegexOptions.IgnoreCase)]
public static partial Regex ValidateEmailRegex();
}

View file

@ -0,0 +1,85 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Logging;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
namespace SmtpRelay.Graph;
public class GraphSender(GraphConfig config, ILogger<GraphSender> logger)
{
private static readonly string[] _scopes = ["https://graph.microsoft.com/.default"];
private static readonly TokenRequestContext _tokenRequestContext = new(_scopes);
private readonly GraphConfig _config = config;
private readonly ILogger<GraphSender> _logger = logger;
private ClientSecretCredential? _cachedCredentials = null;
private AccessToken? _cachedAccessToken = null;
public async Task<HttpResponseMessage?> SendAsync(FileInfo spoolFile, ReadOnlyMemory<byte> dataBytes, CancellationToken cancellationToken)
{
if (_config.Bypass)
{
_logger.LogWarning("Bypassed!");
return null;
}
// create credentials if not already cached
_cachedCredentials ??= new ClientSecretCredential(_config.TenantId, _config.ClientId, _config.ClientSecret);
// get token if not already cached or expired
if (_cachedAccessToken == null || InvalidToken(_cachedAccessToken.Value))
{
_cachedAccessToken = _cachedCredentials.GetToken(_tokenRequestContext, cancellationToken);
_logger.LogInformation("Requested new Azure Access Token");
// convert token => jwt
var jwt = new JwtSecurityToken(_cachedAccessToken.Value.Token);
// check jwt roles for "Mail.Send"
var roles = jwt.Claims.Where(c => c.Type == "roles").Select(c => c.Value);
if (!roles.Contains("Mail.Send"))
throw new InvalidDataException("Azure Access Token missing: APPLICATION PERMISSION => { ''MAIL.SEND'' }");
_logger.LogInformation("Required Azure Token Permission granted");
}
// capturedData: byte[] exactly as received in SMTP DATA (RFC822/MIME)
var base64 = Convert.ToBase64String(dataBytes.Span);
var content = new StringContent(base64, Encoding.ASCII, "text/plain");
// create http client, with access token
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _cachedAccessToken.Value.Token);
//// send directly: /users/{id}
var resp = await http.PostAsync($"https://graph.microsoft.com/v1.0/users/{_config.SenderUpn}/sendMail", content, cancellationToken);
// debug info
//Console.WriteLine(JsonSerializer.Serialize(resp, new JsonSerializerOptions() { WriteIndented = true }));
// throw hard on error
var rcode = resp.EnsureSuccessStatusCode();
// move into sent dir
Directory.CreateDirectory(Common.SentDir.FullName);
// move spooled file to sent folder
var sentFile = new FileInfo(Path.Combine(Common.SentDir.FullName, spoolFile.Name));
spoolFile.MoveTo(sentFile.FullName);
_logger.LogInformation("Sent: {Path}", sentFile.FullName);
return resp;
}
private static bool InvalidToken(AccessToken token, TimeSpan? refreshBefore = null)
{
if (string.IsNullOrEmpty(token.Token))
return true;
var buffer = refreshBefore ?? TimeSpan.FromMinutes(2);
return DateTimeOffset.UtcNow >= token.ExpiresOn - buffer;
}
}

154
src/smtprelay/Program.cs Normal file
View file

@ -0,0 +1,154 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SmtpRelay.Graph;
using System.Diagnostics;
namespace SmtpRelay;
internal partial class Program
{
public static async Task Main()
{
// builder
var builder = Host.CreateDefaultBuilder()
.UseWindowsService(opt => opt.ServiceName = "Webmatic SMTP Relay")
.UseSystemd();
// configuration
builder.ConfigureHostConfiguration(config =>
{
config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
config.AddEnvironmentVariables();
});
// logging
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.SetMinimumLevel(LogLevel.Trace);
logging.AddFilter("Microsoft", LogLevel.Critical);
logging.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff ";
});
// file logger
using var proc = Process.GetCurrentProcess();
var appDir = new DirectoryInfo(proc.MainModule!.FileName).Parent;
logging.AddFile(appDir + "/logs/{Date}.log", LogLevel.Trace, fileSizeLimitBytes: 104857600, retainedFileCountLimit: 10, outputTemplate: "{Timestamp:o} [{Level:u3}] ({SourceContext}) {Message} {NewLine}{Exception}");
});
// services
builder.ConfigureServices((host, services) =>
{
// register pre-configured smtp config
var smtpConfig = new SmtpConfig
{
Host = host.Configuration.GetSection("Smtp").GetValue<string?>("Address") ?? "127.0.0.1",
Port = host.Configuration.GetSection("Smtp").GetValue<int?>("Port") ?? 2525,
MaxMessageBytes = host.Configuration.GetSection("Smtp").GetValue<long?>("MaxMessageBytes") ?? 10 * 1024 * 1024,
Greetings = host.Configuration.GetSection("Smtp").GetValue<string?>("Greetings") ?? "OAuth Relay",
UseAuthentication = host.Configuration.GetSection("Smtp").GetValue<bool?>("UseAuthentication") ?? false,
UseWhiteList = host.Configuration.GetSection("Smtp").GetValue<bool?>("UseWhitelist") ?? false
};
//// fetch authentication entries
if (smtpConfig.UseAuthentication)
{
var users = host.Configuration
.GetSection("Smtp")
.GetSection("Authentication")
.GetChildren()
.ToDictionary(p => p.Key, p => p.Value);
foreach (var item in users)
{
smtpConfig.Authentication.Add(item.Key, item.Value!);
}
}
//// fetch whitelist:sender entries
if (smtpConfig.UseWhiteList)
{
var senders = host.Configuration
.GetSection("Smtp")
.GetSection("Whitelist")
.GetSection("Senders")
.GetChildren()
.ToList();
foreach (var item in senders)
smtpConfig.AllowedSenders.Add(item.Value!);
}
//// fetch whitelist:receivers entries
if (smtpConfig.UseWhiteList)
{
var senders = host.Configuration
.GetSection("Smtp")
.GetSection("Whitelist")
.GetSection("Receivers")
.GetChildren()
.ToList();
foreach (var item in senders)
smtpConfig.AllowedReceivers.Add(item.Value!);
}
services.AddSingleton(smtpConfig);
// register pre-configured graph config
var graphConfig = new GraphConfig
{
TenantId = host.Configuration.GetSection("Graph").GetValue<string?>("TenantId") ?? null,
ClientId = host.Configuration.GetSection("Graph").GetValue<string?>("ClientId") ?? null,
ClientSecret = host.Configuration.GetSection("Graph").GetValue<string?>("ClientSecret") ?? null,
SenderUpn = host.Configuration.GetSection("Graph").GetValue<string?>("SenderUpn") ?? null,
Bypass = host.Configuration.GetSection("Graph").GetValue<bool?>("Bypass") ?? false
};
//// validate
if (!graphConfig.Bypass)
{
if (string.IsNullOrWhiteSpace(graphConfig.TenantId))
throw new ArgumentNullException(graphConfig.TenantId, "{Graph:TenantId} cannot be null");
if (string.IsNullOrWhiteSpace(graphConfig.ClientId))
throw new ArgumentNullException(graphConfig.ClientId, "{Graph:ClientId} cannot be null");
if (string.IsNullOrWhiteSpace(graphConfig.ClientSecret))
throw new ArgumentNullException(graphConfig.ClientSecret, "{Graph:ClientSecret} cannot be null");
if (string.IsNullOrWhiteSpace(graphConfig.SenderUpn))
throw new ArgumentNullException(graphConfig.SenderUpn, "{Graph:SenderUpn} cannot be null");
if (!GraphHelper.ValidateEmailRegex().Match(graphConfig.SenderUpn).Success)
throw new ArgumentNullException(graphConfig.SenderUpn, "{Graph:SenderUpn} invalid format");
}
services.AddSingleton(graphConfig);
// register smtp instances
services.AddSingleton<SmtpServer>();
services.AddScoped<SmtpSession>();
// register smtp service
services.AddHostedService<Service>();
// register graph sender
services.AddSingleton<GraphSender>();
});
// build host
var host = builder.Build();
// run app
await host.RunAsync();
}
}

24
src/smtprelay/Service.cs Normal file
View file

@ -0,0 +1,24 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace SmtpRelay;
internal class Service(SmtpServer server, SmtpConfig config, ILogger<Service> logger) : BackgroundService
{
private readonly SmtpServer _server = server;
private readonly SmtpConfig _config = config;
private readonly ILogger<Service> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Started Service");
try
{
await _server.RunAsync(_config, stoppingToken);
}
catch (OperationCanceledException) { }
_logger.LogInformation("Stopped Service");
}
}

View file

@ -0,0 +1,16 @@
namespace SmtpRelay;
public class SmtpConfig
{
public string Host { get; set; } = "0.0.0.0";
public int Port { get; set; } = 25;
public long MaxMessageBytes { get; set; }
public string Greetings { get; set; } = "Relay";
public TimeSpan ReadTimeout { get; init; } = TimeSpan.FromMinutes(3);
public TimeSpan WriteTimeout { get; init; } = TimeSpan.FromMinutes(1);
public bool UseAuthentication { get; set; } = false;
public bool UseWhiteList { get; set; } = false;
public Dictionary<string, string> Authentication { get; } = [];
public List<string> AllowedSenders { get; } = [];
public List<string> AllowedReceivers { get; } = [];
}

View file

@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
namespace SmtpRelay;
public static partial class SmtpHelper
{
public static bool TryBase64(string s, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(s);
return true;
}
catch
{
bytes = [];
return false;
}
}
public static bool MatchesAny(string email, IEnumerable<string> patterns)
{
foreach (var pattern in patterns)
{
// Escape regex special chars except '%'
string escaped = Regex.Escape(pattern).Replace("%", ".*");
// Force full string match (not partial)
string regex = $"^{escaped}$";
if (Regex.IsMatch(email, regex, RegexOptions.IgnoreCase))
return true;
}
return false;
}
}

View file

@ -0,0 +1,71 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Net.Sockets;
namespace SmtpRelay;
public class SmtpServer(IServiceScopeFactory scopeFactory, ILogger<SmtpServer> logger)
{
private readonly ILogger<SmtpServer> _logger = logger;
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
public bool IsRunning { get; private set; } = false;
public async Task RunAsync(SmtpConfig config, CancellationToken cancellationToken)
{
if (IsRunning)
return;
var ep = new IPEndPoint(IPAddress.Parse(config.Host), config.Port);
// start listener
using var listener = new TcpListener(ep);
listener.Start();
cancellationToken.Register(listener.Stop);
// set listener state
IsRunning = true;
// log
_logger.LogInformation("SMTP listening on {HOST}:{PORT}", config.Host, config.Port);
// handler loop
while (!cancellationToken.IsCancellationRequested)
{
try
{
// await new client
var client = await listener.AcceptTcpClientAsync(cancellationToken);
// handle client async
_ = HandleClientAsync(client, config, cancellationToken);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex.ToString());
}
}
// set listener state
IsRunning = false;
}
private async Task HandleClientAsync(TcpClient client, SmtpConfig config, CancellationToken cancellationToken)
{
// create session
using var scope = _scopeFactory.CreateScope();
var session = scope.ServiceProvider.GetRequiredService<SmtpSession>();
try
{
await session.InitAsync(client.Client, config, cancellationToken);
}
finally
{
client?.Dispose();
}
}
}

View file

@ -0,0 +1,615 @@
using Microsoft.Extensions.Logging;
using SmtpRelay.Graph;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace SmtpRelay;
public partial class SmtpSession(GraphSender graphSender, ILogger<SmtpSession> logger)
{
private static byte[] CrlnSpan { get; } = Encoding.ASCII.GetBytes("\r\n");
private static byte[] DataDelimiterSpan { get; } = Encoding.ASCII.GetBytes("\r\n.\r\n");
private readonly GraphSender _graphSender = graphSender;
private readonly ILogger<SmtpSession> _logger = logger;
private readonly List<string> _rcptTo = [];
private bool _ehlo = false;
private bool _authenticated = false;
private bool _exit = false;
private string? _authUser = null;
private string? _mailFrom = null;
private long _requestedSize = 0;
private readonly SemaphoreSlim _readLock = new(1, 1);
private readonly SemaphoreSlim _writeLock = new(1, 1);
private byte[] _buffer = new byte[8192];
private int _bufferOffset = 0;
private int _dataOffset = 0;
public async Task InitAsync(Socket socket, SmtpConfig config, CancellationToken cancellationToken)
{
// set tcp options
socket.NoDelay = true;
_logger.LogInformation("[{REP}] Connected", socket.RemoteEndPoint);
try
{
// write greetings message
await OnGreeting(socket, config, cancellationToken);
// main loop
while (!cancellationToken.IsCancellationRequested)
{
// get next line
var rawLine = await ReadToSpanAsync(socket, CrlnSpan, true, cancellationToken);
if (rawLine == null)
break;
var line = Encoding.ASCII.GetString(rawLine);
if (string.IsNullOrWhiteSpace(line))
{
await OnEmpty(socket, cancellationToken);
continue;
}
switch (line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[0].ToUpperInvariant())
{
case "EHLO": await OnEhlo(socket, config, line, cancellationToken); break;
case "HELO": await OnHelo(socket, config, line, cancellationToken); break;
case "AUTH": await OnAuth(socket, config, line, cancellationToken); break;
case "MAIL": await OnMail(socket, config, line, cancellationToken); break;
case "RCPT": await OnRcpt(socket, config, line, cancellationToken); break;
case "DATA": await OnData(socket, config, cancellationToken); break;
case "NOOP": await OnNoop(socket, cancellationToken); break;
case "RSET": await OnRset(socket, cancellationToken); break;
case "QUIT": await OnQuit(socket, cancellationToken); break;
default: await OnNotImplemented(socket, cancellationToken); break;
}
if (_exit)
break;
}
}
catch (Exception ex)
{
_logger.LogError("[{REP}] Error: {MSG}", socket.RemoteEndPoint, ex.ToString());
}
_logger.LogInformation("[{REP}] Disconnected", socket.RemoteEndPoint);
}
private async Task OnGreeting(Socket socket, SmtpConfig config, CancellationToken cancellationToken)
{
// write default reply message
await WriteLineAsync(socket, $"220 {config.Greetings} Service ready", true, cancellationToken);
}
private async Task OnEmpty(Socket socket, CancellationToken cancellationToken)
{
// write default reply message
await WriteLineAsync(socket, "500 Empty command", true, cancellationToken);
}
private async Task OnNotImplemented(Socket socket, CancellationToken cancellationToken)
{
// write default reply message
await WriteLineAsync(socket, "502 Command not implemented", true, cancellationToken);
}
private async Task OnEhlo(Socket socket, SmtpConfig config, string line, CancellationToken cancellationToken)
{
// cut args from line
var arg = line.Length > 4 ? line[5..].Trim() : null;
// on missing args
if (string.IsNullOrWhiteSpace(arg))
{
await WriteLineAsync(socket, "501 Syntax: EHLO domain", true, cancellationToken);
return;
}
// set ehlo completed
_ehlo = true;
// write ehlo reply messages (options...)
await WriteLineAsync(socket, $"250-{config.Greetings}", true, cancellationToken);
await WriteLineAsync(socket, $"250-SIZE {config.MaxMessageBytes}", true, cancellationToken);
await WriteLineAsync(socket, "250-8BITMIME", true, cancellationToken);
// write authentication options (if authentication enabled)
if (config.UseAuthentication)
await WriteLineAsync(socket, "250-AUTH PLAIN LOGIN", true, cancellationToken);
// write basic pipelining accept (sequencing enforced in code)
await WriteLineAsync(socket, "250 PIPELINING", true, cancellationToken);
}
private async Task OnHelo(Socket socket, SmtpConfig config, string line, CancellationToken cancellationToken)
{
// cut args from line
var arg = line.Length > 4 ? line[5..].Trim() : null;
// on missing args
if (string.IsNullOrWhiteSpace(arg))
{
// write default reply message
await WriteLineAsync(socket, "501 Syntax: HELO domain", true, cancellationToken);
return;
}
// set ehlo completed
_ehlo = true;
// write ehlo reply messages (options...)
await WriteLineAsync(socket, $"250 {config.Greetings}", true, cancellationToken);
}
private async Task OnAuth(Socket socket, SmtpConfig config, string line, CancellationToken cancellationToken)
{
// if ehlo not completed yet
if (!_ehlo)
{
// write default reply message
await WriteLineAsync(socket, "503 Bad sequence of commands", true, cancellationToken);
return;
}
// cut args from line
var args = line.Length > 4 ? line[5..].Trim() : string.Empty;
// on missing args
if (string.IsNullOrEmpty(args))
{
// write default reply message
await WriteLineAsync(socket, "501 Syntax: AUTH mechanism [initial-response]", true, cancellationToken);
return;
}
// parse auth mechanism and inital from args
var parts = args.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var mechanism = parts[0].ToUpperInvariant();
var initial = parts.Length > 1 ? parts[1] : null;
// policy: only allow AUTH when secure unless explicitly permitted
//if (!_encrypted && !_config.AllowAuthWithoutTls)
//{
// await writer.WriteLineAsync("538 5.7.11 Encryption required for requested authentication mechanism");
// return;
//}
// if already authenticated
if (_authenticated)
{
await WriteLineAsync(socket, "503 Already authenticated", true, cancellationToken);
return;
}
// switch on auth mechanism
switch (mechanism)
{
case "PLAIN": await HandleAuthPlainAsync(socket, config, initial, cancellationToken); break;
case "LOGIN": await HandleAuthLoginAsync(socket, config, initial, cancellationToken); break;
default: await WriteLineAsync(socket, "504 5.5.4 Unrecognized authentication type", true, cancellationToken); break;
}
}
private async Task OnNoop(Socket socket, CancellationToken cancellationToken)
{
// write default reply message
await WriteLineAsync(socket, "250 OK", true, cancellationToken);
}
private async Task OnRset(Socket socket, CancellationToken cancellationToken)
{
// reset mail data
_mailFrom = null;
_rcptTo.Clear();
_requestedSize = 0;
// write default reply message
await WriteLineAsync(socket, "250 OK", true, cancellationToken);
}
private async Task OnQuit(Socket socket, CancellationToken cancellationToken)
{
// write default reply message
await WriteLineAsync(socket, "221 Bye", true, cancellationToken);
// set exit
_exit = true;
}
private async Task OnMail(Socket socket, SmtpConfig config, string line, CancellationToken cancellationToken)
{
// if ehlo not completed yet
if (!_ehlo)
{
// write default reply message
await WriteLineAsync(socket, "503 Bad sequence of commands", true, cancellationToken);
return;
}
// if authentication enforced but not done yet
if (config.UseAuthentication && !_authenticated)
{
// write default reply message
await WriteLineAsync(socket, "530 5.7.0 Authentication required", true, cancellationToken);
return;
}
// if sender information already passed
if (_mailFrom != null)
{
// write default reply message
await WriteLineAsync(socket, "503 MAIL already issued", true, cancellationToken);
return;
}
// check if line starts with "MAIL FROM:"
var fromRegex = MailFromRegex().Match(line);
if (!fromRegex.Success)
{
// write default reply message
await WriteLineAsync(socket, "501 Syntax: MAIL FROM:<address> [SIZE=nnn] [BODY=8BITMIME]", true, cancellationToken);
return;
}
// split out args (sender, options)
_requestedSize = 0;
var parms = fromRegex.Groups[2].Success ? fromRegex.Groups[2].Value : string.Empty;
foreach (var p in parms.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()))
{
if (p.StartsWith("SIZE=", StringComparison.InvariantCultureIgnoreCase) && long.TryParse(p.AsSpan(5), out var n))
_requestedSize = n;
// BODY=8BITMIME accepted but not transformed
}
// check provided size
if (_requestedSize > 0 && _requestedSize > config.MaxMessageBytes)
{
// write default reply message
await WriteLineAsync(socket, "552 Message size exceeds fixed maximum message size", true, cancellationToken);
return;
}
// if whitelist enabled, validate sender
if (config.UseWhiteList && !ValidateSender(config, fromRegex.Groups[1].Value))
{
// 554 alternative usage ;)
await WriteLineAsync(socket, "554 Sender not allowed", true, cancellationToken);
return;
}
// set sender
_mailFrom = fromRegex.Groups[1].Value;
// write default reply message
await WriteLineAsync(socket, "250 OK", true, cancellationToken);
}
private async Task OnRcpt(Socket socket, SmtpConfig config, string line, CancellationToken cancellationToken)
{
// if sender information not set
if (_mailFrom == null)
{
// write default reply message
await WriteLineAsync(socket, "503 Need MAIL before RCPT", true, cancellationToken);
return;
}
// check if line starts with "RCPT TO:"
var rcptRegex = RcptToRegex().Match(line);
if (!rcptRegex.Success)
{
// write default reply message
await WriteLineAsync(socket, "501 Syntax: RCPT TO:<address>", true, cancellationToken);
return;
}
// if whitelist enabled, validate receiver
if (config.UseWhiteList && !ValidateReceiver(config, rcptRegex.Groups[1].Value))
{
// 554 alternative usage ;)
await WriteLineAsync(socket, "554 Receiver not allowed", true, cancellationToken);
return;
}
// add receiver data
_rcptTo.Add(rcptRegex.Groups[1].Value);
// write default reply message
await WriteLineAsync(socket, "250 OK", true, cancellationToken);
}
private async Task OnData(Socket socket, SmtpConfig config, CancellationToken cancellationToken)
{
// if sender not set OR receivers empty
if (_mailFrom is null || _rcptTo.Count == 0)
{
// write default reply message
await WriteLineAsync(socket, "503 Need MAIL and RCPT before DATA", true, cancellationToken);
return;
}
// write default reply message
await WriteLineAsync(socket, "354 End data with <CR><LF>.<CR><LF>", true, cancellationToken);
try
{
var rawData = await ReadToSpanAsync(socket, DataDelimiterSpan, true, cancellationToken);
await ProcessCapturedData(rawData, cancellationToken);
// dot unstuff
//if (response.StartsWith(".."))
// response = response[1..];
//var response = Encoding.ASCII.GetString(rawData);
//Console.WriteLine(JsonSerializer.Serialize(response));
}
catch (Exception ex)
{
_logger.LogError(ex.ToString());
throw new InvalidOperationException(ex.ToString());
}
// reset mail data
_mailFrom = null;
_rcptTo.Clear();
_requestedSize = 0;
// write default reply message
await WriteLineAsync(socket, "250 OK", true, cancellationToken);
}
private async Task HandleAuthPlainAsync(Socket socket, SmtpConfig config, string? raw, CancellationToken cancellationToken)
{
_logger.LogWarning("auth plain");
// PLAIN == base64
string payloadB64 = raw ?? string.Empty;
if (string.IsNullOrEmpty(payloadB64) || payloadB64 == "=")
{
// Empty initial response -> prompt
// empty challenge
await WriteLineAsync(socket, "334", true, cancellationToken);
var rawLine = await ReadToSpanAsync(socket, CrlnSpan, true, cancellationToken);
payloadB64 = Encoding.ASCII.GetString(rawLine ?? throw new InvalidDataException("null")).Trim() ?? "";
}
if (!SmtpHelper.TryBase64(payloadB64, out var bytes))
{
await WriteLineAsync(socket, "501 5.5.2 Invalid base64 data", true, cancellationToken);
return;
}
var s = Encoding.ASCII.GetString(bytes);
var parts = s.Split('\0');
if (parts.Length < 3)
{
await WriteLineAsync(socket, "501 5.5.2 Invalid PLAIN auth response", true, cancellationToken);
return;
}
var authzid = parts[0];
var user = parts[1];
var pass = parts[2];
if (ValidateUser(config, user, pass))
{
_authenticated = true;
_authUser = user;
await WriteLineAsync(socket, "235 2.7.0 Authentication successful", true, cancellationToken);
return;
}
await WriteLineAsync(socket, "535 5.7.8 Authentication credentials invalid", true, cancellationToken);
}
private async Task HandleAuthLoginAsync(Socket socket, SmtpConfig config, string? raw, CancellationToken cancellationToken)
{
_logger.LogWarning("auth login");
string? user;
string? pass;
if (!string.IsNullOrEmpty(raw))
{
if (!SmtpHelper.TryBase64(raw, out var ub))
{
await WriteLineAsync(socket, "501 5.5.2 Invalid base64", true, cancellationToken);
return;
}
user = Encoding.ASCII.GetString(ub);
}
else
{
await WriteLineAsync(socket, $"334 {Convert.ToBase64String(Encoding.ASCII.GetBytes("Username:"))}", true, cancellationToken);
var rawLine = await ReadToSpanAsync(socket, CrlnSpan, true, cancellationToken);
var response = Encoding.ASCII.GetString(rawLine ?? throw new InvalidDataException("null")).Trim() ?? "";
if (!SmtpHelper.TryBase64(response, out var ub))
{
await WriteLineAsync(socket, $"501 5.5.2 Invalid base64", true, cancellationToken);
return;
}
user = Encoding.ASCII.GetString(ub);
}
await WriteLineAsync(socket, $"334 {Convert.ToBase64String(Encoding.ASCII.GetBytes("Password:"))}", true, cancellationToken);
var rawLine2 = await ReadToSpanAsync(socket, CrlnSpan, true, cancellationToken);
var response2 = Encoding.ASCII.GetString(rawLine2 ?? throw new InvalidDataException("null")).Trim() ?? "";
if (!SmtpHelper.TryBase64(response2, out var pb))
{
await WriteLineAsync(socket, $"501 5.5.2 Invalid base64", true, cancellationToken);
return;
}
pass = Encoding.ASCII.GetString(pb);
if (ValidateUser(config, user!, pass!))
{
_authenticated = true;
_authUser = user;
await WriteLineAsync(socket, $"235 2.7.0 Authentication successful", true, cancellationToken);
}
else
{
await WriteLineAsync(socket, $"535 5.7.8 Authentication credentials invalid", true, cancellationToken);
}
}
private async Task<byte[]?> ReadToSpanAsync(Socket socket, ReadOnlyMemory<byte> until, bool escape, CancellationToken cancellationToken)
{
byte[]? returnData = null;
await _readLock.WaitAsync(cancellationToken);
try
{
while (!cancellationToken.IsCancellationRequested)
{
Memory<byte> readBuffer = new byte[8192];
var read = await socket.ReceiveAsync(readBuffer, SocketFlags.None, cancellationToken);
if (read == 0)
throw new Exception("Connection Reset");
while (_buffer.Length < _bufferOffset + read)
Array.Resize(ref _buffer, _buffer.Length * 2);
Memory<byte> bufferMemory = new(_buffer);
readBuffer[..read].CopyTo(bufferMemory.Slice(_bufferOffset, read));
_bufferOffset += read;
// cut out unsed buffer
var unusedBuffer = bufferMemory.Slice(_dataOffset, _bufferOffset);
// find escape sequence
var escapeIndex = unusedBuffer.Span.IndexOf(until.Span);
if (escapeIndex == -1)
continue;
// slice out bytes from last escape to new escape
var data = unusedBuffer[..(escapeIndex + until.Length)];
// check if theres bytes remaining after data
var reset = false;
if (_bufferOffset > data.Length)
{
_dataOffset += data.Length;
}
else
{
_dataOffset = 0;
_bufferOffset = 0;
reset = true;
}
// escape if needed
if (escape)
data = data.Slice(0, data.Length - until.Length);
// set return data
returnData = data.ToArray();
// escape if possible
if (reset)
{
Array.Resize(ref _buffer, 8192);
Array.Clear(_buffer, 0, _buffer.Length);
}
break;
}
}
finally
{
_readLock.Release();
}
return returnData;
}
private async Task<bool> WriteLineAsync(Socket socket, string line, bool crlf, CancellationToken cancellationToken)
{
await _writeLock.WaitAsync(cancellationToken);
try
{
if (crlf)
line += "\r\n";
var encodedLine = Encoding.ASCII.GetBytes(line);
var writeCount = await socket.SendAsync(encodedLine, SocketFlags.None, cancellationToken);
return writeCount == encodedLine.Length;
}
finally
{
_writeLock.Release();
}
}
private async Task ProcessCapturedData(ReadOnlyMemory<byte> dataBytes, CancellationToken cancellationToken)
{
// save to spool dir
var spoolFile = await SpoolToFileAsync(dataBytes, cancellationToken);
// sent via graph api
await _graphSender.SendAsync(spoolFile, dataBytes, cancellationToken);
}
private static bool ValidateUser(SmtpConfig config, string user, string password)
{
if (!config.Authentication.Any(p => p.Key == user))
return false;
var candidate = config.Authentication.Single(p => p.Key == user);
return candidate.Value == password;
}
private static bool ValidateSender(SmtpConfig config, string sender) => SmtpHelper.MatchesAny(sender, config.AllowedSenders);
private static bool ValidateReceiver(SmtpConfig config, string receiver) => SmtpHelper.MatchesAny(receiver, config.AllowedReceivers);
private async Task<FileInfo> SpoolToFileAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
Directory.CreateDirectory(Common.SpoolDir.FullName);
var path = Path.Combine(Common.SpoolDir.FullName, $"{Guid.NewGuid()}.eml");
var spoolFile = new FileInfo(path);
await File.WriteAllBytesAsync(spoolFile.FullName, data, cancellationToken);
_logger.LogInformation("Spooled: {Path}", spoolFile.FullName);
return spoolFile;
}
[GeneratedRegex(@"^MAIL\s+FROM:\s*<([^>]+)>(?:\s+(.*))?$", RegexOptions.IgnoreCase)]
public static partial Regex MailFromRegex();
[GeneratedRegex(@"^RCPT\s+TO:\s*<([^>]+)>(?:\s+(.*))?$", RegexOptions.IgnoreCase)]
public static partial Regex RcptToRegex();
}

View file

@ -0,0 +1,28 @@
{
"Smtp": {
"Address": "0.0.0.0",
"Port": 2525,
"Greetings": "Webmatic SMTP Relay",
"MaxMessageBytes": 52428800,
"UseAuthentication": true,
"UseWhitelist": true,
"Authentication": {
"relay@example.local": "supersecret"
},
"Whitelist": {
"Senders": [
"test@example.local"
],
"Receivers": [
"%"
]
}
},
"Graph": {
"TenantId": "",
"ClientId": "",
"ClientSecret": "",
"SenderUpn": "",
"Bypass": true
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Product>SmtpRelay</Product>
<AssemblyName>smtp_relay</AssemblyName>
<AssemblyVersion>2025.10.27.0</AssemblyVersion>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<RootNamespace>SmtpRelay</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>none</SatelliteResourceLanguages>
<InvariantGlobalization>true</InvariantGlobalization>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>