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