init
This commit is contained in:
commit
dfa2fe0de4
14 changed files with 1483 additions and 0 deletions
363
.gitignore
vendored
Normal file
363
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
# *.pubxml
|
||||
# *.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
30
smtp-relay.sln
Normal file
30
smtp-relay.sln
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34902.65
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7162BB38-DF40-4438-827C-05A55268EB23}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "smtprelay", "src\smtprelay\smtprelay.csproj", "{F1834A4A-8E76-61A2-D639-D02937B8885C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F1834A4A-8E76-61A2-D639-D02937B8885C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{F1834A4A-8E76-61A2-D639-D02937B8885C} = {7162BB38-DF40-4438-827C-05A55268EB23}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {91A1FDC3-5235-4A64-AFB8-BF772349C4E7}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
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