.net update, implemented base session, added test client

This commit is contained in:
Kevin Kai Berthold 2025-12-03 18:05:41 +01:00
parent e0b732267b
commit 7b147569e0
11 changed files with 371 additions and 41 deletions

View file

@ -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}

View file

@ -0,0 +1,31 @@
using Microsoft.Extensions.Logging;
namespace SmtpRelay.Graph;
public partial class GraphSmtpSession(GraphSender graphSender, ILogger<GraphSmtpSession> logger) : SmtpSessionBase(logger)
{
private readonly GraphSender _graphSender = graphSender;
protected override async Task OnProcessDataAsync(ReadOnlyMemory<byte> 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<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;
}
}

View file

@ -136,8 +136,8 @@ internal partial class Program
services.AddSingleton(graphConfig);
// register smtp instances
services.AddSingleton<SmtpServer>();
services.AddScoped<SmtpSession>();
services.AddSingleton<SmtpServer<GraphSmtpSession>>();
services.AddScoped<GraphSmtpSession>();
// register smtp service
services.AddHostedService<Service>();

View file

@ -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<Service> logger) : BackgroundService
internal class Service(SmtpServer<GraphSmtpSession> server, SmtpConfig config, ILogger<Service> logger) : BackgroundService
{
private readonly SmtpServer _server = server;
private readonly SmtpServer<GraphSmtpSession> _server = server;
private readonly SmtpConfig _config = config;
private readonly ILogger<Service> _logger = logger;

View file

@ -5,10 +5,11 @@ using System.Net.Sockets;
namespace SmtpRelay;
public class SmtpServer(IServiceScopeFactory scopeFactory, ILogger<SmtpServer> logger)
public class SmtpServer<TSession>(IServiceScopeFactory scopeFactory, ILogger<SmtpServer<TSession>> logger)
where TSession : SmtpSessionBase
{
private readonly ILogger<SmtpServer> _logger = logger;
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
protected readonly IServiceScopeFactory _scopeFactory = scopeFactory;
protected readonly ILogger<SmtpServer<TSession>> _logger = logger;
public bool IsRunning { get; private set; } = false;
@ -57,7 +58,7 @@ public class SmtpServer(IServiceScopeFactory scopeFactory, ILogger<SmtpServer> l
{
// create session
using var scope = _scopeFactory.CreateScope();
var session = scope.ServiceProvider.GetRequiredService<SmtpSession>();
var session = scope.ServiceProvider.GetRequiredService<TSession>();
try
{

View file

@ -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<SmtpSession> logger)
public abstract partial class SmtpSessionBase(ILogger<SmtpSessionBase> 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 = [];
protected readonly ILogger<SmtpSessionBase> _logger = logger;
protected readonly List<string> _rcptTo = [];
private bool _ehlo = false;
private bool _authenticated = false;
@ -86,6 +83,8 @@ public partial class SmtpSession(GraphSender graphSender, ILogger<SmtpSession> l
_logger.LogInformation("[{REP}] Disconnected", socket.RemoteEndPoint);
}
protected abstract Task OnProcessDataAsync(ReadOnlyMemory<byte> 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<SmtpSession> 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<SmtpSession> l
}
}
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))
@ -636,7 +626,6 @@ public partial class SmtpSession(GraphSender graphSender, ILogger<SmtpSession> l
return spoolFile;
}
[GeneratedRegex(@"^MAIL\s+FROM:\s*<([^>]+)>(?:\s+(.*))?$", RegexOptions.IgnoreCase)]
public static partial Regex MailFromRegex();

View file

@ -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": ""
}
}

View file

@ -3,9 +3,9 @@
<PropertyGroup>
<Product>SmtpRelay</Product>
<AssemblyName>smtp_relay</AssemblyName>
<AssemblyVersion>2025.10.27.0</AssemblyVersion>
<AssemblyVersion>2025.12.3.0</AssemblyVersion>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<RootNamespace>SmtpRelay</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@ -17,11 +17,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<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" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
</ItemGroup>
<ItemGroup>

276
tests/smtpclient/Program.cs Normal file
View file

@ -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<string>? _rcpts = [];
private static List<string>? _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);
}
}

View file

@ -0,0 +1 @@
test

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Product>SmtpClient</Product>
<AssemblyName>smtp_client</AssemblyName>
<AssemblyVersion>2025.12.3.0</AssemblyVersion>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<RootNamespace>SmtpClient</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>none</SatelliteResourceLanguages>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<None Update="attachment.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>