diff --git a/smtp-relay.sln b/smtp-relay.sln index a2d0b9f..23b5e95 100644 --- a/smtp-relay.sln +++ b/smtp-relay.sln @@ -1,12 +1,16 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34902.65 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11222.15 d18.0 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 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{8B4B0C4C-3F23-4418-91B4-C143295838F5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "smtpclient", "tests\smtpclient\smtpclient.csproj", "{19058057-D6D9-980C-0359-AC3A630D5EAD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -17,12 +21,17 @@ Global {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 + {19058057-D6D9-980C-0359-AC3A630D5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19058057-D6D9-980C-0359-AC3A630D5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19058057-D6D9-980C-0359-AC3A630D5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19058057-D6D9-980C-0359-AC3A630D5EAD}.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} + {19058057-D6D9-980C-0359-AC3A630D5EAD} = {8B4B0C4C-3F23-4418-91B4-C143295838F5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {91A1FDC3-5235-4A64-AFB8-BF772349C4E7} diff --git a/src/smtprelay/Graph/GraphSmtpSession.cs b/src/smtprelay/Graph/GraphSmtpSession.cs new file mode 100644 index 0000000..0e420e2 --- /dev/null +++ b/src/smtprelay/Graph/GraphSmtpSession.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; + +namespace SmtpRelay.Graph; + +public partial class GraphSmtpSession(GraphSender graphSender, ILogger logger) : SmtpSessionBase(logger) +{ + private readonly GraphSender _graphSender = graphSender; + + protected override async Task OnProcessDataAsync(ReadOnlyMemory dataBytes, CancellationToken cancellationToken) + { + // save to spool dir (optional) + var spoolFile = await SpoolToFileAsync(dataBytes, cancellationToken); + + // sent via graph api + await _graphSender.SendAsync(spoolFile, dataBytes, cancellationToken); + } + + 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; + } +} \ No newline at end of file diff --git a/src/smtprelay/Program.cs b/src/smtprelay/Program.cs index b711406..c6d6995 100644 --- a/src/smtprelay/Program.cs +++ b/src/smtprelay/Program.cs @@ -136,8 +136,8 @@ internal partial class Program services.AddSingleton(graphConfig); // register smtp instances - services.AddSingleton(); - services.AddScoped(); + services.AddSingleton>(); + services.AddScoped(); // register smtp service services.AddHostedService(); diff --git a/src/smtprelay/Service.cs b/src/smtprelay/Service.cs index 478d615..9ce3162 100644 --- a/src/smtprelay/Service.cs +++ b/src/smtprelay/Service.cs @@ -1,11 +1,12 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using SmtpRelay.Graph; namespace SmtpRelay; -internal class Service(SmtpServer server, SmtpConfig config, ILogger logger) : BackgroundService +internal class Service(SmtpServer server, SmtpConfig config, ILogger logger) : BackgroundService { - private readonly SmtpServer _server = server; + private readonly SmtpServer _server = server; private readonly SmtpConfig _config = config; private readonly ILogger _logger = logger; diff --git a/src/smtprelay/Smtp/SmtpServer.cs b/src/smtprelay/Smtp/SmtpServer.cs index c7426f8..ea49519 100644 --- a/src/smtprelay/Smtp/SmtpServer.cs +++ b/src/smtprelay/Smtp/SmtpServer.cs @@ -5,16 +5,17 @@ using System.Net.Sockets; namespace SmtpRelay; -public class SmtpServer(IServiceScopeFactory scopeFactory, ILogger logger) +public class SmtpServer(IServiceScopeFactory scopeFactory, ILogger> logger) + where TSession : SmtpSessionBase { - private readonly ILogger _logger = logger; - private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + protected readonly IServiceScopeFactory _scopeFactory = scopeFactory; + protected readonly ILogger> _logger = logger; public bool IsRunning { get; private set; } = false; public async Task RunAsync(SmtpConfig config, CancellationToken cancellationToken) { - if (IsRunning) + if (IsRunning) return; var ep = new IPEndPoint(IPAddress.Parse(config.Host), config.Port); @@ -48,7 +49,7 @@ public class SmtpServer(IServiceScopeFactory scopeFactory, ILogger l _logger.LogError(ex.ToString()); } } - + // set listener state IsRunning = false; } @@ -57,7 +58,7 @@ public class SmtpServer(IServiceScopeFactory scopeFactory, ILogger l { // create session using var scope = _scopeFactory.CreateScope(); - var session = scope.ServiceProvider.GetRequiredService(); + var session = scope.ServiceProvider.GetRequiredService(); try { diff --git a/src/smtprelay/Smtp/SmtpSession.cs b/src/smtprelay/Smtp/SmtpSession.cs index 52e281d..0316b54 100644 --- a/src/smtprelay/Smtp/SmtpSession.cs +++ b/src/smtprelay/Smtp/SmtpSession.cs @@ -1,21 +1,18 @@ using Microsoft.Extensions.Logging; using SmtpRelay.Graph; -using System.Diagnostics.CodeAnalysis; 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) +public abstract partial class SmtpSessionBase(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 = []; + protected readonly ILogger _logger = logger; + protected readonly List _rcptTo = []; private bool _ehlo = false; private bool _authenticated = false; @@ -86,6 +83,8 @@ public partial class SmtpSession(GraphSender graphSender, ILogger l _logger.LogInformation("[{REP}] Disconnected", socket.RemoteEndPoint); } + protected abstract Task OnProcessDataAsync(ReadOnlyMemory dataBytes, CancellationToken cancellationToken); + private async Task OnGreeting(Socket socket, SmtpConfig config, CancellationToken cancellationToken) { // write default reply message @@ -354,7 +353,7 @@ public partial class SmtpSession(GraphSender graphSender, ILogger l { var rawData = await ReadToSpanAsync(socket, DataDelimiterSpan, true, cancellationToken); - await ProcessCapturedData(rawData, cancellationToken); + await OnProcessDataAsync(rawData, cancellationToken); // dot unstuff //if (response.StartsWith("..")) @@ -600,15 +599,6 @@ public partial class SmtpSession(GraphSender graphSender, ILogger l } } - 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)) @@ -636,7 +626,6 @@ public partial class SmtpSession(GraphSender graphSender, ILogger l return spoolFile; } - [GeneratedRegex(@"^MAIL\s+FROM:\s*<([^>]+)>(?:\s+(.*))?$", RegexOptions.IgnoreCase)] public static partial Regex MailFromRegex(); diff --git a/src/smtprelay/appsettings.json b/src/smtprelay/appsettings.json index 95ed2bd..3e270ad 100644 --- a/src/smtprelay/appsettings.json +++ b/src/smtprelay/appsettings.json @@ -8,24 +8,23 @@ "UseWhitelist": true, "UsePipelining": true, "Authentication": { - "relay@example.local": "supersecret", - "relay@autohaus-weigl.local": "Secret-Prevent-Whole" + "relay@example.local": "supersecret" }, "Whitelist": { "Senders": [ - "test@example.local", - "rechnung@autohaus-weigl.de" + "me2@example.local" ], "Receivers": [ + "noreply@example.local", "%" ] } }, "Graph": { + "Bypass": true, "TenantId": "", "ClientId": "", "ClientSecret": "", - "SenderUpn": "", - "Bypass": true + "SenderUpn": "" } } \ No newline at end of file diff --git a/src/smtprelay/smtprelay.csproj b/src/smtprelay/smtprelay.csproj index dace281..cf819b0 100644 --- a/src/smtprelay/smtprelay.csproj +++ b/src/smtprelay/smtprelay.csproj @@ -3,9 +3,9 @@ SmtpRelay smtp_relay - 2025.10.27.0 + 2025.12.3.0 Exe - net9.0 + net10.0 latest SmtpRelay enable @@ -17,11 +17,11 @@ - - + + - - + + diff --git a/tests/smtpclient/Program.cs b/tests/smtpclient/Program.cs new file mode 100644 index 0000000..fb5cd28 --- /dev/null +++ b/tests/smtpclient/Program.cs @@ -0,0 +1,276 @@ +using System.Data; +using System.Net; +using System.Net.Mail; +using System.Net.Sockets; +using System.Text; + +namespace SmtpClient; + +internal class Program +{ + private static string? _host = null; + private static string? _username = null; + private static string? _password = null; + private static string? _from = null; + private static List? _rcpts = []; + private static List? _rcptsCc = []; + private static string? _subject = null; + private static string? _body = null; + private static bool _secure = false; + + static async Task Main() + { + Console.WriteLine("Relay Test Client"); + + Console.WriteLine("\nSelect Input:"); + Console.WriteLine("\t1-Static"); + Console.WriteLine("\t2-Interactive"); + + switch (Console.ReadKey().Key) + { + case ConsoleKey.D1: + case ConsoleKey.NumPad1: + StaticInput(); + break; + case ConsoleKey.D2: + case ConsoleKey.NumPad2: + InteractiveInput(); + break; + default: + return; + } + + Console.WriteLine("\nSelect Method:"); + Console.WriteLine("\t1-Single Mail"); + Console.WriteLine("\t2-Single Mail (Raw Pipelined)"); + Console.WriteLine("\t3-Mutli Mail"); + + try + { + switch (Console.ReadKey(true).Key) + { + case ConsoleKey.D1: + case ConsoleKey.NumPad1: + await SendAsync(); + break; + case ConsoleKey.D2: + case ConsoleKey.NumPad2: + await SendPipelinedAsync(); + break; + case ConsoleKey.D3: + case ConsoleKey.NumPad3: + await SendMultiAsync(); + break; + default: + Console.WriteLine("\nUndefined Method"); + break; + } + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + } + } + + static void StaticInput() + { + _host = "localhost:2525"?.Trim(); + _secure = false; + _username = "relay@example.local"; + _password = "supersecret"; + _from = "me@example.local"?.Trim(); + _rcpts = "noreply@example.local"?.Trim()?.Split(';').ToList(); + _rcptsCc = []; + _subject = "Testnachricht"; + _body = "Das ist eine Testnachricht."; + } + + static void InteractiveInput() + { + Console.WriteLine("\nEnter Relay Host: "); + _host = Console.ReadLine()?.Trim(); + + Console.WriteLine("\nEnter Username (Optional): "); + _username = Console.ReadLine()?.Trim(); + + if (!string.IsNullOrWhiteSpace(_username)) + { + Console.WriteLine("\nEnter Password: "); + _password = Console.ReadLine()?.Trim(); + } + + Console.WriteLine("\nEnter Sender:"); + _from = Console.ReadLine()?.Trim(); + + Console.WriteLine("\nEnter Receiver (delimit with ';'):"); + _rcpts = Console.ReadLine()?.Trim()?.Split(';').ToList(); + + Console.WriteLine("\nEnter Cc Receiver (delimit with ';'):"); + _rcptsCc = Console.ReadLine()?.Trim()?.Split(';').ToList(); + + Console.WriteLine("\nEnter Subject:"); + _subject = Console.ReadLine()?.Trim(); + + Console.WriteLine("\nEnter Body:"); + _body = Console.ReadLine()?.Trim(); + } + + static async Task SendAsync() + { + var from = new MailAddress(_from!); + var to = _rcpts!.Select(p => new MailAddress(p)).ToList(); + var cc = _rcptsCc!.Select(p => new MailAddress(p)).ToList(); + + var firstTo = to.First(); + to.Remove(firstTo); + + using var message = new MailMessage(from, firstTo) + { + Priority = MailPriority.Normal, + Subject = _subject ?? "Undefined", + IsBodyHtml = false, + Body = _body ?? string.Empty, + }; + + foreach (var x in to) + message.To.Add(x); + + foreach (var x in cc) + message.CC.Add(x); + + message.Attachments.Add(new Attachment("attachment.txt")); + + var credential = new NetworkCredential(); + + if (!string.IsNullOrWhiteSpace(_username)) + { + credential.UserName = _username; + credential.Password = _password; + } + + var host = _host; + var port = 25; + + if (_host!.Contains(':')) + { + host = _host.Split(':')[0]; + port = int.Parse(_host.Split(":")[1]); + } + + using var smtp = new System.Net.Mail.SmtpClient(host, port) + { + UseDefaultCredentials = false, + Credentials = credential, + EnableSsl = _secure, + DeliveryMethod = SmtpDeliveryMethod.Network + }; + + await smtp.SendMailAsync(message); + } + + static async Task SendMultiAsync() + { + var from = new MailAddress(_from!); + var to = _rcpts!.Select(p => new MailAddress(p)).ToList(); + var cc = _rcptsCc!.Select(p => new MailAddress(p)).ToList(); + + var firstTo = to.First(); + to.Remove(firstTo); + + using var message = new MailMessage(from, firstTo) + { + Priority = MailPriority.Normal, + Subject = _subject ?? "Undefined", + IsBodyHtml = false, + Body = _body ?? string.Empty + }; + + foreach (var x in to) + message.To.Add(x); + + foreach (var x in cc) + message.CC.Add(x); + + message.Attachments.Add(new Attachment("attachment.txt")); + + var credential = new NetworkCredential(); + + if (!string.IsNullOrWhiteSpace(_username)) + { + credential.UserName = _username; + credential.Password = _password; + } + + var host = _host; + var port = 25; + + if (_host!.Contains(':')) + { + host = _host.Split(':')[0]; + port = int.Parse(_host.Split(":")[1]); + } + + using var smtp = new System.Net.Mail.SmtpClient(host, port) + { + UseDefaultCredentials = false, + Credentials = credential, + EnableSsl = _secure, + DeliveryMethod = SmtpDeliveryMethod.Network + }; + + await smtp.SendMailAsync(message); + await smtp.SendMailAsync(message); + } + + static async Task SendPipelinedAsync() + { + var raw = "EHLO SmtpClient\r\n"; + + if (!string.IsNullOrWhiteSpace(_username)) + { + raw += $"AUTH PLAIN {Convert.ToBase64String(Encoding.UTF8.GetBytes($"\0{_username}\0{_password}"))}\r\n"; + } + + raw += $"MAIL FROM:<{_from}> SIZE=512\r\n"; + + foreach (var rcpt in _rcpts!) + { + raw += $"RCPT TO:<{rcpt}>\r\n"; + } + + raw += "DATA\r\n"; + raw += "MIME-Version: 1.0\r\n"; + raw += $"From: {_from}\r\n"; + raw += $"To: {string.Join(", ", _rcpts!)}\r\n"; + + if (_rcptsCc != null && _rcptsCc.Count > 0) + { + raw += $"Cc: {string.Join(", ", _rcptsCc!)}\r\n"; + } + + raw += "Date: 29 Oct 2025 21:10:56 +0100\r\n"; + raw += $"Subject:{_subject}\r\n"; + raw += "\r\n" ; + raw += $"{_body}"; + raw += "\r\n.\r\n"; + raw += "QUIT\r\n"; + + var bytes = Encoding.ASCII.GetBytes(raw); + var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + var host = _host; + var port = 25; + + if (_host!.Contains(':')) + { + host = _host.Split(':')[0]; + port = int.Parse(_host.Split(":")[1]); + } + + await sock.ConnectAsync(host!, port); + await sock.SendAsync(bytes); + + Console.WriteLine("wait 3 seconds..."); + await Task.Delay(3000); + } +} \ No newline at end of file diff --git a/tests/smtpclient/attachment.txt b/tests/smtpclient/attachment.txt new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/tests/smtpclient/attachment.txt @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/tests/smtpclient/smtpclient.csproj b/tests/smtpclient/smtpclient.csproj new file mode 100644 index 0000000..fdea5d2 --- /dev/null +++ b/tests/smtpclient/smtpclient.csproj @@ -0,0 +1,23 @@ + + + + SmtpClient + smtp_client + 2025.12.3.0 + Exe + net10.0 + latest + SmtpClient + enable + enable + none + true + + + + + PreserveNewest + + + +