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

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>