init
This commit is contained in:
commit
dfa2fe0de4
14 changed files with 1483 additions and 0 deletions
7
src/smtprelay/Common.cs
Normal file
7
src/smtprelay/Common.cs
Normal 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"));
|
||||
}
|
||||
11
src/smtprelay/Graph/GraphConfig.cs
Normal file
11
src/smtprelay/Graph/GraphConfig.cs
Normal 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;
|
||||
}
|
||||
9
src/smtprelay/Graph/GraphHelper.cs
Normal file
9
src/smtprelay/Graph/GraphHelper.cs
Normal 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();
|
||||
}
|
||||
85
src/smtprelay/Graph/GraphSender.cs
Normal file
85
src/smtprelay/Graph/GraphSender.cs
Normal 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
154
src/smtprelay/Program.cs
Normal 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
24
src/smtprelay/Service.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
16
src/smtprelay/Smtp/SmtpConfig.cs
Normal file
16
src/smtprelay/Smtp/SmtpConfig.cs
Normal 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; } = [];
|
||||
}
|
||||
37
src/smtprelay/Smtp/SmtpHelper.cs
Normal file
37
src/smtprelay/Smtp/SmtpHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/smtprelay/Smtp/SmtpServer.cs
Normal file
71
src/smtprelay/Smtp/SmtpServer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
615
src/smtprelay/Smtp/SmtpSession.cs
Normal file
615
src/smtprelay/Smtp/SmtpSession.cs
Normal 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();
|
||||
}
|
||||
28
src/smtprelay/appsettings.json
Normal file
28
src/smtprelay/appsettings.json
Normal 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
|
||||
}
|
||||
}
|
||||
33
src/smtprelay/smtprelay.csproj
Normal file
33
src/smtprelay/smtprelay.csproj
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue