commit dfa2fe0de4261e1b7ca2e7e5d8e53f9c9d5f8141 Author: Kevin Kai Berthold Date: Mon Oct 27 15:36:41 2025 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24668dd --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/smtp-relay.sln b/smtp-relay.sln new file mode 100644 index 0000000..a2d0b9f --- /dev/null +++ b/smtp-relay.sln @@ -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 diff --git a/src/smtprelay/Common.cs b/src/smtprelay/Common.cs new file mode 100644 index 0000000..6d1e372 --- /dev/null +++ b/src/smtprelay/Common.cs @@ -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")); +} \ No newline at end of file diff --git a/src/smtprelay/Graph/GraphConfig.cs b/src/smtprelay/Graph/GraphConfig.cs new file mode 100644 index 0000000..894054b --- /dev/null +++ b/src/smtprelay/Graph/GraphConfig.cs @@ -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; +} \ No newline at end of file diff --git a/src/smtprelay/Graph/GraphHelper.cs b/src/smtprelay/Graph/GraphHelper.cs new file mode 100644 index 0000000..74ce405 --- /dev/null +++ b/src/smtprelay/Graph/GraphHelper.cs @@ -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(); +} \ No newline at end of file diff --git a/src/smtprelay/Graph/GraphSender.cs b/src/smtprelay/Graph/GraphSender.cs new file mode 100644 index 0000000..1d957ff --- /dev/null +++ b/src/smtprelay/Graph/GraphSender.cs @@ -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 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 _logger = logger; + + private ClientSecretCredential? _cachedCredentials = null; + private AccessToken? _cachedAccessToken = null; + + public async Task SendAsync(FileInfo spoolFile, ReadOnlyMemory 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; + } +} \ No newline at end of file diff --git a/src/smtprelay/Program.cs b/src/smtprelay/Program.cs new file mode 100644 index 0000000..433e43e --- /dev/null +++ b/src/smtprelay/Program.cs @@ -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("Address") ?? "127.0.0.1", + Port = host.Configuration.GetSection("Smtp").GetValue("Port") ?? 2525, + MaxMessageBytes = host.Configuration.GetSection("Smtp").GetValue("MaxMessageBytes") ?? 10 * 1024 * 1024, + Greetings = host.Configuration.GetSection("Smtp").GetValue("Greetings") ?? "OAuth Relay", + UseAuthentication = host.Configuration.GetSection("Smtp").GetValue("UseAuthentication") ?? false, + UseWhiteList = host.Configuration.GetSection("Smtp").GetValue("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("TenantId") ?? null, + ClientId = host.Configuration.GetSection("Graph").GetValue("ClientId") ?? null, + ClientSecret = host.Configuration.GetSection("Graph").GetValue("ClientSecret") ?? null, + SenderUpn = host.Configuration.GetSection("Graph").GetValue("SenderUpn") ?? null, + Bypass = host.Configuration.GetSection("Graph").GetValue("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(); + services.AddScoped(); + + // register smtp service + services.AddHostedService(); + + // register graph sender + services.AddSingleton(); + }); + + // build host + var host = builder.Build(); + + // run app + await host.RunAsync(); + } +} \ No newline at end of file diff --git a/src/smtprelay/Service.cs b/src/smtprelay/Service.cs new file mode 100644 index 0000000..478d615 --- /dev/null +++ b/src/smtprelay/Service.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SmtpRelay; + +internal class Service(SmtpServer server, SmtpConfig config, ILogger logger) : BackgroundService +{ + private readonly SmtpServer _server = server; + private readonly SmtpConfig _config = config; + private readonly ILogger _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"); + } +} diff --git a/src/smtprelay/Smtp/SmtpConfig.cs b/src/smtprelay/Smtp/SmtpConfig.cs new file mode 100644 index 0000000..7ce920d --- /dev/null +++ b/src/smtprelay/Smtp/SmtpConfig.cs @@ -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 Authentication { get; } = []; + public List AllowedSenders { get; } = []; + public List AllowedReceivers { get; } = []; +} \ No newline at end of file diff --git a/src/smtprelay/Smtp/SmtpHelper.cs b/src/smtprelay/Smtp/SmtpHelper.cs new file mode 100644 index 0000000..2808904 --- /dev/null +++ b/src/smtprelay/Smtp/SmtpHelper.cs @@ -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 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; + } +} \ No newline at end of file diff --git a/src/smtprelay/Smtp/SmtpServer.cs b/src/smtprelay/Smtp/SmtpServer.cs new file mode 100644 index 0000000..c7426f8 --- /dev/null +++ b/src/smtprelay/Smtp/SmtpServer.cs @@ -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 logger) +{ + private readonly ILogger _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(); + + try + { + await session.InitAsync(client.Client, config, cancellationToken); + } + finally + { + client?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/smtprelay/Smtp/SmtpSession.cs b/src/smtprelay/Smtp/SmtpSession.cs new file mode 100644 index 0000000..d3f76be --- /dev/null +++ b/src/smtprelay/Smtp/SmtpSession.cs @@ -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 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 _logger = logger; + private readonly List _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:
[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:
", 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 .", 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 ReadToSpanAsync(Socket socket, ReadOnlyMemory until, bool escape, CancellationToken cancellationToken) + { + byte[]? returnData = null; + + await _readLock.WaitAsync(cancellationToken); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + Memory 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 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 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 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 SpoolToFileAsync(ReadOnlyMemory 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(); +} \ No newline at end of file diff --git a/src/smtprelay/appsettings.json b/src/smtprelay/appsettings.json new file mode 100644 index 0000000..8264ab6 --- /dev/null +++ b/src/smtprelay/appsettings.json @@ -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 + } +} \ No newline at end of file diff --git a/src/smtprelay/smtprelay.csproj b/src/smtprelay/smtprelay.csproj new file mode 100644 index 0000000..dace281 --- /dev/null +++ b/src/smtprelay/smtprelay.csproj @@ -0,0 +1,33 @@ + + + + SmtpRelay + smtp_relay + 2025.10.27.0 + Exe + net9.0 + latest + SmtpRelay + enable + enable + none + true + true + true + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file