Add project files.

alphons <alphons@heijden.com> 4 Aug 2026, 07:56
f884f8d3d681893804da392a37314728f314c271
0 files changed
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1ff0c42
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,63 @@
+###############################################################################
+# Set default behavior to automatically normalize line endings.
+###############################################################################
+* text=auto
+
+###############################################################################
+# Set default behavior for command prompt diff.
+#
+# This is need for earlier builds of msysgit that does not have it on by
+# default for csharp files.
+# Note: This is only used by command line
+###############################################################################
+#*.cs diff=csharp
+
+###############################################################################
+# Set the merge driver for project and solution files
+#
+# Merging from the command prompt will add diff markers to the files if there
+# are conflicts (Merging from VS is not affected by the settings below, in VS
+# the diff markers are never inserted). Diff markers may cause the following
+# file extensions to fail to load in VS. An alternative would be to treat
+# these files as binary and thus will always conflict and require user
+# intervention with every merge. To do so, just uncomment the entries below
+###############################################################################
+#*.sln merge=binary
+#*.csproj merge=binary
+#*.vbproj merge=binary
+#*.vcxproj merge=binary
+#*.vcproj merge=binary
+#*.dbproj merge=binary
+#*.fsproj merge=binary
+#*.lsproj merge=binary
+#*.wixproj merge=binary
+#*.modelproj merge=binary
+#*.sqlproj merge=binary
+#*.wwaproj merge=binary
+
+###############################################################################
+# behavior for image files
+#
+# image files are treated as binary by default.
+###############################################################################
+#*.jpg binary
+#*.png binary
+#*.gif binary
+
+###############################################################################
+# diff behavior for common document formats
+#
+# Convert binary document formats to text before diffing them. This feature
+# is only available from the command line. Turn it on by uncommenting the
+# entries below.
+###############################################################################
+#*.doc diff=astextplain
+#*.DOC diff=astextplain
+#*.docx diff=astextplain
+#*.DOCX diff=astextplain
+#*.dot diff=astextplain
+#*.DOT diff=astextplain
+#*.pdf diff=astextplain
+#*.PDF diff=astextplain
+#*.rtf diff=astextplain
+#*.RTF diff=astextplain
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..94239ce
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,370 @@
+## 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.*
+
+Mailboxes/
+
+# 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
+/MailSharp.WebManager/certificate.pfx
+
+# Runtime data: account credentials (encrypted), the encryption key itself, and the message/
+# attachment/image disk cache - none of this belongs in source control.
+MailSharp.MailClient/App_Data/
diff --git a/MailSharp.Common/EventIdConfig.cs b/MailSharp.Common/EventIdConfig.cs
new file mode 100644
index 0000000..19220c9
--- /dev/null
+++ b/MailSharp.Common/EventIdConfig.cs
@@ -0,0 +1,7 @@
+namespace MailSharp.Common;
+
+public class EventIdConfig
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+}
diff --git a/MailSharp.Common/Extensions/CommonServiceExtensions.cs b/MailSharp.Common/Extensions/CommonServiceExtensions.cs
new file mode 100644
index 0000000..bdc6829
--- /dev/null
+++ b/MailSharp.Common/Extensions/CommonServiceExtensions.cs
@@ -0,0 +1,14 @@
+using MailSharp.Common.Services;
+
+namespace MailSharp.Common.Extensions;
+
+public static class CommonServiceExtensions
+{
+ // Adds Authentication and Mailbox services to the specified IServiceCollection
+ public static IServiceCollection AddCommonServices(this IServiceCollection services)
+ {
+ services.AddSingleton<AuthenticationService>();
+ services.AddSingleton<MailboxService>();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.Common/IpGroup.cs b/MailSharp.Common/IpGroup.cs
new file mode 100644
index 0000000..30caeca
--- /dev/null
+++ b/MailSharp.Common/IpGroup.cs
@@ -0,0 +1,62 @@
+using System.Net;
+
+namespace MailSharp.Common;
+
+public class IpGroupAccess
+{
+ public bool Smtp { get; set; }
+ public bool Pop3 { get; set; }
+ public bool Imap { get; set; }
+ public bool RequireSslTlsForAuth { get; set; }
+}
+
+public class IpGroup
+{
+ public string Name { get; set; } = string.Empty;
+ public int Priority { get; set; }
+ public string Cidr { get; set; } = string.Empty;
+ public DateTimeOffset? Expires { get; set; }
+ public IpGroupAccess Access { get; set; } = new();
+}
+
+public static class IpGroupMatcher
+{
+ public static IpGroup? Match(IEnumerable<IpGroup> groups, IPAddress clientIp)
+ {
+ if (clientIp.IsIPv4MappedToIPv6)
+ clientIp = clientIp.MapToIPv4();
+
+ var now = DateTimeOffset.UtcNow;
+ return groups
+ .Where(g => g.Expires == null || g.Expires > now)
+ .Where(g => IsInCidr(clientIp, g.Cidr))
+ .OrderBy(g => g.Priority)
+ .FirstOrDefault();
+ }
+
+ private static bool IsInCidr(IPAddress ip, string cidr)
+ {
+ var slash = cidr.IndexOf('/');
+ if (slash < 0) return false;
+ if (!IPAddress.TryParse(cidr[..slash], out var network)) return false;
+ if (!int.TryParse(cidr[(slash + 1)..], out var prefix)) return false;
+
+ var ipBytes = ip.GetAddressBytes();
+ var netBytes = network.GetAddressBytes();
+ if (ipBytes.Length != netBytes.Length) return false;
+
+ int fullBytes = prefix / 8;
+ int remainder = prefix % 8;
+
+ for (int i = 0; i < fullBytes; i++)
+ if (ipBytes[i] != netBytes[i]) return false;
+
+ if (remainder > 0)
+ {
+ byte mask = (byte)(0xFF << (8 - remainder));
+ if ((ipBytes[fullBytes] & mask) != (netBytes[fullBytes] & mask)) return false;
+ }
+
+ return true;
+ }
+}
diff --git a/MailSharp.Common/MailSharp.Common.csproj b/MailSharp.Common/MailSharp.Common.csproj
new file mode 100644
index 0000000..6278be9
--- /dev/null
+++ b/MailSharp.Common/MailSharp.Common.csproj
@@ -0,0 +1,10 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <OutputType>Library</OutputType>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+</Project>
diff --git a/MailSharp.Common/Message.cs b/MailSharp.Common/Message.cs
new file mode 100644
index 0000000..aadd84b
--- /dev/null
+++ b/MailSharp.Common/Message.cs
@@ -0,0 +1,10 @@
+using MailSharp.Common.Services;
+
+namespace MailSharp.Common;
+
+// Message metadata for IMAP
+public class Message
+{
+ public string Uid { get; set; } = string.Empty;
+ public MessageFlags Flags { get; set; } = new();
+}
diff --git a/MailSharp.Common/MessageFlags.cs b/MailSharp.Common/MessageFlags.cs
new file mode 100644
index 0000000..09db817
--- /dev/null
+++ b/MailSharp.Common/MessageFlags.cs
@@ -0,0 +1,10 @@
+namespace MailSharp.Common;
+
+public class MessageFlags
+{
+ public bool Seen { get; set; }
+ public bool Deleted { get; set; }
+ public bool Flagged { get; set; }
+ public bool Answered { get; set; }
+}
+
diff --git a/MailSharp.Common/PortConfig.cs b/MailSharp.Common/PortConfig.cs
new file mode 100644
index 0000000..73416ae
--- /dev/null
+++ b/MailSharp.Common/PortConfig.cs
@@ -0,0 +1,8 @@
+namespace MailSharp.Common;
+
+public class PortConfig
+{
+ public string Host { get; set; } = "127.0.01";
+ public int Port { get; set; } = 25;
+ public SecurityEnum Security { get; set; }
+}
\ No newline at end of file
diff --git a/MailSharp.Common/Properties/launchSettings.json b/MailSharp.Common/Properties/launchSettings.json
new file mode 100644
index 0000000..f4f1f6e
--- /dev/null
+++ b/MailSharp.Common/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "MailSharp.Common": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:11240;http://localhost:11241"
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.Common/SecurityEnum.cs b/MailSharp.Common/SecurityEnum.cs
new file mode 100644
index 0000000..f09ed6d
--- /dev/null
+++ b/MailSharp.Common/SecurityEnum.cs
@@ -0,0 +1,9 @@
+namespace MailSharp.Common;
+
+public enum SecurityEnum
+{
+ None,
+ StartTlsOptional,
+ StartTls, // StartTLS required before MAIL/AUTH/DATA
+ Tls // Implicit TLS from connection start
+}
diff --git a/MailSharp.Common/ServerContext.cs b/MailSharp.Common/ServerContext.cs
new file mode 100644
index 0000000..d598155
--- /dev/null
+++ b/MailSharp.Common/ServerContext.cs
@@ -0,0 +1,6 @@
+using System.Net.Sockets;
+
+namespace MailSharp.Common;
+
+public sealed record ServerContext(TcpListener Listener, SecurityEnum Security);
+
diff --git a/MailSharp.Common/Services/AuthenticationService.cs b/MailSharp.Common/Services/AuthenticationService.cs
new file mode 100644
index 0000000..6835ec8
--- /dev/null
+++ b/MailSharp.Common/Services/AuthenticationService.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+
+namespace MailSharp.Common.Services;
+
+public class AuthenticationService(
+ IConfiguration configuration,
+ ILogger<AuthenticationService> logger)
+{
+
+ // Authenticate user credentials for POP3 or IMAP
+ public async Task<bool> AuthenticateAsync(string username, string password, CancellationToken cancellationToken)
+ {
+ EventIdConfig eventIdConfig = configuration.GetSection("AuthEventIds:AuthenticationAttempt").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing AuthEventIds:AuthenticationAttempt");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["AuthLogMessages:AuthenticationAttempt"],
+ username);
+
+ try
+ {
+ string userStorePath = configuration["AuthSettings:UserStorePath"] ?? throw new InvalidOperationException("UserStorePath not configured");
+ if (!File.Exists(userStorePath))
+ {
+ eventIdConfig = configuration.GetSection("AuthEventIds:AuthenticationFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing AuthEventIds:AuthenticationFailed");
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["AuthLogMessages:AuthenticationFailed"],
+ username);
+ return false;
+ }
+
+ string json = await File.ReadAllTextAsync(userStorePath, cancellationToken);
+ var users = JsonSerializer.Deserialize<List<UserConfig>>(json) ?? throw new InvalidOperationException("Invalid user store format");
+ var user = users.FirstOrDefault(u => u.Username == username && u.Password == password);
+
+ if (user != null)
+ {
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["AuthLogMessages:AuthenticationSuccess"],
+ username);
+ return true;
+ }
+
+ var errorEventIdConfig = configuration.GetSection("AuthEventIds:AuthenticationFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing AuthEventIds:AuthenticationFailed");
+ logger.LogWarning(
+ new EventId(errorEventIdConfig.Id, errorEventIdConfig.Name),
+ configuration["AuthLogMessages:AuthenticationFailed"],
+ username);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ var errorEventIdConfig = configuration.GetSection("AuthEventIds:AuthenticationFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing AuthEventIds:AuthenticationFailed");
+ logger.LogError(
+ new EventId(errorEventIdConfig.Id, errorEventIdConfig.Name),
+ ex,
+ configuration["AuthLogMessages:AuthenticationFailed"],
+ username);
+ return false;
+ }
+ }
+
+}
diff --git a/MailSharp.Common/Services/MailboxService.cs b/MailSharp.Common/Services/MailboxService.cs
new file mode 100644
index 0000000..6251e91
--- /dev/null
+++ b/MailSharp.Common/Services/MailboxService.cs
@@ -0,0 +1,298 @@
+using System.Text.Json;
+
+namespace MailSharp.Common.Services;
+
+public class MailboxService(
+ IConfiguration configuration,
+ ILogger<MailboxService> logger)
+{
+ // Builds the mailbox root path for a user.
+ // Accepts "user@domain.com" → {storagePath}/domain.com/user
+ // or plain "user" → {storagePath}/user (fallback)
+ private string MailboxRoot(string username)
+ {
+ string storagePath = configuration["MailboxSettings:StoragePath"]
+ ?? throw new InvalidOperationException("MailboxSettings:StoragePath not configured");
+ int at = username.IndexOf('@');
+ if (at > 0 && at < username.Length - 1)
+ {
+ string user = username[..at];
+ string domain = username[(at + 1)..];
+ return Path.Combine(storagePath, domain, user);
+ }
+ return Path.Combine(storagePath, username);
+ }
+
+ // Retrieve list of messages for a user in a specific folder
+ public async Task<List<Message>> GetMessagesAsync(string username, string folder, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("MailboxEventIds:MessagesRequested").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing MailboxEventIds:MessagesRequested");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:MessagesRequested"],
+ username, folder);
+
+ try
+ {
+ string mailboxPath = Path.Combine(MailboxRoot(username), folder);
+ if (!Directory.Exists(mailboxPath))
+ {
+ return new List<Message>();
+ }
+
+ var messages = new List<Message>();
+ foreach (string file in Directory.GetFiles(mailboxPath, "*.eml"))
+ {
+ string messageId = Path.GetFileNameWithoutExtension(file);
+ string metadataPath = Path.Combine(mailboxPath, $"{messageId}.json");
+ Message message = new()
+ {
+ Uid = messageId,
+ Flags = File.Exists(metadataPath)
+ ? JsonSerializer.Deserialize<MessageFlags>(await File.ReadAllTextAsync(metadataPath, cancellationToken))
+ : new MessageFlags()
+ };
+ messages.Add(message);
+ }
+
+ return messages;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["MailboxLogMessages:MessagesRetrievalFailed"],
+ username, folder);
+ return new List<Message>();
+ }
+ }
+
+ // Delete a message for a user in a specific folder
+ public async Task<bool> DeleteMessageAsync(string username, string folder, string messageId, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("MailboxEventIds:MessageDeletion").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing MailboxEventIds:MessageDeletion");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:MessageDeletion"],
+ username, messageId, folder);
+
+ try
+ {
+ string mailboxPath = Path.Combine(MailboxRoot(username), folder);
+ string filePath = Path.Combine(mailboxPath, $"{messageId}.eml");
+ string metadataPath = Path.Combine(mailboxPath, $"{messageId}.json");
+
+ if (!File.Exists(filePath))
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:MessageNotFound"],
+ username, messageId, folder);
+ return false;
+ }
+
+ await Task.Run(() =>
+ {
+ File.Delete(filePath);
+ if (File.Exists(metadataPath))
+ File.Delete(metadataPath);
+ }, cancellationToken);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["MailboxLogMessages:MessageDeletionFailed"],
+ username, messageId, folder);
+ return false;
+ }
+ }
+
+ // Create a new folder for a user
+ public async Task<bool> CreateFolderAsync(string username, string folder, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("MailboxEventIds:FolderCreation").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing MailboxEventIds:FolderCreation");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:FolderCreation"],
+ username, folder);
+
+ try
+ {
+ string mailboxPath = Path.Combine(MailboxRoot(username), folder);
+ if (Directory.Exists(mailboxPath))
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:FolderExists"],
+ username, folder);
+ return false;
+ }
+
+ await Task.Run(() => Directory.CreateDirectory(mailboxPath), cancellationToken);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["MailboxLogMessages:FolderCreationFailed"],
+ username, folder);
+ return false;
+ }
+ }
+
+ // Delete a folder for a user
+ public async Task<bool> DeleteFolderAsync(string username, string folder, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("MailboxEventIds:FolderDeletion").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing MailboxEventIds:FolderDeletion");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:FolderDeletion"],
+ username, folder);
+
+ try
+ {
+ string mailboxPath = Path.Combine(MailboxRoot(username), folder);
+ if (!Directory.Exists(mailboxPath))
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:FolderNotFound"],
+ username, folder);
+ return false;
+ }
+
+ await Task.Run(() => Directory.Delete(mailboxPath, true), cancellationToken);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["MailboxLogMessages:FolderDeletionFailed"],
+ username, folder);
+ return false;
+ }
+ }
+
+ // List folders for a user
+ public async Task<List<string>> ListFoldersAsync(string username, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("MailboxEventIds:FolderList").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing MailboxEventIds:FolderList");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:FolderList"],
+ username);
+
+ try
+ {
+ string mailboxPath = MailboxRoot(username);
+ if (!Directory.Exists(mailboxPath))
+ {
+ return new List<string> { "INBOX" };
+ }
+
+ var folders = Directory.GetDirectories(mailboxPath)
+ .Select(d => Path.GetFileName(d))
+ .ToList();
+ folders.Add("INBOX");
+ return await Task.FromResult(folders);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["MailboxLogMessages:FolderListFailed"],
+ username);
+ return new List<string> { "INBOX" };
+ }
+ }
+
+ // Set flags for a message
+ public async Task<bool> SetMessageFlagsAsync(string username, string folder, string messageId, MessageFlags flags, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("MailboxEventIds:FlagUpdate").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing MailboxEventIds:FlagUpdate");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:FlagUpdate"],
+ username, messageId, folder);
+
+ try
+ {
+ string mailboxPath = Path.Combine(MailboxRoot(username), folder);
+ string metadataPath = Path.Combine(mailboxPath, $"{messageId}.json");
+
+ if (!File.Exists(Path.Combine(mailboxPath, $"{messageId}.eml")))
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:MessageNotFound"],
+ username, messageId, folder);
+ return false;
+ }
+
+ string json = JsonSerializer.Serialize(flags);
+ await File.WriteAllTextAsync(metadataPath, json, cancellationToken);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["MailboxLogMessages:FlagUpdateFailed"],
+ username, messageId, folder);
+ return false;
+ }
+ }
+
+ // Retrieve message content
+ public async Task<string?> GetMessageContentAsync(string username, string folder, string messageId, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("MailboxEventIds:MessageFetch").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing MailboxEventIds:MessageFetch");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:MessageFetch"],
+ username, messageId, folder);
+
+ try
+ {
+ string mailboxPath = Path.Combine(MailboxRoot(username), folder);
+ string filePath = Path.Combine(mailboxPath, $"{messageId}.eml");
+
+ if (!File.Exists(filePath))
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["MailboxLogMessages:MessageNotFound"],
+ username, messageId, folder);
+ return null;
+ }
+
+ return await File.ReadAllTextAsync(filePath, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["MailboxLogMessages:MessageFetchFailed"],
+ username, messageId, folder);
+ return null;
+ }
+ }
+}
diff --git a/MailSharp.Common/UserConfig.cs b/MailSharp.Common/UserConfig.cs
new file mode 100644
index 0000000..a96ec3b
--- /dev/null
+++ b/MailSharp.Common/UserConfig.cs
@@ -0,0 +1,8 @@
+namespace MailSharp.Common;
+
+// User configuration model for JSON file
+public class UserConfig
+{
+ public string? Username { get; set; }
+ public string? Password { get; set; }
+}
diff --git a/MailSharp.DNS/DnsHeader.cs b/MailSharp.DNS/DnsHeader.cs
new file mode 100644
index 0000000..a7c9545
--- /dev/null
+++ b/MailSharp.DNS/DnsHeader.cs
@@ -0,0 +1,219 @@
+using MailSharp.DNS;
+using System.Buffers.Binary;
+
+namespace MailSharp.DNS;
+
+#region RFC specification
+/*
+4.1.1. Header section format
+
+The header contains the following fields:
+
+ 1 1 1 1 1 1
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | ID |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ |QR| Opcode |AA|TC|RD|RA| Z | RCODE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | QDCOUNT |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | ANCOUNT |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | NSCOUNT |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | ARCOUNT |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+ where:
+
+ ID A 16 bit identifier assigned by the program that
+ generates any kind of query. This identifier is copied
+ the corresponding reply and can be used by the requester
+ to match up replies to outstanding queries.
+
+ QR A one bit field that specifies whether this message is a
+ query (0), or a response (1).
+
+ OPCODE A four bit field that specifies kind of query in this
+ message. This value is set by the originator of a query
+ and copied into the response. The values are:
+
+ 0 a standard query (QUERY)
+
+ 1 an inverse query (IQUERY)
+
+ 2 a server status request (STATUS)
+
+ 3-15 reserved for future use
+
+ AA Authoritative Answer - this bit is valid in responses,
+ and specifies that the responding name server is an
+ authority for the domain name in question section.
+
+ Note that the contents of the answer section may have
+ multiple owner names because of aliases. The AA bit
+ corresponds to the name which matches the query name, or
+ the first owner name in the answer section.
+
+ TC TrunCation - specifies that this message was truncated
+ due to length greater than that permitted on the
+ transmission channel.
+
+ RD Recursion Desired - this bit may be set in a query and
+ is copied into the response. If RD is set, it directs
+ the name server to pursue the query recursively.
+ Recursive query support is optional.
+
+ RA Recursion Available - this be is set or cleared in a
+ response, and denotes whether recursive query support is
+ available in the name server.
+
+ Z Reserved for future use. Must be zero in all queries
+ and responses.
+
+ RCODE Response code - this 4 bit field is set as part of
+ responses. The values have the following
+ interpretation:
+
+ 0 No error condition
+
+ 1 Format error - The name server was
+ unable to interpret the query.
+
+ 2 Server failure - The name server was
+ unable to process this query due to a
+ problem with the name server.
+
+ 3 Name Error - Meaningful only for
+ responses from an authoritative name
+ server, this code signifies that the
+ domain name referenced in the query does
+ not exist.
+
+ 4 Not Implemented - The name server does
+ not support the requested kind of query.
+
+ 5 Refused - The name server refuses to
+ perform the specified operation for
+ policy reasons. For example, a name
+ server may not wish to provide the
+ information to the particular requester,
+ or a name server may not wish to perform
+ a particular operation (e.g., zone
+ transfer) for particular data.
+
+ 6-15 Reserved for future use.
+
+ QDCOUNT an unsigned 16 bit integer specifying the number of
+ entries in the question section.
+
+ ANCOUNT an unsigned 16 bit integer specifying the number of
+ resource records in the answer section.
+
+ NSCOUNT an unsigned 16 bit integer specifying the number of name
+ server resource records in the authority records
+ section.
+
+ ARCOUNT an unsigned 16 bit integer specifying the number of
+ resource records in the additional records section.
+
+ */
+#endregion
+
+public sealed class DnsHeader
+{
+ private readonly Memory<byte> memory;
+ private Span<byte> Span => memory.Span;
+
+ public DnsHeader(byte[] buffer)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ if (buffer.Length < 12)
+ throw new ArgumentException("Buffer too small for DNS header", nameof(buffer));
+
+ memory = buffer.AsMemory(0,12);
+ }
+
+ public ushort ID
+ {
+ get => BinaryPrimitives.ReadUInt16BigEndian(Span.Slice(0, 2));
+ set => BinaryPrimitives.WriteUInt16BigEndian(Span.Slice(0, 2), value);
+ }
+
+ private ushort Flags
+ {
+ get => BinaryPrimitives.ReadUInt16BigEndian(Span.Slice(2, 2));
+ set => BinaryPrimitives.WriteUInt16BigEndian(Span.Slice(2, 2), value);
+ }
+
+ public ushort QDCount
+ {
+ get => BinaryPrimitives.ReadUInt16BigEndian(Span.Slice(4, 2));
+ set => BinaryPrimitives.WriteUInt16BigEndian(Span.Slice(4, 2), value);
+ }
+
+ public ushort ANCount
+ {
+ get => BinaryPrimitives.ReadUInt16BigEndian(Span.Slice(6, 2));
+ //set => BinaryPrimitives.WriteUInt16BigEndian(Span.Slice(6, 2), value);
+ }
+
+ public ushort NSCount
+ {
+ get => BinaryPrimitives.ReadUInt16BigEndian(Span.Slice(8, 2));
+ set => BinaryPrimitives.WriteUInt16BigEndian(Span.Slice(8, 2), value);
+ }
+ public ushort ARCount => BinaryPrimitives.ReadUInt16BigEndian(Span.Slice(10, 2));
+
+ // Bit fields � perfect zoals je ze hebt
+ public bool QR
+ {
+ get => (Flags & 0x8000) != 0;
+ set => Flags = (ushort)(value ? Flags | 0x8000 : Flags & ~0x8000);
+ }
+
+ public OPCode OPCODE
+ {
+ get => (OPCode)((Flags >> 11) & 0xF);
+ set => Flags = (ushort)(Flags & ~0x7800 | ((ushort)value << 11));
+ }
+
+ public bool AA
+ {
+ get => (Flags & 0x0400) != 0;
+ set => Flags = (ushort)(value ? Flags | 0x0400 : Flags & ~0x0400);
+ }
+
+ public bool TC
+ {
+ get => (Flags & 0x0200) != 0;
+ set => Flags = (ushort)(value ? Flags | 0x0200 : Flags & ~0x0200);
+ }
+
+ public bool RD
+ {
+ get => (Flags & 0x0100) != 0;
+ set => Flags = (ushort)(value ? Flags | 0x0100 : Flags & ~0x0100);
+ }
+
+ public bool RA
+ {
+ get => (Flags & 0x0080) != 0;
+ set => Flags = (ushort)(value ? Flags | 0x0080 : Flags & ~0x0080);
+ }
+
+ public ushort Z
+ {
+ get => (ushort)((Flags >> 4) & 0x7);
+ set => Flags = (ushort)(Flags & ~0x0070 | ((value & 0x7) << 4));
+ }
+
+ public RCode RCODE
+ {
+ get => (RCode)(Flags & 0x000F);
+ set => Flags = (ushort)(Flags & ~0x000F | (ushort)value);
+ }
+
+ public ReadOnlySpan<byte> AsReadOnlySpan() => Span;
+}
diff --git a/MailSharp.DNS/DnsResourceRecord.cs b/MailSharp.DNS/DnsResourceRecord.cs
new file mode 100644
index 0000000..a476be7
--- /dev/null
+++ b/MailSharp.DNS/DnsResourceRecord.cs
@@ -0,0 +1,32 @@
+namespace MailSharp.DNS;
+
+public abstract class DnsResourceRecord<TType, TClass>
+ where TType : struct, Enum
+ where TClass : struct, Enum
+{
+ public string Name { get; init; } = string.Empty;
+ public TType Type { get; init; }
+ public TClass Class { get; init; }
+
+ protected DnsResourceRecord(string name, TType type, TClass @class)
+ {
+ Name = name.EndsWith('.') ? name : name + ".";
+ Type = type;
+ Class = @class;
+ }
+
+ protected DnsResourceRecord()
+ {
+ }
+
+ public override string ToString() => $"{Name,-32}\t{Class}\t{Type}";
+
+ public override int GetHashCode()
+ {
+ int hash = 17;
+ hash = hash * 31 + Class.GetHashCode();
+ hash = hash * 31 + Type.GetHashCode();
+ hash = hash * 31 + (Name != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Name) : 0);
+ return hash;
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/DnsWriter.cs b/MailSharp.DNS/DnsWriter.cs
new file mode 100644
index 0000000..db136ab
--- /dev/null
+++ b/MailSharp.DNS/DnsWriter.cs
@@ -0,0 +1,91 @@
+namespace MailSharp.DNS;
+
+public static class DnsWriter
+{
+ public static int WriteDomainName(Span<byte> destination, string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ destination[0] = 0;
+ return 1;
+ }
+
+ ReadOnlySpan<char> chars = name.AsSpan();
+ bool needsTrailingDot = chars[chars.Length - 1] != '.';
+
+ int totalLength = chars.Length + (needsTrailingDot ? 1 : 0);
+ if (totalLength > 255)
+ throw new ArgumentException("DNS name too long");
+
+ int pos = 0;
+ int start = 0;
+
+ // Loop tot einde van string (exclusief eventuele toe te voegen punt)
+ int end = chars.Length;
+ while (true)
+ {
+ int nextDot = chars.Slice(start).IndexOf('.');
+ if (nextDot == -1)
+ break;
+
+ int labelLength = nextDot;
+ if (labelLength > 63)
+ throw new ArgumentException("DNS label too long");
+
+ destination[pos++] = (byte)labelLength;
+ for (int i = 0; i < labelLength; i++)
+ destination[pos++] = (byte)chars[start + i];
+
+ start += nextDot + 1;
+ }
+
+ // Laatste label
+ int lastLabelLength = end - start;
+ if (lastLabelLength > 63)
+ throw new ArgumentException("DNS label too long");
+
+ destination[pos++] = (byte)lastLabelLength;
+ for (int i = 0; i < lastLabelLength; i++)
+ destination[pos++] = (byte)chars[start + i];
+
+ // Voeg punt toe als nodig
+ if (needsTrailingDot)
+ {
+ destination[pos++] = 0; // leeg label = root
+ }
+ else
+ {
+ // Als het al eindigt met '.', dan is laatste label leeg → schrijf 0
+ if (lastLabelLength == 0)
+ destination[pos - 1] = 0;
+ else
+ destination[pos++] = 0;
+ }
+
+ return pos;
+ }
+
+ public static int MeasureDomainName(string name)
+ {
+ if (string.IsNullOrEmpty(name)) return 1; // alleen root label (0)
+
+ ReadOnlySpan<char> span = name.AsSpan();
+ int total = 0;
+ int start = 0;
+
+ for (int i = 0; i <= span.Length; i++)
+ {
+ if (i == span.Length || span[i] == '.')
+ {
+ int labelLen = i - start;
+ if (labelLen > 63) throw new ArgumentException("Label too long");
+ total += 1 + labelLen; // length byte + data
+ start = i + 1;
+ }
+ }
+
+ total += 1; // null terminator
+ return total;
+ }
+}
+
diff --git a/MailSharp.DNS/EnumsCollection.cs b/MailSharp.DNS/EnumsCollection.cs
new file mode 100644
index 0000000..68c7534
--- /dev/null
+++ b/MailSharp.DNS/EnumsCollection.cs
@@ -0,0 +1,422 @@
+/*
+ * http://www.iana.org/assignments/dns-parameters
+ *
+ * https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml
+ *
+ */
+
+
+namespace MailSharp.DNS;
+
+/*
+ * 3.2.2. TYPE values
+ *
+ * TYPE fields are used in resource records.
+ * Note that these types are a subset of QTYPEs.
+ *
+ * TYPE value meaning
+ */
+public enum DnsType : ushort
+{
+ Unknown = 0, // Reserved Unknown type
+ A = 1, // a IPV4 host address
+ NS = 2, // an authoritative name server
+ MD = 3, // a mail destination (Obsolete - use MX)
+ MF = 4, // a mail forwarder (Obsolete - use MX)
+ CNAME = 5, // the canonical name for an alias
+ SOA = 6, // marks the start of a zone of authority
+ MB = 7, // a mailbox domain name (EXPERIMENTAL)
+ MG = 8, // a mail group member (EXPERIMENTAL)
+ MR = 9, // a mail rename domain name (EXPERIMENTAL)
+ NULL = 10, // a null RR (EXPERIMENTAL)
+ WKS = 11, // a well known service description
+ PTR = 12, // a domain name pointer
+ HINFO = 13, // host information
+ MINFO = 14, // mailbox or mail list information
+ MX = 15, // mail exchange
+ TXT = 16, // text strings
+
+ RP = 17, // The Responsible Person rfc1183
+ AFSDB = 18, // AFS Data Base location
+ X25 = 19, // X.25 address rfc1183
+ ISDN = 20, // ISDN address rfc1183
+ RT = 21, // The Route Through rfc1183
+
+ NSAP = 22, // Network service access point address rfc1706
+ NSAPPTR = 23, // Obsolete, rfc1348
+
+ SIG = 24, // Cryptographic public key signature rfc2931 / rfc2535
+ KEY = 25, // Public key as used in DNSSEC rfc2535
+
+ PX = 26, // Pointer to X.400/RFC822 mail mapping information rfc2163
+
+ GPOS = 27, // Geographical position rfc1712 (obsolete)
+
+ AAAA = 28, // a IPV6 host address, rfc3596
+
+ LOC = 29, // Location information rfc1876
+
+ NXT = 30, // Next Domain, Obsolete rfc2065 / rfc2535
+
+ EID = 31, // *** Endpoint Identifier (Patton)
+ NIMLOC = 32, // *** Nimrod Locator (Patton)
+
+ SRV = 33, // Location of services rfc2782
+
+ ATMA = 34, // *** ATM Address (Dobrowski)
+
+ NAPTR = 35, // The Naming Authority Pointer rfc3403
+
+ KX = 36, // Key Exchange Delegation Record rfc2230
+
+ CERT = 37, // *** CERT RFC2538
+
+ A6 = 38, // IPv6 address rfc3363 (rfc2874 rfc3226)
+ DNAME = 39, // A way to provide aliases for a whole domain, not just a single domain name as with CNAME. rfc2672
+
+ SINK = 40, // *** SINK Eastlake
+ OPT = 41, // *** OPT RFC2671
+
+ APL = 42, // *** APL [RFC3123]
+
+ DS = 43, // Delegation Signer rfc3658
+
+ SSHFP = 44, // SSH Key Fingerprint rfc4255
+ IPSECKEY = 45, // IPSECKEY rfc4025
+ RRSIG = 46, // RRSIG rfc3755
+ NSEC = 47, // NSEC rfc3755
+ DNSKEY = 48, // DNSKEY 3755
+ DHCID = 49, // DHCID rfc4701
+
+ NSEC3 = 50, // NSEC3 rfc5155
+ NSEC3PARAM = 51, // NSEC3PARAM rfc5155
+
+ TLSA = 52, // TLSA [RFC6698]
+ SMIMEA = 53, // S/MIME cert association [RFC8162] SMIMEA/smimea-completed-template 2015-12-01
+ Unassigned = 54,
+ HIP = 55, // Host Identity Protocol [RFC8005]
+ NINFO = 56, // NINFO [Jim_Reid] NINFO/ninfo-completed-template 2008-01-21
+ RKEY = 57, // RKEY [Jim_Reid] RKEY/rkey-completed-template 2008-01-21
+ TALINK = 58, // Trust Anchor LINK [Wouter_Wijngaards] TALINK/talink-completed-template 2010-02-17
+ CDS = 59, // Child DS [RFC7344] CDS/cds-completed-template 2011-06-06
+ CDNSKEY = 60, // DNSKEY(s) the Child wants reflected in DS [RFC7344] 2014-06-16
+ OPENPGPKEY = 61, // OpenPGP Key [RFC7929] OPENPGPKEY/openpgpkey-completed-template 2014-08-12
+ CSYNC = 62, // Child-To-Parent Synchronization [RFC7477] 2015-01-27
+ ZONEMD = 63, // Message Digest Over Zone Data [RFC8976] ZONEMD/zonemd-completed-template 2018-12-12
+ SVCB = 64, // General-purpose service binding [RFC9460] SVCB/svcb-completed-template 2020-06-30
+ HTTPS = 65, // SVCB-compatible type for use with HTTP [RFC9460] HTTPS/https-completed-template 2020-06-30
+ DSYNC = 66, // Endpoint discovery for delegation synchronization [RFC9859] DSYNC/dsync-completed-template 2024-12-10
+ HHIT = 67, // Hierarchical Host Identity Tag [draft-ietf-drip-registries-28] HHIT/hhit-completed-template 2025-05-20
+ BRID = 68, // UAS Broadcast Remote Identification [draft-ietf-drip-registries-28] BRID/brid-completed-template 2025-05-20
+
+ //Unassigned 69-98
+
+ SPF = 99, // SPF rfc4408
+
+ UINFO = 100, // *** IANA-Reserved
+ UID = 101, // *** IANA-Reserved
+ GID = 102, // *** IANA-Reserved
+ UNSPEC = 103, // *** IANA-Reserved
+
+ NID = 104, // [RFC6742] ILNP/nid-completed-template
+ L32 = 105, // [RFC6742] ILNP/l32-completed-template
+ L64 = 106, // [RFC6742] ILNP/l64-completed-template
+ LP = 107, // [RFC6742] ILNP/lp-completed-template
+ EUI48 = 108, // an EUI-48 address [RFC7043] EUI48/eui48-completed-template 2013-03-27
+ EUI64 = 109, // an EUI-64 address [RFC7043] EUI64/eui64-completed-template 2013-03-27
+ // Unassigned 110-127
+ NXNAME = 128, // NXDOMAIN indicator for Compact Denial of Existence [RFC9824]
+ // Unassigned 129-248
+ TKEY = 249, // Transaction Key [RFC2930]
+ TSIG = 250, // Transaction Signature [RFC8945]
+ IXFR = 251, // incremental transfer [RFC1995]
+ AXFR = 252, // transfer of an entire zone [RFC1035][RFC5936]
+ MAILB = 253, // mailbox-related RRs (MB, MG or MR) [RFC1035]
+ MAILA = 254, // mail agent RRs (OBSOLETE - see MX) [RFC1035]
+
+ ANY = 255, // A request for some or all records the server has available [RFC1035][RFC6895][RFC8482]
+
+ URI = 256, // URI [RFC7553] URI/uri-completed-template 2011-02-22
+ CAA = 257, // Certification Authority Restriction [RFC8659] CAA/caa-completed-template 2011-04-07
+ AVC = 258, // Application Visibility and Control [Wolfgang_Riedel] AVC/avc-completed-template 2016-02-26
+ DOA = 259, // Digital Object Architecture [draft-durand-doa-over-dns-02] DOA/doa-completed-template 2017-08-30
+ AMTRELAY = 260, // Automatic Multicast Tunneling Relay [RFC8777] AMTRELAY/amtrelay-completed-template 2019-02-06
+ RESINFO = 261, // Resolver Information as Key/Value Pairs [RFC9606] RESINFO/resinfo-completed-template 2023-11-02
+ WALLET = 262, // Public wallet address [Paul_Hoffman] WALLET/wallet-completed-template 2024-06-21
+ CLA = 263, // BP Convergence Layer Adapter [draft-johnson-dns-ipn-cla-07] CLA/cla-completed-template 2024-07-25
+ IPN = 264, // BP Node Number [draft-johnson-dns-ipn-cla-07] IPN/ipn-completed-template 2024-07-25
+ // Unassigned 265-32767
+ TA = 32768, // DNSSEC Trust Authorities [Sam_Weiler]
+ DLV = 32769 // DNSSEC Lookaside Validation (OBSOLETE) [RFC8749][RFC4431]
+}
+
+/*
+ * 3.2.3. QTYPE values
+ *
+ * QTYPE fields appear in the question part of a query. QTYPES are a
+ * superset of TYPEs, hence all TYPEs are valid QTYPEs. In addition, the
+ * following QTYPEs are defined:
+ *
+ * QTYPE value meaning
+ */
+public enum DnsQType : ushort
+{
+ A = DnsType.A, // a IPV4 host address
+ NS = DnsType.NS, // an authoritative name server
+ MD = DnsType.MD, // a mail destination (Obsolete - use MX)
+ MF = DnsType.MF, // a mail forwarder (Obsolete - use MX)
+ CNAME = DnsType.CNAME, // the canonical name for an alias
+ SOA = DnsType.SOA, // marks the start of a zone of authority
+ MB = DnsType.MB, // a mailbox domain name (EXPERIMENTAL)
+ MG = DnsType.MG, // a mail group member (EXPERIMENTAL)
+ MR = DnsType.MR, // a mail rename domain name (EXPERIMENTAL)
+ NULL = DnsType.NULL, // a null RR (EXPERIMENTAL)
+ WKS = DnsType.WKS, // a well known service description
+ PTR = DnsType.PTR, // a domain name pointer
+ HINFO = DnsType.HINFO, // host information
+ MINFO = DnsType.MINFO, // mailbox or mail list information
+ MX = DnsType.MX, // mail exchange
+ TXT = DnsType.TXT, // text strings
+
+ RP = DnsType.RP, // The Responsible Person rfc1183
+ AFSDB = DnsType.AFSDB, // AFS Data Base location
+ X25 = DnsType.X25, // X.25 address rfc1183
+ ISDN = DnsType.ISDN, // ISDN address rfc1183
+ RT = DnsType.RT, // The Route Through rfc1183
+
+ NSAP = DnsType.NSAP, // Network service access point address rfc1706
+ NSAP_PTR = DnsType.NSAPPTR, // Obsolete, rfc1348
+
+ SIG = DnsType.SIG, // Cryptographic public key signature rfc2931 / rfc2535
+ KEY = DnsType.KEY, // Public key as used in DNSSEC rfc2535
+
+ PX = DnsType.PX, // Pointer to X.400/RFC822 mail mapping information rfc2163
+
+ GPOS = DnsType.GPOS, // Geographical position rfc1712 (obsolete)
+
+ AAAA = DnsType.AAAA, // a IPV6 host address
+
+ LOC = DnsType.LOC, // Location information rfc1876
+
+ NXT = DnsType.NXT, // Obsolete rfc2065 / rfc2535
+
+ EID = DnsType.EID, // *** Endpoint Identifier (Patton)
+ NIMLOC = DnsType.NIMLOC, // *** Nimrod Locator (Patton)
+
+ SRV = DnsType.SRV, // Location of services rfc2782
+
+ ATMA = DnsType.ATMA, // *** ATM Address (Dobrowski)
+
+ NAPTR = DnsType.NAPTR, // The Naming Authority Pointer rfc3403
+
+ KX = DnsType.KX, // Key Exchange Delegation Record rfc2230
+
+ CERT = DnsType.CERT, // *** CERT RFC2538
+
+ A6 = DnsType.A6, // IPv6 address rfc3363
+ DNAME = DnsType.DNAME, // A way to provide aliases for a whole domain, not just a single domain name as with CNAME. rfc2672
+
+ SINK = DnsType.SINK, // *** SINK Eastlake
+ OPT = DnsType.OPT, // *** OPT RFC2671
+
+ APL = DnsType.APL, // *** APL [RFC3123]
+
+ DS = DnsType.DS, // Delegation Signer rfc3658
+
+ SSHFP = DnsType.SSHFP, // *** SSH Key Fingerprint RFC-ietf-secsh-dns
+ IPSECKEY = DnsType.IPSECKEY, // rfc4025
+ RRSIG = DnsType.RRSIG, // *** RRSIG RFC-ietf-dnsext-dnssec-2535
+ NSEC = DnsType.NSEC, // *** NSEC RFC-ietf-dnsext-dnssec-2535
+ DNSKEY = DnsType.DNSKEY, // *** DNSKEY RFC-ietf-dnsext-dnssec-2535
+ DHCID = DnsType.DHCID, // rfc4701
+
+ NSEC3 = DnsType.NSEC3, // RFC5155
+ NSEC3PARAM = DnsType.NSEC3PARAM, // RFC5155
+
+ TLSA = DnsType.TLSA, // TLSA [RFC6698]
+ SMIMEA = DnsType.SMIMEA, // S/MIME cert association [RFC8162] SMIMEA/smimea-completed-template 2015-12-01
+ Unassigned = DnsType.Unassigned,
+ HIP = DnsType.HIP, // Host Identity Protocol [RFC8005]
+ NINFO = DnsType.NINFO, // NINFO [Jim_Reid] NINFO/ninfo-completed-template 2008-01-21
+ RKEY = DnsType.RKEY, // RKEY [Jim_Reid] RKEY/rkey-completed-template 2008-01-21
+ TALINK = DnsType.TALINK, // Trust Anchor LINK [Wouter_Wijngaards] TALINK/talink-completed-template 2010-02-17
+ CDS = DnsType.CDS, // Child DS [RFC7344] CDS/cds-completed-template 2011-06-06
+ CDNSKEY = DnsType.CDNSKEY, // DNSKEY(s) the Child wants reflected in DS [RFC7344] 2014-06-16
+ OPENPGPKEY = DnsType.OPENPGPKEY, // OpenPGP Key [RFC7929] OPENPGPKEY/openpgpkey-completed-template 2014-08-12
+ CSYNC = DnsType.CSYNC, // Child-To-Parent Synchronization [RFC7477] 2015-01-27
+ ZONEMD = DnsType.ZONEMD, // Message Digest Over Zone Data [RFC8976] ZONEMD/zonemd-completed-template 2018-12-12
+ SVCB = DnsType.SVCB, // General-purpose service binding [RFC9460] SVCB/svcb-completed-template 2020-06-30
+ HTTPS = DnsType.HTTPS, // SVCB-compatible type for use with HTTP [RFC9460] HTTPS/https-completed-template 2020-06-30
+ DSYNC = DnsType.DSYNC, // Endpoint discovery for delegation synchronization [RFC9859] DSYNC/dsync-completed-template 2024-12-10
+ HHIT = DnsType.HHIT, // Hierarchical Host Identity Tag [draft-ietf-drip-registries-28] HHIT/hhit-completed-template 2025-05-20
+ BRID = DnsType.BRID, // UAS Broadcast Remote Identification [draft-ietf-drip-registries-28] BRID/brid-completed-template 2025-05-20
+
+ //Unassigned 69-98
+
+ SPF = DnsType.SPF, // RFC4408
+ UINFO = DnsType.UINFO, // *** IANA-Reserved
+ UID = DnsType.UID, // *** IANA-Reserved
+ GID = DnsType.GID, // *** IANA-Reserved
+ UNSPEC = DnsType.UNSPEC, // *** IANA-Reserved
+
+ NID = DnsType.NID, // [RFC6742] ILNP/nid-completed-template
+ L32 = DnsType.L32, // [RFC6742] ILNP/l32-completed-template
+ L64 = DnsType.L64, // [RFC6742] ILNP/l64-completed-template
+ LP = DnsType.LP, // [RFC6742] ILNP/lp-completed-template
+ EUI48 = DnsType.EUI48, // an EUI-48 address [RFC7043] EUI48/eui48-completed-template 2013-03-27
+ EUI64 = DnsType.EUI64, // an EUI-64 address [RFC7043] EUI64/eui64-completed-template 2013-03-27
+ // Unassigned 110-127
+ NXNAME = DnsType.NXNAME, // NXDOMAIN indicator for Compact Denial of Existence [RFC9824]
+ // Unassigned 129-248
+ TKEY = DnsType.TKEY, // Transaction Key [RFC2930]
+ TSIG = DnsType.TSIG, // Transaction Signature [RFC8945]
+ IXFR = DnsType.IXFR, // incremental transfer [RFC1995]
+ AXFR = DnsType.AXFR, // transfer of an entire zone [RFC1035][RFC5936]
+ MAILB = DnsType.MAILB, // mailbox-related RRs (MB, MG or MR) [RFC1035]
+ MAILA = DnsType.MAILA, // mail agent RRs (OBSOLETE - see MX) [RFC1035]
+
+ ANY = DnsType.ANY, // A request for some or all records the server has available [RFC1035][RFC6895][RFC8482]
+
+ URI = DnsType.URI, // URI [RFC7553] URI/uri-completed-template 2011-02-22
+ CAA = DnsType.CAA, // Certification Authority Restriction [RFC8659] CAA/caa-completed-template 2011-04-07
+ AVC = DnsType.AVC, // Application Visibility and Control [Wolfgang_Riedel] AVC/avc-completed-template 2016-02-26
+ DOA = DnsType.DOA, // Digital Object Architecture [draft-durand-doa-over-dns-02] DOA/doa-completed-template 2017-08-30
+ AMTRELAY = DnsType.AMTRELAY, // Automatic Multicast Tunneling Relay [RFC8777] AMTRELAY/amtrelay-completed-template 2019-02-06
+ RESINFO = DnsType.RESINFO, // Resolver Information as Key/Value Pairs [RFC9606] RESINFO/resinfo-completed-template 2023-11-02
+ WALLET = DnsType.WALLET, // Public wallet address [Paul_Hoffman] WALLET/wallet-completed-template 2024-06-21
+ CLA = DnsType.CLA, // BP Convergence Layer Adapter [draft-johnson-dns-ipn-cla-07] CLA/cla-completed-template 2024-07-25
+ IPN = DnsType.IPN, // BP Node Number [draft-johnson-dns-ipn-cla-07] IPN/ipn-completed-template 2024-07-25
+ // Unassigned 265-32767
+ TA = DnsType.TA, // DNSSEC Trust Authorities [Sam_Weiler]
+ DLV = DnsType.DLV // DNSSEC Lookaside Validation (OBSOLETE) [RFC8749][RFC4431]
+}
+/*
+ * 3.2.4. CLASS values
+ *
+ * CLASS fields appear in resource records. The following CLASS mnemonics
+ *and values are defined:
+ *
+ * CLASS value meaning
+ */
+public enum DnsClass : ushort
+{
+ IN = 1, // the Internet
+ CS = 2, // the CSNET class (Obsolete - used only for examples in some obsolete RFCs)
+ CH = 3, // the CHAOS class
+ HS = 4 // Hesiod [Dyer 87]
+}
+/*
+ * 3.2.5. QCLASS values
+ *
+ * QCLASS fields appear in the question section of a query. QCLASS values
+ * are a superset of CLASS values; every CLASS is a valid QCLASS. In
+ * addition to CLASS values, the following QCLASSes are defined:
+ *
+ * QCLASS value meaning
+ */
+public enum DnsQClass : ushort
+{
+ IN = DnsClass.IN, // the Internet
+ CS = DnsClass.CS, // the CSNET class (Obsolete - used only for examples in some obsolete RFCs)
+ CH = DnsClass.CH, // the CHAOS class
+ HS = DnsClass.HS, // Hesiod [Dyer 87]
+
+ ANY = 255 // any class
+}
+
+/*
+RCODE Response code - this 4 bit field is set as part of
+ responses. The values have the following
+ interpretation:
+ */
+public enum RCode : ushort
+{
+ NoError = 0, // No Error [RFC1035]
+ FormErr = 1, // Format Error [RFC1035]
+ ServFail = 2, // Server Failure [RFC1035]
+ NXDomain = 3, // Non-Existent Domain [RFC1035]
+ NotImp = 4, // Not Implemented [RFC1035]
+ Refused = 5, // Query Refused [RFC1035]
+ YXDomain = 6, // Name Exists when it should not [RFC2136]
+ YXRRSet = 7, // RR Set Exists when it should not [RFC2136]
+ NXRRSet = 8, // RR Set that should exist does not [RFC2136]
+ NotAuth = 9, // Server Not Authoritative for zone [RFC2136]
+ NotZone = 10, // Name not contained in zone [RFC2136]
+
+ RESERVED11 = 11, // Reserved
+ RESERVED12 = 12, // Reserved
+ RESERVED13 = 13, // Reserved
+ RESERVED14 = 14, // Reserved
+ RESERVED15 = 15, // Reserved
+
+ BADVERSSIG = 16, // Bad OPT Version [RFC2671]
+ // TSIG Signature Failure [RFC2845]
+ BADKEY = 17, // Key not recognized [RFC2845]
+ BADTIME = 18, // Signature out of time window [RFC2845]
+ BADMODE = 19, // Bad TKEY Mode [RFC2930]
+ BADNAME = 20, // Duplicate key name [RFC2930]
+ BADALG = 21, // Algorithm not supported [RFC2930]
+ BADTRUNC = 22 // Bad Truncation [RFC4635]
+ /*
+ 23-3840 available for assignment
+ 0x0016-0x0F00
+ 3841-4095 Private Use
+ 0x0F01-0x0FFF
+ 4096-65535 available for assignment
+ 0x1000-0xFFFF
+ */
+
+}
+
+/*
+OPCODE A four bit field that specifies kind of query in this
+ message. This value is set by the originator of a query
+ and copied into the response. The values are:
+
+ 0 a standard query (QUERY)
+
+ 1 an inverse query (IQUERY)
+
+ 2 a server status request (STATUS)
+
+ 3-15 reserved for future use
+ */
+public enum OPCode
+{
+ Query = 0, // a standard query (QUERY)
+ IQUERY = 1, // OpCode Retired (previously IQUERY - No further [RFC3425]
+ // assignment of this code available)
+ Status = 2, // a server status request (STATUS) RFC1035
+ RESERVED3 = 3, // IANA
+
+ Notify = 4, // RFC1996
+ Update = 5, // RFC2136
+
+ RESERVED6 = 6,
+ RESERVED7 = 7,
+ RESERVED8 = 8,
+ RESERVED9 = 9,
+ RESERVED10 = 10,
+ RESERVED11 = 11,
+ RESERVED12 = 12,
+ RESERVED13 = 13,
+ RESERVED14 = 14,
+ RESERVED15 = 15,
+}
+
+public enum TransportType
+{
+ Udp,
+ Tcp
+}
+
+/// <summary>
+/// IPSECKEY
+/// </summary>
+public enum GatewayType : byte
+{
+ NoGateway = 0,
+ IPv4 = 1,
+ IPv6 = 2,
+ WireFormatDomainName = 3
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/MailSharp.DNS.csproj b/MailSharp.DNS/MailSharp.DNS.csproj
new file mode 100644
index 0000000..52c44e7
--- /dev/null
+++ b/MailSharp.DNS/MailSharp.DNS.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Update="Root\named_root.txt">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+</Project>
diff --git a/MailSharp.DNS/QuestionRecord.cs b/MailSharp.DNS/QuestionRecord.cs
new file mode 100644
index 0000000..fd80b69
--- /dev/null
+++ b/MailSharp.DNS/QuestionRecord.cs
@@ -0,0 +1,68 @@
+using System.Buffers.Binary;
+
+namespace MailSharp.DNS;
+
+#region Rfc 1034/1035
+/*
+4.1.2. Question section format
+
+The question section is used to carry the "question" in most queries,
+i.e., the parameters that define what is being asked. The section
+contains QDCOUNT (usually 1) entries, each of the following format:
+
+ 1 1 1 1 1 1
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | |
+ / QNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | QTYPE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | QCLASS |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+QNAME a domain name represented as a sequence of labels, where
+ each label consists of a length octet followed by that
+ number of octets. The domain name terminates with the
+ zero length octet for the null label of the root. Note
+ that this field may be an odd number of octets; no
+ padding is used.
+
+QTYPE a two octet code which specifies the type of the query.
+ The values for this field include all codes valid for a
+ TYPE field, together with some more general codes which
+ can match more than one type of RR.
+
+
+QCLASS a two octet code that specifies the class of the query.
+ For example, the QCLASS field is IN for the Internet.
+*/
+#endregion
+
+public sealed class QuestionRecord : DnsResourceRecord<DnsQType, DnsQClass>
+{
+ public QuestionRecord(string name, DnsQType type, DnsQClass @class = DnsQClass.IN)
+ : base(name, type, @class)
+ {
+ }
+
+ // Parsing constructor
+ public QuestionRecord(ref RecordReader rr)
+ {
+ Name = rr.ReadDomainName();
+ Type = (DnsQType)rr.ReadUInt16();
+ Class = (DnsQClass)rr.ReadUInt16();
+ }
+
+ // Schrijft direct naar een Span<byte> → zero alloc
+ public int WriteTo(Span<byte> destination)
+ {
+ int written = DnsWriter.WriteDomainName(destination, Name);
+ BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(written, 2), (ushort)Type);
+ BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(written + 2, 2), (ushort)Class);
+ return written + 4;
+ }
+}
diff --git a/MailSharp.DNS/RR.cs b/MailSharp.DNS/RR.cs
new file mode 100644
index 0000000..c363390
--- /dev/null
+++ b/MailSharp.DNS/RR.cs
@@ -0,0 +1,122 @@
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS;
+
+#region RFC info
+/*
+3.2. RR definitions
+
+3.2.1. Format
+
+All RRs have the same top level format shown below:
+
+ 1 1 1 1 1 1
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | |
+ / /
+ / NAME /
+ | |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | TYPE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | CLASS |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | TTL |
+ | |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | RDLENGTH |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
+ / RDATA /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+
+where:
+
+NAME an owner name, i.e., the name of the node to which this
+ resource record pertains.
+
+TYPE two octets containing one of the RR TYPE codes.
+
+CLASS two octets containing one of the RR CLASS codes.
+
+TTL a 32 bit signed integer that specifies the time interval
+ that the resource record may be cached before the source
+ of the information should again be consulted. Zero
+ values are interpreted to mean that the RR can only be
+ used for the transaction in progress, and should not be
+ cached. For example, SOA records are always distributed
+ with a zero TTL to prohibit caching. Zero values can
+ also be used for extremely volatile data.
+
+RDLENGTH an unsigned 16 bit integer that specifies the length in
+ octets of the RDATA field.
+
+RDATA a variable length string of octets that describes the
+ resource. The format of this information varies
+ according to the TYPE and CLASS of the resource record.
+*/
+#endregion
+
+/// <summary>
+/// Resource Record (rfc1034 3.6.)
+/// </summary>
+public class RR : DnsResourceRecord<DnsType, DnsClass>
+{
+ /// <summary>
+ /// Time to live, the time interval that the resource record may be cached
+ /// </summary>
+ public uint TTL
+ {
+ get
+ {
+ return (uint)Math.Max(0, m_TTL - TimeLived);
+ }
+ set
+ {
+ m_TTL = value;
+ }
+ }
+ private uint m_TTL;
+
+ /// <summary>
+ ///
+ /// </summary>
+ public ushort RDLength;
+
+ /// <summary>
+ /// One of the Record* classes
+ /// </summary>
+ public DnsRecord? Record;
+
+ public int TimeLived => DateTime.Now.Subtract(TimeStamp).Seconds;
+
+ // when this record was received
+ public DateTime TimeStamp;
+
+ public RR()
+ {
+ }
+
+ public RR(RecordReader rr)
+ {
+ TimeStamp = DateTime.Now;
+ Name = rr.ReadDomainName();
+ Type = (DnsType)rr.ReadUInt16();
+ Class = (DnsClass)rr.ReadUInt16();
+ TTL = rr.ReadUInt32();
+ RDLength = rr.ReadUInt16();
+ Record = rr.ReadRecord(Type);
+ }
+
+ public override string ToString()
+ {
+ return string.Format("{0,-32} {1}\t{2}\t{3}\t{4}",
+ Name,
+ TTL,
+ Class,
+ Type,
+ Record);
+ }
+}
diff --git a/MailSharp.DNS/RecordReader.cs b/MailSharp.DNS/RecordReader.cs
new file mode 100644
index 0000000..9c8de22
--- /dev/null
+++ b/MailSharp.DNS/RecordReader.cs
@@ -0,0 +1,191 @@
+using MailSharp.DNS.Records;
+using System.Buffers.Binary;
+using System.Text;
+
+namespace MailSharp.DNS;
+
+public class RecordReader
+{
+ private readonly byte[] data;
+ private int position;
+ public RecordReader(byte[] data)
+ {
+ this.data = data;
+ position = 0;
+ }
+
+ public RecordReader(byte[] data, int position)
+ {
+ this.data = data;
+ this.position = position;
+ }
+ public int Position
+ {
+ get => position;
+ set => position = value;
+ }
+
+ public byte ReadByte()
+ {
+ return data[position++];
+ }
+
+ public byte PeekPosition(int pos)
+ {
+ return data[pos];
+ }
+
+ public byte PeekByte()
+ {
+ return data[position];
+ }
+
+ public char ReadChar()
+ {
+ return (char)ReadByte();
+ }
+
+ public ushort ReadUInt16()
+ {
+ ushort value = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(position));
+ position += 2;
+ return value;
+ }
+
+ public UInt16 ReadUInt16(int offset)
+ {
+ position += offset;
+ return ReadUInt16();
+ }
+
+ public uint ReadUInt32()
+ {
+ uint value = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(position));
+ position += 4;
+ return value;
+ }
+
+ public string ReadDomainName()
+ {
+ StringBuilder name = new();
+ int length;
+
+ // get the length of the first label
+ while ((length = ReadByte()) != 0)
+ {
+ // top 2 bits set denotes domain name compression and to reference elsewhere
+ if ((length & 0xc0) == 0xc0)
+ {
+ // work out the existing domain name, copy this pointer
+ RecordReader newRecordReader = new(data, (length & 0x3f) << 8 | ReadByte());
+
+ name.Append(newRecordReader.ReadDomainName());
+ return name.ToString();
+ }
+
+ // if not using compression, copy a char at a time to the domain name
+ while (length > 0)
+ {
+ name.Append(ReadChar());
+ length--;
+ }
+ name.Append('.');
+ }
+ if (name.Length == 0)
+ return ".";
+ else
+ return name.ToString();
+ }
+
+ public string ReadString() =>ReadString(ReadByte());
+
+ public string ReadString(int length) => Encoding.ASCII.GetString(ReadBytes(length));
+
+ // changed 28 augustus 2008
+ public byte[] ReadBytes(int count)
+ {
+ byte[] result = new byte[count];
+ Buffer.BlockCopy(data, position, result, 0, count);
+ position += count;
+ return result;
+ }
+
+ public DnsRecord ReadRecord(DnsType type)
+ {
+ return type switch
+ {
+ DnsType.A => new RecordA(this),
+ DnsType.NS => new RecordNS(this),
+ DnsType.CNAME => new RecordCNAME(this),
+ DnsType.SOA => new RecordSOA(this),
+ DnsType.MB => new RecordMB(this),
+ DnsType.MG => new RecordMG(this),
+ DnsType.MR => new RecordMR(this),
+ DnsType.NULL => new RecordNULL(this),
+ DnsType.WKS => new RecordWKS(this),
+ DnsType.PTR => new RecordPTR(this),
+ DnsType.HINFO => new RecordHINFO(this),
+ DnsType.MINFO => new RecordMINFO(this),
+ DnsType.MX => new RecordMX(this),
+ DnsType.TXT => new RecordTXT(this),
+ DnsType.RP => new RecordRP(this),
+ DnsType.AFSDB => new RecordAFSDB(this),
+ DnsType.X25 => new RecordX25(this),
+ DnsType.ISDN => new RecordISDN(this),
+ DnsType.RT => new RecordRT(this),
+ DnsType.NSAP => new RecordNSAP(this),
+ DnsType.SIG => new RecordSIG(this),
+ DnsType.KEY => new RecordKEY(this),
+ DnsType.PX => new RecordPX(this),
+ DnsType.AAAA => new RecordAAAA(this),
+ DnsType.LOC => new RecordLOC(this),
+ DnsType.SRV => new RecordSRV(this),
+ DnsType.NAPTR => new RecordNAPTR(this),
+ DnsType.KX => new RecordKX(this),
+ DnsType.DNAME => new RecordDNAME(this),
+ DnsType.DS => new RecordDS(this),
+ DnsType.TKEY => new RecordTKEY(this),
+ DnsType.TSIG => new RecordTSIG(this),
+ DnsType.CAA => new RecordCAA(this),
+
+ DnsType.A6 => new RecordA6(this),
+ DnsType.SPF => new RecordSPF(this),
+ DnsType.IPSECKEY => new RecordIPSECKEY(this),
+ DnsType.APL => new RecordAPL(this),
+
+ DnsType.RRSIG => new RecordRRSIG(this),
+ DnsType.DNSKEY => new RecordDNSKEY(this),
+ DnsType.NSEC => new RecordNSEC(this),
+ DnsType.NSEC3 => new RecordNSEC3(this),
+ DnsType.NSEC3PARAM => new RecordNSEC3PARAM(this),
+
+ DnsType.CERT => new RecordCERT(this),
+
+ DnsType.EID => new RecordEID(this),
+ DnsType.NIMLOC => new RecordNIMLOC(this),
+ DnsType.ATMA => new RecordATMA(this),
+ DnsType.SINK => new RecordSINK(this),
+ DnsType.SSHFP => new RecordSSHFP(this),
+ DnsType.DHCID => new RecordDHCID(this),
+ DnsType.HIP => new RecordHIP(this),
+ DnsType.UINFO => new RecordUINFO(this),
+ DnsType.UID => new RecordUID(this),
+ DnsType.GID => new RecordGID(this),
+ DnsType.UNSPEC => new RecordUNSPEC(this),
+
+ // Incomplete implementation
+ DnsType.OPT => new RecordOPT(this),
+
+ // Obsolete records
+ DnsType.GPOS => new RecordGPOS(this),
+ DnsType.NSAPPTR => new RecordNSAPPTR(this),
+ DnsType.NXT => new RecordNXT(this),
+ DnsType.MD => new RecordMD(this),
+ DnsType.MF => new RecordMF(this),
+
+
+ _ => new RecordUnknown(this),
+ };
+ }
+
+}
diff --git a/MailSharp.DNS/Records/DnsRecord.cs b/MailSharp.DNS/Records/DnsRecord.cs
new file mode 100644
index 0000000..1b8f2e8
--- /dev/null
+++ b/MailSharp.DNS/Records/DnsRecord.cs
@@ -0,0 +1,9 @@
+// Stuff records are made of
+
+namespace MailSharp.DNS.Records;
+
+public abstract record DnsRecord
+{
+ protected DnsRecord() { }
+ protected DnsRecord(RecordReader reader) { }
+}
diff --git a/MailSharp.DNS/Records/RecordA.cs b/MailSharp.DNS/Records/RecordA.cs
new file mode 100644
index 0000000..a4231f4
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordA.cs
@@ -0,0 +1,38 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+using System.Net;
+/*
+ 3.4.1. A RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | ADDRESS |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+ADDRESS A 32 bit Internet address.
+
+Hosts that have multiple Internet addresses will have multiple A
+records.
+ *
+ */
+namespace MailSharp.DNS.Records;
+
+public record RecordA : DnsRecord
+{
+ public IPAddress Address { get; }
+
+ public RecordA(RecordReader rr) : base(rr)
+ {
+ Address = new IPAddress(rr.ReadBytes(4));
+ }
+
+ public RecordA(string value)
+ {
+ Address = IPAddress.Parse(value);
+ }
+
+ public override string ToString() => Address.ToString();
+
+}
diff --git a/MailSharp.DNS/Records/RecordA6.cs b/MailSharp.DNS/Records/RecordA6.cs
new file mode 100644
index 0000000..199db3b
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordA6.cs
@@ -0,0 +1,18 @@
+/*
+ A6 (OBSOLETE - use AAAA)
+ */
+
+using MailSharp.DNS.Records;
+using System.Net;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordA6(RecordReader rr) : DnsRecord(rr)
+{
+ public byte PrefixLength { get; init; } = rr.ReadByte();
+ public byte[] Address { get; init; } = rr.ReadBytes(16);
+ public string PrefixName { get; init; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{PrefixLength} {new IPAddress(Address)} {PrefixName}";
+
+}
diff --git a/MailSharp.DNS/Records/RecordAAAA.cs b/MailSharp.DNS/Records/RecordAAAA.cs
new file mode 100644
index 0000000..dff9120
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordAAAA.cs
@@ -0,0 +1,32 @@
+using MailSharp.DNS.Records;
+using System.Net;
+
+
+#region Rfc info
+/*
+2.2 AAAA data format
+
+ A 128 bit IPv6 address is encoded in the data portion of an AAAA
+ resource record in network byte order (high-order byte first).
+ */
+#endregion
+
+namespace MailSharp.DNS.Records;
+
+public record RecordAAAA : DnsRecord
+{
+ public IPAddress Address { get; }
+
+ public RecordAAAA(RecordReader rr) : base(rr)
+ {
+ Address = new IPAddress(rr.ReadBytes(16));
+ }
+
+ public RecordAAAA(string value)
+ {
+ Address = IPAddress.Parse(value);
+ }
+
+ public override string ToString() => Address.ToString();
+
+}
diff --git a/MailSharp.DNS/Records/RecordAFSDB.cs b/MailSharp.DNS/Records/RecordAFSDB.cs
new file mode 100644
index 0000000..3ec0c70
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordAFSDB.cs
@@ -0,0 +1,40 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+/* http://tools.ietf.org/rfc/rfc1183.txt
+
+ * 1. AFS Data Base location
+
+ This section defines an extension of the DNS to locate servers both
+ for AFS (AFS is a registered trademark of Transarc Corporation) and
+ for the Open Software Foundation's (OSF) Distributed Computing
+ Environment (DCE) authenticated naming system using HP/Apollo's NCA,
+ both to be components of the OSF DCE. The discussion assumes that
+ the reader is familiar with AFS [5] and NCA [6].
+
+ The AFS (originally the Andrew File System) system uses the DNS to
+ map from a domain name to the name of an AFS cell database server.
+ The DCE Naming service uses the DNS for a similar function: mapping
+ from the domain name of a cell to authenticated name servers for that
+ cell. The method uses a new RR type with mnemonic AFSDB and type
+ code of 18 (decimal).
+
+ AFSDB has the following format:
+
+ <owner> <ttl> <class> AFSDB <subtype> <hostname>
+
+ Both RDATA fields are required in all AFSDB RRs. The <subtype> field
+ is a 16 bit integer. The <hostname> field is a domain name of a host
+ that has a server for the cell named by the owner name of the RR.
+
+ */
+
+namespace MailSharp.DNS.Records;
+
+public record RecordAFSDB(RecordReader rr) : DnsRecord(rr)
+{
+ public ushort SubType { get; } = rr.ReadUInt16();
+ public string HostName { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{SubType} {HostName}";
+}
diff --git a/MailSharp.DNS/Records/RecordAPL.cs b/MailSharp.DNS/Records/RecordAPL.cs
new file mode 100644
index 0000000..b088fce
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordAPL.cs
@@ -0,0 +1,86 @@
+
+/*
+https://www.rfc-editor.org/rfc/rfc3123.html
+
+4. APL RDATA format
+
+ The RDATA section consists of zero or more items (<apitem>) of the
+ form
+
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ | ADDRESSFAMILY |
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ | PREFIX | N | AFDLENGTH |
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ / AFDPART /
+ | |
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+
+ ADDRESSFAMILY 16 bit unsigned value as assigned by IANA
+ (see IANA Considerations)
+ PREFIX 8 bit unsigned binary coded prefix length.
+ Upper and lower bounds and interpretation of
+ this value are address family specific.
+ N negation flag, indicates the presence of the
+ "!" character in the textual format. It has
+ the value "1" if the "!" was given, "0" else.
+ AFDLENGTH length in octets of the following address
+ family dependent part (7 bit unsigned).
+ AFDPART address family dependent part. See below.
+
+ AFDPARTs for address families 1 (IPv4) and 2 (IPv6) families.
+ */
+
+using MailSharp.DNS.Records;
+using System.Net;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordAPL : DnsRecord
+{
+ public class APLItem
+ {
+ public ushort AddressFamily { get; set; }
+ public byte Prefix { get; set; }
+ public bool NegationFlag { get; set; }
+ public IPAddress? IPAddress { get; set; }
+ public override string ToString()
+ {
+ string negation = NegationFlag ? "!" : "";
+ return $"{negation}{AddressFamily}:{IPAddress}/{Prefix}";
+ }
+ }
+ public List<APLItem> APLItems { get; set; } = [];
+
+ public RecordAPL(RecordReader rr) : base(rr)
+ {
+ // re-read length
+ ushort rdLength = rr.ReadUInt16(-2);
+ while (rr.Position < rdLength)
+ {
+ ushort addressFamily = rr.ReadUInt16();
+ byte prefix = rr.ReadByte();
+ byte afdLength = rr.ReadByte();
+ bool negationFlag = (afdLength & 0x80) == 0x80; // check negation flag
+ afdLength = (byte)(afdLength & 0x7F); // mask out negation flag
+
+ byte[] afdPart = rr.ReadBytes(afdLength);
+ IPAddress ipAddress = addressFamily switch
+ {
+ 1 => new IPAddress(afdPart), // IPv4
+ 2 => new IPAddress(afdPart), // IPv6
+ _ => throw new NotSupportedException($"Address family {addressFamily} is not supported.")
+ };
+ APLItems.Add(new APLItem
+ {
+ AddressFamily = addressFamily,
+ NegationFlag = negationFlag,
+ Prefix = prefix,
+ IPAddress = ipAddress
+ });
+ }
+ }
+
+ public override string ToString() => string.Join(" ", APLItems.Select(x => $"{x}"));
+
+}
diff --git a/MailSharp.DNS/Records/RecordATMA.cs b/MailSharp.DNS/Records/RecordATMA.cs
new file mode 100644
index 0000000..f0c2e3c
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordATMA.cs
@@ -0,0 +1,22 @@
+/*
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordATMA : DnsRecord
+{
+ public byte Format { get; init; } // 0 = E.164, 1 = NSAP
+ public string Address { get; init; } = string.Empty;
+
+ public RecordATMA(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ Format = rr.ReadByte();
+ Address = Convert.ToHexString(rr.ReadBytes(rdLength - 1));
+ }
+
+ public override string ToString() => $"{Format} {Address}";
+}
diff --git a/MailSharp.DNS/Records/RecordCAA.cs b/MailSharp.DNS/Records/RecordCAA.cs
new file mode 100644
index 0000000..c019be2
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordCAA.cs
@@ -0,0 +1,36 @@
+using MailSharp.DNS.Records;
+using System.Net;
+/*
+ * RFC8659
+ RDATA format
+
++0-1-2-3-4-5-6-7-|0-1-2-3-4-5-6-7-|
+| Flags | Tag Length = n |
++----------------|----------------+...+---------------+
+| Tag char 0 | Tag char 1 |...| Tag char n-1 |
++----------------|----------------+...+---------------+
++----------------|----------------+.....+----------------+
+| Value byte 0 | Value byte 1 |.....| Value byte m-1 |
++----------------|----------------+.....+----------------+
+
+ *
+ */
+namespace MailSharp.DNS.Records;
+
+public record RecordCAA : DnsRecord
+{
+ public byte Flags { get; }
+ public string Tag { get; }
+ public string Value { get; }
+
+ public RecordCAA(RecordReader rr) : base(rr)
+ {
+ ushort rdlength = rr.ReadUInt16(-2);
+ this.Flags = rr.ReadByte();
+ ushort tagLength = rr.ReadByte();
+ this.Tag = rr.ReadString(tagLength);
+ this.Value = rr.ReadString(rdlength - tagLength - 2);
+ }
+
+ public override string ToString() => $"{Flags} {Tag} {Value}";
+}
diff --git a/MailSharp.DNS/Records/RecordCERT.cs b/MailSharp.DNS/Records/RecordCERT.cs
new file mode 100644
index 0000000..53f2b44
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordCERT.cs
@@ -0,0 +1,50 @@
+/*
+https://www.rfc-editor.org/rfc/rfc4398.html
+
+The CERT Resource Record
+
+ The CERT resource record (RR) has the structure given below. Its RR
+ type code is 37.
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | type | key tag |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | algorithm | /
+ +---------------+ certificate or CRL /
+ / /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-|
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordCERT : DnsRecord
+{
+ public ushort CertType { get; init; } // 1=PKIX, 2=SPKI, 3=PGP, 4=IPKIX, etc.
+ public ushort KeyTag { get; init; }
+ public byte Algorithm { get; init; }
+ public string Certificate { get; init; } = string.Empty; // altijd Base64
+
+ public RecordCERT(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+
+ CertType = rr.ReadUInt16();
+ KeyTag = rr.ReadUInt16();
+ Algorithm = rr.ReadByte();
+
+ byte[] certData = rr.ReadBytes(rdLength - rr.Position);
+
+ Certificate = certData.Length == 0
+ ? "."
+ : Convert.ToBase64String(certData);
+ }
+
+ public override string ToString()
+ {
+ return $"{CertType} {KeyTag} {Algorithm} {Certificate}";
+ }
+}
diff --git a/MailSharp.DNS/Records/RecordCNAME.cs b/MailSharp.DNS/Records/RecordCNAME.cs
new file mode 100644
index 0000000..7335e61
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordCNAME.cs
@@ -0,0 +1,30 @@
+/*
+ *
+3.3.1. CNAME RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / CNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+CNAME A <domain-name> which specifies the canonical or primary
+ name for the owner. The owner name is an alias.
+
+CNAME RRs cause no additional section processing, but name servers may
+choose to restart the query at the canonical name in certain cases. See
+the description of name server logic in [RFC-1034] for details.
+
+ *
+ */
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordCNAME(RecordReader rr) : DnsRecord(rr)
+{
+ public string CName { get; } = rr.ReadDomainName();
+
+ public override string ToString() => CName;
+}
diff --git a/MailSharp.DNS/Records/RecordDHCID.cs b/MailSharp.DNS/Records/RecordDHCID.cs
new file mode 100644
index 0000000..3fb469f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordDHCID.cs
@@ -0,0 +1,37 @@
+/*
+https://www.rfc-editor.org/rfc/rfc4701.html
+The RDATA section of a DHCID RR in transmission contains RDLENGTH
+ octets of binary data. The format of this data and its
+ interpretation by DHCP servers and clients are described below.
+
+ DNS software should consider the RDATA section to be opaque. DHCP
+ clients or servers use the DHCID RR to associate a DHCP client's
+ identity with a DNS name, so that multiple DHCP clients and servers
+ may deterministically perform dynamic DNS updates to the same zone.
+ From the updater's perspective, the DHCID resource record RDATA
+ consists of a 2-octet identifier type, in network byte order,
+ followed by a 1-octet digest type, followed by one or more octets
+ representing the actual identifier:
+
+ < 2 octets > Identifier type code
+ < 1 octet > Digest type code
+ < n octets > Digest (length depends on digest type)
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordDHCID : DnsRecord
+{
+ public byte[] Data { get; init; } = [];
+
+ public RecordDHCID(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ Data = rr.ReadBytes(rdLength);
+ }
+
+ public override string ToString() =>
+ Convert.ToBase64String(Data);
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordDNAME.cs b/MailSharp.DNS/Records/RecordDNAME.cs
new file mode 100644
index 0000000..6a95f7f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordDNAME.cs
@@ -0,0 +1,26 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+/*
+ * http://tools.ietf.org/rfc/rfc2672.txt
+ *
+3. The DNAME Resource Record
+
+ The DNAME RR has mnemonic DNAME and type code 39 (decimal).
+ DNAME has the following format:
+
+ <owner> <ttl> <class> DNAME <target>
+
+ The format is not class-sensitive. All fields are required. The
+ RDATA field <target> is a <domain-name> [DNSIS].
+
+ *
+ */
+namespace MailSharp.DNS.Records;
+
+public record RecordDNAME(RecordReader rr) : DnsRecord(rr)
+{
+ public string Target { get; } = rr.ReadDomainName();
+
+ public override string ToString() => Target;
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordDNSKEY.cs b/MailSharp.DNS/Records/RecordDNSKEY.cs
new file mode 100644
index 0000000..537e717
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordDNSKEY.cs
@@ -0,0 +1,41 @@
+/*
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordDNSKEY : DnsRecord
+{
+ public ushort Flags { get; init; }
+ public byte Protocol { get; init; }
+ public byte Algorithm { get; init; }
+ public byte[] PublicKey{ get; init; }
+
+ public bool ZoneKey => (Flags & 0x0100) == 0x0100;
+ public bool SecureEntryPoint => (Flags & 0x0001) == 0x0001;
+
+ public RecordDNSKEY(RecordReader rr) : base(rr)
+ {
+ // re-read length
+ ushort rdLength= rr.ReadUInt16(-2);
+ Flags = rr.ReadUInt16();
+ Protocol = rr.ReadByte();
+ Algorithm = rr.ReadByte();
+ PublicKey = rr.ReadBytes(rdLength - 4);
+ }
+
+ public override string ToString()
+ {
+ // Flags altijd als decimaal getal (geen 256/257 truc meer sinds RFC 8624 sectie 3.1)
+ // Protocol altijd 3
+ // Public key altijd Base64 (geen spaties, geen newlines)
+ string base64 = PublicKey.Length == 0
+ ? "."
+ : Convert.ToBase64String(PublicKey);
+
+ return $"{Flags} {Protocol} {Algorithm} {base64}";
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordDS.cs b/MailSharp.DNS/Records/RecordDS.cs
new file mode 100644
index 0000000..4e79640
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordDS.cs
@@ -0,0 +1,60 @@
+using MailSharp.DNS.Records;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+/*
+ * http://tools.ietf.org/rfc/rfc3658.txt
+ *
+2.4. Wire Format of the DS record
+
+ The DS (type=43) record contains these fields: key tag, algorithm,
+ digest type, and the digest of a public key KEY record that is
+ allowed and/or used to sign the child's apex KEY RRset. Other keys
+ MAY sign the child's apex KEY RRset.
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | key tag | algorithm | Digest type |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | digest (length depends on type) |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | (SHA-1 digest is 20 bytes) |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-|
+ | |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-|
+ | |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+ */
+
+namespace MailSharp.DNS.Records;
+
+public sealed record RecordDS : DnsRecord
+{
+ public ushort KeyTag { get; init; }
+ public byte Algorithm { get; init; }
+ public byte DigestType { get; init; }
+ public byte[] Digest { get; init; } = [];
+
+ public RecordDS(RecordReader rr) : base(rr)
+ {
+ ushort length = rr.ReadUInt16(-2);
+
+ KeyTag = rr.ReadUInt16();
+ Algorithm = rr.ReadByte();
+ DigestType = rr.ReadByte();
+ Digest = rr.ReadBytes(length - 4);
+ }
+
+ public override string ToString()
+ {
+ // Digest wordt altijd in hex getoond (hoofdletters), zonder spaties
+ string digestHex = Digest.Length == 0
+ ? "."
+ : Convert.ToHexString(Digest);
+
+ return $"{KeyTag} {Algorithm} {DigestType} {digestHex}";
+ }
+}
diff --git a/MailSharp.DNS/Records/RecordEID.cs b/MailSharp.DNS/Records/RecordEID.cs
new file mode 100644
index 0000000..7a84962
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordEID.cs
@@ -0,0 +1,23 @@
+/*
+http://ana-3.lcs.mit.edu/~jnc/nimrod/dns.txt
+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordEID : DnsRecord
+{
+ public byte[] EndpointId { get; init; } = [];
+
+ public RecordEID(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ EndpointId = rr.ReadBytes(rdLength);
+ }
+
+ public override string ToString() =>
+ BitConverter.ToString(EndpointId).Replace("-", "").ToLowerInvariant();
+}
diff --git a/MailSharp.DNS/Records/RecordGID.cs b/MailSharp.DNS/Records/RecordGID.cs
new file mode 100644
index 0000000..7ab6509
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordGID.cs
@@ -0,0 +1,19 @@
+/*
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordGID : DnsRecord
+{
+ public uint Gid { get; init; }
+
+ public RecordGID(RecordReader rr) : base(rr)
+ {
+ Gid = rr.ReadUInt32();
+ }
+
+ public override string ToString() => Gid.ToString();
+}
diff --git a/MailSharp.DNS/Records/RecordGPOS.cs b/MailSharp.DNS/Records/RecordGPOS.cs
new file mode 100644
index 0000000..56b699f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordGPOS.cs
@@ -0,0 +1,65 @@
+/*
+ * http://tools.ietf.org/rfc/rfc1712.txt
+ *
+3. RDATA Format
+
+ MSB LSB
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / LONGITUDE /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / LATITUDE /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / ALTITUDE /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+ where:
+
+ LONGITUDE The real number describing the longitude encoded as a
+ printable string. The precision is limited by 256 charcters
+ within the range -90..90 degrees. Positive numbers
+ indicate locations north of the equator.
+
+ LATITUDE The real number describing the latitude encoded as a
+ printable string. The precision is limited by 256 charcters
+ within the range -180..180 degrees. Positive numbers
+ indicate locations east of the prime meridian.
+
+ ALTITUDE The real number describing the altitude (in meters) from
+ mean sea-level encoded as a printable string. The precision
+ is limited by 256 charcters. Positive numbers indicate
+ locations above mean sea-level.
+
+ Latitude/Longitude/Altitude values are encoded as strings as to avoid
+ the precision limitations imposed by encoding as unsigned integers.
+ Although this might not be considered optimal, it allows for a very
+ high degree of precision with an acceptable average encoded record
+ length.
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordGPOS : DnsRecord
+{
+ public string LONGITUDE;
+ public string LATITUDE;
+ public string ALTITUDE;
+
+ public RecordGPOS(RecordReader rr) : base(rr)
+ {
+ LONGITUDE = rr.ReadString();
+ LATITUDE = rr.ReadString();
+ ALTITUDE = rr.ReadString();
+ }
+
+ public override string ToString()
+ {
+ return string.Format("{0} {1} {2}",
+ LONGITUDE,
+ LATITUDE,
+ ALTITUDE);
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordHINFO.cs b/MailSharp.DNS/Records/RecordHINFO.cs
new file mode 100644
index 0000000..86c5c35
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordHINFO.cs
@@ -0,0 +1,34 @@
+/*
+ 3.3.2. HINFO RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / CPU /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / OS /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+CPU A <character-string> which specifies the CPU type.
+
+OS A <character-string> which specifies the operating
+ system type.
+
+Standard values for CPU and OS can be found in [RFC-1010].
+
+HINFO records are used to acquire general information about a host. The
+main use is for protocols such as FTP that can use special procedures
+when talking between machines or operating systems of the same type.
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordHINFO(RecordReader rr) : DnsRecord(rr)
+{
+ public string Cpu { get; } = rr.ReadString();
+ public string Os { get; } = rr.ReadString();
+
+ public override string ToString() => $"CPU={Cpu} OS={Os}";
+}
diff --git a/MailSharp.DNS/Records/RecordHIP.cs b/MailSharp.DNS/Records/RecordHIP.cs
new file mode 100644
index 0000000..4664ba2
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordHIP.cs
@@ -0,0 +1,71 @@
+/*
+https://www.rfc-editor.org/rfc/rfc8005.html
+HIP RR Storage Format
+
+ The RDATA for a HIP RR consists of a PK Algorithm Type, the HIT
+ length, a HIT, a PK (i.e., an HI), and optionally one or more RVSs.
+
+ 0 1 2 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | HIT length | PK algorithm | PK length |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | |
+ ~ HIT ~
+ | |
+ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | | |
+ +-+-+-+-+-+-+-+-+-+-+-+ +
+ | Public Key |
+ ~ ~
+ | |
+ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | | |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +
+ | |
+ ~ Rendezvous Servers ~
+ | |
+ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | |
+ +-+-+-+-+-+-+-+
+
+ The HIT length, PK algorithm, PK length, HIT, and Public Key fields
+ are REQUIRED. The Rendezvous Server field is OPTIONAL.
+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordHIP : DnsRecord
+{
+ public byte HitLength { get; init; }
+ public byte PkAlgorithm { get; init; }
+ public ushort PkLength { get; init; }
+ public byte[] HostIdentityTag { get; init; } = Array.Empty<byte>();
+ public byte[] PublicKey { get; init; } = Array.Empty<byte>();
+ public List<string> RendezvousServers { get; init; } = [];
+
+ public RecordHIP(RecordReader rr) : base(rr)
+ {
+ ushort rdlength = rr.ReadUInt16(-2);
+ HitLength = rr.ReadByte();
+ PkAlgorithm = rr.ReadByte();
+ PkLength = rr.ReadUInt16();
+ HostIdentityTag = rr.ReadBytes(HitLength);
+ PublicKey = rr.ReadBytes(PkLength);
+
+ while (rr.Position < rdlength)
+ RendezvousServers.Add(rr.ReadDomainName());
+ }
+
+ public override string ToString()
+ {
+ string hit = Convert.ToBase64String(HostIdentityTag);
+ string pk = Convert.ToBase64String(PublicKey);
+ string srv = RendezvousServers.Count == 0 ? "" : " " + string.Join(" ", RendezvousServers);
+ return $"{PkAlgorithm} {hit} {pk}{srv}";
+ }
+}
diff --git a/MailSharp.DNS/Records/RecordIPSECKEY.cs b/MailSharp.DNS/Records/RecordIPSECKEY.cs
new file mode 100644
index 0000000..48cbbec
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordIPSECKEY.cs
@@ -0,0 +1,87 @@
+
+/*
+https://www.rfc-editor.org/rfc/rfc4025.html
+
+ The RDATA for an IPSECKEY RR consists of a precedence value, a
+ gateway type, a public key, algorithm type, and an optional gateway
+ address.
+
+ 0 1 2 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | precedence | gateway type | algorithm | gateway |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-------------+ +
+ ~ gateway ~
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | /
+ / public key /
+ / /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-|
+ */
+
+using MailSharp.DNS.Records;
+using System.Net;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordIPSECKEY : DnsRecord
+{
+ public byte Precedence { get; init; }
+ public GatewayType GatewayType { get; init; }
+ public byte Algorithm { get; init; }
+ public IPAddress? GatewayAddress { get; init; }
+ public string? GatewayName { get; init; }
+
+ public byte[] PublicKey { get; init; }
+
+ public RecordIPSECKEY(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+
+ Precedence = rr.ReadByte();
+ GatewayType = (GatewayType)rr.ReadByte();
+ Algorithm = rr.ReadByte();
+
+ switch (GatewayType)
+ {
+ case GatewayType.NoGateway:
+ // 1 byte "."
+ if (rr.ReadByte() != 0) throw new FormatException("Expected root label for . gateway");
+ break;
+
+ case GatewayType.IPv4:
+ GatewayAddress = new IPAddress(rr.ReadBytes(4));
+ break;
+
+ case GatewayType.IPv6:
+ GatewayAddress = new IPAddress(rr.ReadBytes(16));
+ break;
+
+ case GatewayType.WireFormatDomainName:
+ GatewayName = rr.ReadDomainName();
+ break;
+ }
+
+ PublicKey = rr.ReadBytes(rdLength - rr.Position);
+ }
+
+ public override string ToString()
+ {
+ string gateway = GatewayType switch
+ {
+ GatewayType.NoGateway => ".",
+ GatewayType.IPv4 => GatewayAddress?.ToString() ?? "0.0.0.0",
+ GatewayType.IPv6 => GatewayAddress?.ToString() ?? "::",
+ GatewayType.WireFormatDomainName => GatewayName ?? ".",
+ _ => "?"
+ };
+
+ // Public key wordt standaard als Base64 getoond (zonder newlines)
+ string base64Key = PublicKey.Length == 0
+ ? "."
+ : Convert.ToBase64String(PublicKey);
+
+ return $"{Precedence} {(byte)GatewayType} {Algorithm} {gateway} {base64Key}";
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordISDN.cs b/MailSharp.DNS/Records/RecordISDN.cs
new file mode 100644
index 0000000..c467b64
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordISDN.cs
@@ -0,0 +1,79 @@
+/* http://tools.ietf.org/rfc/rfc1183.txt
+
+3.2. The ISDN RR
+
+ The ISDN RR is defined with mnemonic ISDN and type code 20 (decimal).
+
+ An ISDN (Integrated Service Digital Network) number is simply a
+ telephone number. The intent of the members of the CCITT is to
+ upgrade all telephone and data network service to a common service.
+
+ The numbering plan (E.163/E.164) is the same as the familiar
+ international plan for POTS (an un-official acronym, meaning Plain
+ Old Telephone Service). In E.166, CCITT says "An E.163/E.164
+ telephony subscriber may become an ISDN subscriber without a number
+ change."
+
+ ISDN has the following format:
+
+ <owner> <ttl> <class> ISDN <ISDN-address> <sa>
+
+ The <ISDN-address> field is required; <sa> is optional.
+
+ <ISDN-address> identifies the ISDN number of <owner> and DDI (Direct
+ Dial In) if any, as defined by E.164 [8] and E.163 [7], the ISDN and
+ PSTN (Public Switched Telephone Network) numbering plan. E.163
+ defines the country codes, and E.164 the form of the addresses. Its
+ format in master files is a <character-string> syntactically
+ identical to that used in TXT and HINFO.
+
+ <sa> specifies the subaddress (SA). The format of <sa> in master
+ files is a <character-string> syntactically identical to that used in
+ TXT and HINFO.
+
+ The format of ISDN is class insensitive. ISDN RRs cause no
+ additional section processing.
+
+ The <ISDN-address> is a string of characters, normally decimal
+ digits, beginning with the E.163 country code and ending with the DDI
+ if any. Note that ISDN, in Q.931, permits any IA5 character in the
+ general case.
+
+ The <sa> is a string of hexadecimal digits. For digits 0-9, the
+ concrete encoding in the Q.931 call setup information element is
+ identical to BCD.
+
+ For example:
+
+ Relay.Prime.COM. IN ISDN 150862028003217
+ sh.Prime.COM. IN ISDN 150862028003217 004
+
+ (Note: "1" is the country code for the North American Integrated
+ Numbering Area, i.e., the system of "area codes" familiar to people
+ in those countries.)
+
+ The RR data is the ASCII representation of the digits. It is encoded
+ as one or two <character-string>s, i.e., count followed by
+ characters.
+
+ CCITT recommendation E.166 [9] defines prefix escape codes for the
+ representation of ISDN (E.163/E.164) addresses in X.121, and PSDN
+ (X.121) addresses in E.164. It specifies that the exact codes are a
+ "national matter", i.e., different on different networks. A host
+ connected to the ISDN may be able to use both the X25 and ISDN
+ addresses, with the local prefix added.
+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordISDN(RecordReader rr) : DnsRecord(rr)
+{
+ public string IsdnAddress { get; } = rr.ReadString();
+ public string Sa { get; } = rr.ReadString();
+
+ public override string ToString() => $"{IsdnAddress} {Sa}";
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordKEY.cs b/MailSharp.DNS/Records/RecordKEY.cs
new file mode 100644
index 0000000..e2152f0
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordKEY.cs
@@ -0,0 +1,50 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+
+#region Rfc info
+/* http://www.ietf.org/rfc/rfc2535.txt
+ *
+3.1 KEY RDATA format
+
+ The RDATA for a KEY RR consists of flags, a protocol octet, the
+ algorithm number octet, and the public key itself. The format is as
+ follows:
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | flags | protocol | algorithm |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | /
+ / public key /
+ / /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-|
+
+ The KEY RR is not intended for storage of certificates and a separate
+ certificate RR has been developed for that purpose, defined in [RFC
+ 2538].
+
+ The meaning of the KEY RR owner name, flags, and protocol octet are
+ described in Sections 3.1.1 through 3.1.5 below. The flags and
+ algorithm must be examined before any data following the algorithm
+ octet as they control the existence and format of any following data.
+ The algorithm and public key fields are described in Section 3.2.
+ The format of the public key is algorithm dependent.
+
+ KEY RRs do not specify their validity period but their authenticating
+ SIG RR(s) do as described in Section 4 below.
+
+*/
+#endregion
+
+namespace MailSharp.DNS.Records;
+
+public record RecordKEY(RecordReader rr) : DnsRecord(rr)
+{
+ public ushort Flags { get; } = rr.ReadUInt16();
+ public byte Protocol { get; } = rr.ReadByte();
+ public byte Algorithm { get; } = rr.ReadByte();
+ public string PublicKey { get; } = rr.ReadString();
+
+ public override string ToString() => $"{Flags} {Protocol} {Algorithm} \"{PublicKey}\"";
+}
diff --git a/MailSharp.DNS/Records/RecordKX.cs b/MailSharp.DNS/Records/RecordKX.cs
new file mode 100644
index 0000000..d27e244
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordKX.cs
@@ -0,0 +1,46 @@
+/*
+ * http://tools.ietf.org/rfc/rfc2230.txt
+ *
+ * 3.1 KX RDATA format
+
+ The KX DNS record has the following RDATA format:
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | PREFERENCE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / EXCHANGER /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+ where:
+
+ PREFERENCE A 16 bit non-negative integer which specifies the
+ preference given to this RR among other KX records
+ at the same owner. Lower values are preferred.
+
+ EXCHANGER A <domain-name> which specifies a host willing to
+ act as a mail exchange for the owner name.
+
+ KX records MUST cause type A additional section processing for the
+ host specified by EXCHANGER. In the event that the host processing
+ the DNS transaction supports IPv6, KX records MUST also cause type
+ AAAA additional section processing.
+
+ The KX RDATA field MUST NOT be compressed.
+
+ */
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordKX(RecordReader rr) : DnsRecord(rr), IComparable<RecordKX>
+{
+ public ushort Preference { get; } = rr.ReadUInt16();
+ public string Exchange { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{Preference} {Exchange}";
+ public int CompareTo(RecordKX? other) =>
+ other is null ? 1 :
+ Preference != other.Preference ? Preference.CompareTo(other.Preference) :
+ string.Compare(Exchange, other.Exchange, StringComparison.OrdinalIgnoreCase);
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordLOC.cs b/MailSharp.DNS/Records/RecordLOC.cs
new file mode 100644
index 0000000..9e4329d
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordLOC.cs
@@ -0,0 +1,121 @@
+/*
+ * http://www.ietf.org/rfc/rfc1876.txt
+ *
+2. RDATA Format
+
+ MSB LSB
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 0| VERSION | SIZE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 2| HORIZ PRE | VERT PRE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 4| LATITUDE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 6| LATITUDE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 8| LONGITUDE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 10| LONGITUDE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 12| ALTITUDE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ 14| ALTITUDE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+VERSION Version number of the representation. This must be zero.
+ Implementations are required to check this field and make
+ no assumptions about the format of unrecognized versions.
+
+SIZE The diameter of a sphere enclosing the described entity, in
+ centimeters, expressed as a pair of four-bit unsigned
+ integers, each ranging from zero to nine, with the most
+ significant four bits representing the base and the second
+ number representing the power of ten by which to multiply
+ the base. This allows sizes from 0e0 (<1cm) to 9e9
+ (90,000km) to be expressed. This representation was chosen
+ such that the hexadecimal representation can be read by
+ eye; 0x15 = 1e5. Four-bit values greater than 9 are
+ undefined, as are values with a base of zero and a non-zero
+ exponent.
+
+ Since 20000000m (represented by the value 0x29) is greater
+ than the equatorial diameter of the WGS 84 ellipsoid
+ (12756274m), it is therefore suitable for use as a
+ "worldwide" size.
+
+HORIZ PRE The horizontal precision of the data, in centimeters,
+ expressed using the same representation as SIZE. This is
+ the diameter of the horizontal "circle of error", rather
+ than a "plus or minus" value. (This was chosen to match
+ the interpretation of SIZE; to get a "plus or minus" value,
+ divide by 2.)
+
+VERT PRE The vertical precision of the data, in centimeters,
+ expressed using the sane representation as for SIZE. This
+ is the total potential vertical error, rather than a "plus
+ or minus" value. (This was chosen to match the
+ interpretation of SIZE; to get a "plus or minus" value,
+ divide by 2.) Note that if altitude above or below sea
+ level is used as an approximation for altitude relative to
+ the [WGS 84] ellipsoid, the precision value should be
+ adjusted.
+
+LATITUDE The latitude of the center of the sphere described by the
+ SIZE field, expressed as a 32-bit integer, most significant
+ octet first (network standard byte order), in thousandths
+ of a second of arc. 2^31 represents the equator; numbers
+ above that are north latitude.
+
+LONGITUDE The longitude of the center of the sphere described by the
+ SIZE field, expressed as a 32-bit integer, most significant
+ octet first (network standard byte order), in thousandths
+ of a second of arc, rounded away from the prime meridian.
+ 2^31 represents the prime meridian; numbers above that are
+ east longitude.
+
+ALTITUDE The altitude of the center of the sphere described by the
+ SIZE field, expressed as a 32-bit integer, most significant
+ octet first (network standard byte order), in centimeters,
+ from a base of 100,000m below the [WGS 84] reference
+ spheroid used by GPS (semimajor axis a=6378137.0,
+ reciprocal flattening rf=298.257223563). Altitude above
+ (or below) sea level may be used as an approximation of
+ altitude relative to the the [WGS 84] spheroid, though due
+ to the Earth's surface not being a perfect spheroid, there
+ will be differences. (For example, the geoid (which sea
+ level approximates) for the continental US ranges from 10
+ meters to 50 meters below the [WGS 84] spheroid.
+ Adjustments to ALTITUDE and/or VERT PRE will be necessary
+ in most cases. The Defense Mapping Agency publishes geoid
+ height values relative to the [WGS 84] ellipsoid.
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordLOC(RecordReader rr) : DnsRecord(rr)
+{
+ public byte Version { get; } = rr.ReadByte();
+ public byte Size { get; } = rr.ReadByte();
+ public byte HorizPre { get; } = rr.ReadByte();
+ public byte VertPre { get; } = rr.ReadByte();
+ public uint Latitude { get; } = rr.ReadUInt32();
+ public uint Longitude { get; } = rr.ReadUInt32();
+ public uint Altitude { get; } = rr.ReadUInt32();
+
+ public override string ToString()
+ {
+ static string ToCoord(uint v, char neg, char pos) =>
+ v >= 2147483648u
+ ? $"{(v - 2147483648u) / 3600000.0:0.000} {neg}"
+ : $"{(2147483648u - v) / 3600000.0:0.000} {pos}";
+
+ static string ToSize(byte b) => b == 0 ? "0m" : $"{(b >> 4)}e{(b & 0x0F)}m";
+
+ return $"{ToCoord(Latitude, 'S', 'N')} {ToCoord(Longitude, 'W', 'E')} {(Altitude / 100.0 - 100000):F2}m {ToSize(Size)} {ToSize(HorizPre)} {ToSize(VertPre)}";
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordMB.cs b/MailSharp.DNS/Records/RecordMB.cs
new file mode 100644
index 0000000..dbde227
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordMB.cs
@@ -0,0 +1,25 @@
+/*
+3.3.3. MB RDATA format (EXPERIMENTAL)
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / MADNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+MADNAME A <domain-name> which specifies a host which has the
+ specified mailbox.
+
+MB records cause additional section processing which looks up an A type
+RRs corresponding to MADNAME.
+*/
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordMB(RecordReader rr) : DnsRecord(rr)
+{
+ public string MadName { get; } = rr.ReadDomainName();
+ public override string ToString() => MadName;
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordMD.cs b/MailSharp.DNS/Records/RecordMD.cs
new file mode 100644
index 0000000..3ed554f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordMD.cs
@@ -0,0 +1,33 @@
+/*
+3.3.4. MD RDATA format (Obsolete)
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / MADNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+MADNAME A <domain-name> which specifies a host which has a mail
+ agent for the domain which should be able to deliver
+ mail for the domain.
+
+MD records cause additional section processing which looks up an A type
+record corresponding to MADNAME.
+
+MD is obsolete. See the definition of MX and [RFC-974] for details of
+the new scheme. The recommended policy for dealing with MD RRs found in
+a master file is to reject them, or to convert them to MX RRs with a
+preference of 0.
+ * */
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordMD(RecordReader rr) : DnsRecord(rr)
+{
+ public string MADName = rr.ReadDomainName();
+
+ public override string ToString() => MADName;
+
+}
diff --git a/MailSharp.DNS/Records/RecordMF.cs b/MailSharp.DNS/Records/RecordMF.cs
new file mode 100644
index 0000000..a954a52
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordMF.cs
@@ -0,0 +1,33 @@
+/*
+ *
+3.3.5. MF RDATA format (Obsolete)
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / MADNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+MADNAME A <domain-name> which specifies a host which has a mail
+ agent for the domain which will accept mail for
+ forwarding to the domain.
+
+MF records cause additional section processing which looks up an A type
+record corresponding to MADNAME.
+
+MF is obsolete. See the definition of MX and [RFC-974] for details ofw
+the new scheme. The recommended policy for dealing with MD RRs found in
+a master file is to reject them, or to convert them to MX RRs with a
+preference of 10. */
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordMF(RecordReader rr) : DnsRecord(rr)
+{
+ public string MADName = rr.ReadDomainName();
+
+ public override string ToString() => MADName;
+
+}
diff --git a/MailSharp.DNS/Records/RecordMG.cs b/MailSharp.DNS/Records/RecordMG.cs
new file mode 100644
index 0000000..5ff8085
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordMG.cs
@@ -0,0 +1,24 @@
+/*
+3.3.6. MG RDATA format (EXPERIMENTAL)
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / MGMNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+MGMNAME A <domain-name> which specifies a mailbox which is a
+ member of the mail group specified by the domain name.
+
+MG records cause no additional section processing.
+*/
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordMG(RecordReader rr) : DnsRecord(rr)
+{
+ public string MgName { get; } = rr.ReadDomainName();
+ public override string ToString() => MgName;
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordMINFO.cs b/MailSharp.DNS/Records/RecordMINFO.cs
new file mode 100644
index 0000000..fc31866
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordMINFO.cs
@@ -0,0 +1,41 @@
+/*
+ 3.3.7. MINFO RDATA format (EXPERIMENTAL)
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / RMAILBX /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / EMAILBX /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+RMAILBX A <domain-name> which specifies a mailbox which is
+ responsible for the mailing list or mailbox. If this
+ domain name names the root, the owner of the MINFO RR is
+ responsible for itself. Note that many existing mailing
+ lists use a mailbox X-request for the RMAILBX field of
+ mailing list X, e.g., Msgroup-request for Msgroup. This
+ field provides a more general mechanism.
+
+
+EMAILBX A <domain-name> which specifies a mailbox which is to
+ receive error messages related to the mailing list or
+ mailbox specified by the owner of the MINFO RR (similar
+ to the ERRORS-TO: field which has been proposed). If
+ this domain name names the root, errors should be
+ returned to the sender of the message.
+
+MINFO records cause no additional section processing. Although these
+records can be associated with a simple mailbox, they are usually used
+with a mailing list.
+ */
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordMINFO(RecordReader rr) : DnsRecord(rr)
+{
+ public string RMBox { get; } = rr.ReadDomainName();
+ public string EMBox { get; } = rr.ReadDomainName();
+ public override string ToString() => $"{RMBox} {EMBox}";
+}
diff --git a/MailSharp.DNS/Records/RecordMR.cs b/MailSharp.DNS/Records/RecordMR.cs
new file mode 100644
index 0000000..f287b81
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordMR.cs
@@ -0,0 +1,26 @@
+/*
+3.3.8. MR RDATA format (EXPERIMENTAL)
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / NEWNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+NEWNAME A <domain-name> which specifies a mailbox which is the
+ proper rename of the specified mailbox.
+
+MR records cause no additional section processing. The main use for MR
+is as a forwarding entry for a user who has moved to a different
+mailbox.
+*/
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordMR(RecordReader rr) : DnsRecord(rr)
+{
+ public string NewName { get; } = rr.ReadDomainName();
+ public override string ToString() => NewName;
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordMX.cs b/MailSharp.DNS/Records/RecordMX.cs
new file mode 100644
index 0000000..923d08b
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordMX.cs
@@ -0,0 +1,48 @@
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+/*
+3.3.9. MX RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | PREFERENCE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / EXCHANGE /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+PREFERENCE A 16 bit integer which specifies the preference given to
+ this RR among others at the same owner. Lower values
+ are preferred.
+
+EXCHANGE A <domain-name> which specifies a host willing to act as
+ a mail exchange for the owner name.
+
+MX records cause type A additional section processing for the host
+specified by EXCHANGE. The use of MX RRs is explained in detail in
+[RFC-974].
+*/
+
+
+public record RecordMX(RecordReader rr) : DnsRecord(rr), IComparable<RecordMX>
+{
+ public ushort Preference { get; } = rr.ReadUInt16();
+ public string Exchange { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{Preference} {Exchange}";
+
+ public int CompareTo(RecordMX? other)
+ {
+ if (other is not RecordMX recordMX)
+ return -1;
+ else if (Preference > recordMX.Preference)
+ return 1;
+ else if (Preference < recordMX.Preference)
+ return -1;
+ else // they are the same, now compare case insensitive names
+ return string.Compare(Exchange, recordMX.Exchange, true);
+ }
+}
diff --git a/MailSharp.DNS/Records/RecordNAPTR.cs b/MailSharp.DNS/Records/RecordNAPTR.cs
new file mode 100644
index 0000000..59d6bd3
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNAPTR.cs
@@ -0,0 +1,57 @@
+/*
+ * http://www.faqs.org/rfcs/rfc2915.html
+ *
+ 8. DNS Packet Format
+
+ The packet format for the NAPTR record is:
+
+ 1 1 1 1 1 1
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | ORDER |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | PREFERENCE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / FLAGS /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / SERVICES /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / REGEXP /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / REPLACEMENT /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+ where:
+
+ FLAGS A <character-string> which contains various flags.
+
+ SERVICES A <character-string> which contains protocol and service
+ identifiers.
+
+ REGEXP A <character-string> which contains a regular expression.
+
+ REPLACEMENT A <domain-name> which specifies the new value in the
+ case where the regular expression is a simple replacement
+ operation.
+
+ <character-string> and <domain-name> as used here are defined in
+ RFC1035 [1].
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNAPTR(RecordReader rr) : DnsRecord(rr)
+{
+ public ushort Order { get; } = rr.ReadUInt16();
+ public ushort Preference { get; } = rr.ReadUInt16();
+ public string Flags { get; } = rr.ReadString();
+ public string Services { get; } = rr.ReadString();
+ public string Regexp { get; } = rr.ReadString();
+ public string Replacement { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{Order} {Preference} \"{Flags}\" \"{Services}\" \"{Regexp}\" {Replacement}";
+}
diff --git a/MailSharp.DNS/Records/RecordNIMLOC.cs b/MailSharp.DNS/Records/RecordNIMLOC.cs
new file mode 100644
index 0000000..a3b79be
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNIMLOC.cs
@@ -0,0 +1,21 @@
+/*
+
+ */
+
+using MailSharp.DNS.Records;
+using System.Text;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNIMLOC : DnsRecord
+{
+ public string Locator { get; init; } = string.Empty;
+
+ public RecordNIMLOC(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ Locator = rr.ReadString(rdLength);
+ }
+
+ public override string ToString() => Locator;
+}
diff --git a/MailSharp.DNS/Records/RecordNS.cs b/MailSharp.DNS/Records/RecordNS.cs
new file mode 100644
index 0000000..7d6f1ca
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNS.cs
@@ -0,0 +1,44 @@
+/*
+ 3.3.11. NS RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / NSDNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+NSDNAME A <domain-name> which specifies a host which should be
+ authoritative for the specified class and domain.
+
+NS records cause both the usual additional section processing to locate
+a type A record, and, when used in a referral, a special search of the
+zone in which they reside for glue information.
+
+The NS RR states that the named host should be expected to have a zone
+starting at owner name of the specified class. Note that the class may
+not indicate the protocol family which should be used to communicate
+with the host, although it is typically a strong hint. For example,
+hosts which are name servers for either Internet (IN) or Hesiod (HS)
+class information are normally queried using IN class protocols.
+ */
+using MailSharp.DNS.Records;
+using System.Net;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNS: DnsRecord
+{
+ public string NsName { get; set; }
+
+ public RecordNS(RecordReader rr) : base(rr)
+ {
+ NsName = rr.ReadDomainName();
+ }
+ public override string ToString() => NsName;
+
+ public RecordNS(string value)
+ {
+ NsName = value;
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordNSAP.cs b/MailSharp.DNS/Records/RecordNSAP.cs
new file mode 100644
index 0000000..4faec16
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNSAP.cs
@@ -0,0 +1,71 @@
+using MailSharp.DNS.Records;
+using System.Text;
+/*
+ * http://tools.ietf.org/rfc/rfc1348.txt
+ * http://tools.ietf.org/html/rfc1706
+ *
+ * |--------------|
+ | <-- IDP --> |
+ |--------------|-------------------------------------|
+ | AFI | IDI | <-- DSP --> |
+ |-----|--------|-------------------------------------|
+ | 47 | 0005 | DFI | AA |Rsvd | RD |Area | ID |Sel |
+ |-----|--------|-----|----|-----|----|-----|----|----|
+ octets | 1 | 2 | 1 | 3 | 2 | 2 | 2 | 6 | 1 |
+ |-----|--------|-----|----|-----|----|-----|----|----|
+
+ IDP Initial Domain Part
+ AFI Authority and Format Identifier
+ IDI Initial Domain Identifier
+ DSP Domain Specific Part
+ DFI DSP Format Identifier
+ AA Administrative Authority
+ Rsvd Reserved
+ RD Routing Domain Identifier
+ Area Area Identifier
+ ID System Identifier
+ SEL NSAP Selector
+
+ Figure 1: GOSIP Version 2 NSAP structure.
+
+
+ */
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNSAP : DnsRecord
+{
+ public ushort LENGTH;
+ public byte[] NSAPADDRESS;
+
+ public RecordNSAP(RecordReader rr): base(rr)
+ {
+ LENGTH = rr.ReadUInt16();
+ NSAPADDRESS = rr.ReadBytes(LENGTH);
+ }
+
+ public override string ToString()
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.AppendFormat("{0} ", LENGTH);
+ for (int intI = 0; intI < NSAPADDRESS.Length; intI++)
+ sb.AppendFormat("{0:X00}", NSAPADDRESS[intI]);
+ return sb.ToString();
+ }
+
+ public string ToGOSIPV2()
+ {
+ return string.Format("{0:X}.{1:X}.{2:X}.{3:X}.{4:X}.{5:X}.{6:X}{7:X}.{8:X}",
+ NSAPADDRESS[0], // AFI
+ NSAPADDRESS[1] << 8 | NSAPADDRESS[2], // IDI
+ NSAPADDRESS[3], // DFI
+ NSAPADDRESS[4] << 16 | NSAPADDRESS[5] << 8 | NSAPADDRESS[6], // AA
+ NSAPADDRESS[7] << 8 | NSAPADDRESS[8], // Rsvd
+ NSAPADDRESS[9] << 8 | NSAPADDRESS[10],// RD
+ NSAPADDRESS[11] << 8 | NSAPADDRESS[12],// Area
+ NSAPADDRESS[13] << 16 | NSAPADDRESS[14] << 8 | NSAPADDRESS[15], // ID-High
+ NSAPADDRESS[16] << 16 | NSAPADDRESS[17] << 8 | NSAPADDRESS[18], // ID-Low
+ NSAPADDRESS[19]);
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordNSAPPTR.cs b/MailSharp.DNS/Records/RecordNSAPPTR.cs
new file mode 100644
index 0000000..966a956
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNSAPPTR.cs
@@ -0,0 +1,54 @@
+/*
+ * http://tools.ietf.org/rfc/rfc1348.txt
+
+ * The NSAP-PTR RR
+
+ The NSAP-PTR RR is defined with mnemonic NSAP-PTR and a type code 23
+ (decimal).
+
+ Its function is analogous to the PTR record used for IP addresses [4,7].
+
+ NSAP-PTR has the following format:
+
+ <NSAP-suffix> <ttl> <class> NSAP-PTR <owner>
+
+ All fields are required.
+
+ <NSAP-suffix> enumerates the actual octet values assigned by the
+ assigning authority for the LOCAL network. Its format in master
+ files is a <character-string> syntactically identical to that used in
+ TXT and HINFO.
+
+ The format of NSAP-PTR is class insensitive. NSAP-PTR RR causes no
+ additional section processing.
+
+ For example:
+
+ In net ff08000574.nsap-in-addr.arpa:
+
+ 444433332222111199990123000000ff NSAP-PTR foo.bar.com.
+
+ Or in net 11110031f67293.nsap-in-addr.arpa:
+
+ 67894444333322220000 NSAP-PTR host.school.de.
+
+ The RR data is the ASCII representation of the digits. It is encoded
+ as a <character-string>.
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNSAPPTR : DnsRecord // RFC 1348, later RFC 2915 (ARCHIVED)
+{
+ public string NextDomain { get; init; } = string.Empty;
+
+ public RecordNSAPPTR(RecordReader rr) : base(rr)
+ {
+ NextDomain = rr.ReadDomainName();
+ }
+
+ public override string ToString() => NextDomain;
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordNSEC.cs b/MailSharp.DNS/Records/RecordNSEC.cs
new file mode 100644
index 0000000..8ef19b4
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNSEC.cs
@@ -0,0 +1,57 @@
+/*
+ * https://www.rfc-editor.org/rfc/rfc4034.html
+NSEC RDATA Wire Format
+
+ The RDATA of the NSEC RR is as shown below:
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ / Next Domain Name /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ / Type Bit Maps /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNSEC : DnsRecord
+{
+ public string NextDomainName { get;init; }
+ public List<DnsType> TypeBitMaps { get; init; } = [];
+
+ public RecordNSEC(RecordReader rr) : base(rr)
+ {
+ // re-read length
+ ushort rdLength = rr.ReadUInt16(-2);
+
+ NextDomainName = rr.ReadDomainName();
+
+ while (rr.Position < rdLength)
+ {
+ byte windowBlock = rr.ReadByte();// 0�255
+ byte bitmapLength = rr.ReadByte();// 1�32
+ for (int i = 0; i < bitmapLength * 8; i++)
+ {
+ if ((rr.ReadByte() & (0x80 >> (i % 8))) != 0)
+ {
+ TypeBitMaps.Add((DnsType)(windowBlock * 256 + i));
+ }
+ }
+ }
+ }
+
+ public override string ToString()
+ {
+ if (TypeBitMaps.Count == 0)
+ return $"{NextDomainName}";
+
+ // Sorteer op type-nummer (verplicht in presentatieformaat)
+ var sorted = TypeBitMaps.OrderBy(t => (ushort)t);
+
+ return $"{NextDomainName} {string.Join(" ", sorted)}";
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordNSEC3.cs b/MailSharp.DNS/Records/RecordNSEC3.cs
new file mode 100644
index 0000000..6618083
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNSEC3.cs
@@ -0,0 +1,87 @@
+/*
+https://www.rfc-editor.org/rfc/rfc5155.html
+
+NSEC3 RDATA Wire Format
+
+ The RDATA of the NSEC3 RR is as shown below:
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Hash Alg. | Flags | Iterations |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Salt Length | Salt /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Hash Length | Next Hashed Owner Name /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ / Type Bit Maps /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+ Hash Algorithm is a single octet.
+
+ Flags field is a single octet, the Opt-Out flag is the least
+ significant bit, as shown below:
+
+ 0 1 2 3 4 5 6 7
+ +-+-+-+-+-+-+-+-+
+ | |O|
+ +-+-+-+-+-+-+-+-+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNSEC3 : DnsRecord
+{
+ public byte HashAlgorithm { get; init; }
+ public byte Flags { get; init; }
+ public ushort Iterations { get; init; }
+ public byte[] Salt { get; init; } = [];
+ public string NextHashedOwnerName { get; init; } = string.Empty;
+ public List<DnsType> TypeBitMaps { get; init; } = [];
+
+ public bool OptOut => (Flags & 0x01) == 0x01;
+
+ public RecordNSEC3(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+
+ HashAlgorithm = rr.ReadByte();
+ Flags = rr.ReadByte();
+ Iterations = rr.ReadUInt16();
+
+ byte saltLength = rr.ReadByte();
+ Salt = saltLength == 0 ? Array.Empty<byte>() : rr.ReadBytes(saltLength);
+
+ byte hashLength = rr.ReadByte();
+ byte[] hash = rr.ReadBytes(hashLength);
+ NextHashedOwnerName = Convert.ToBase64String(hash);
+
+ while (rr.Position < rdLength)
+ {
+ byte window = rr.ReadByte();
+ byte len = rr.ReadByte();
+
+ for (int i = 0; i < len * 8; i++)
+ {
+ if ((rr.PeekPosition(i / 8) & (0x80 >> (i % 8))) != 0)
+ TypeBitMaps.Add((DnsType)(window * 256 + i));
+ }
+ rr.Position += len;
+ }
+ }
+
+ public override string ToString()
+ {
+ string saltStr = Salt.Length == 0 ? "-" : Convert.ToHexString(Salt);
+ string optOut = OptOut ? " [Opt-Out]" : "";
+
+ var sortedTypes = TypeBitMaps.Count == 0
+ ? ""
+ : " " + string.Join(" ", TypeBitMaps.OrderBy(t => (ushort)t));
+
+ return $"{HashAlgorithm} {Flags} {Iterations} {saltStr} {NextHashedOwnerName}{sortedTypes}{optOut}";
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordNSEC3PARAM.cs b/MailSharp.DNS/Records/RecordNSEC3PARAM.cs
new file mode 100644
index 0000000..a3a6593
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNSEC3PARAM.cs
@@ -0,0 +1,48 @@
+/*
+ * https://www.rfc-editor.org/rfc/rfc5155.html
+ *
+ * NSEC3PARAM RDATA Wire Format
+
+ The RDATA of the NSEC3PARAM RR is as shown below:
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Hash Alg. | Flags | Iterations |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Salt Length | Salt /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNSEC3PARAM : DnsRecord
+{
+ public byte HashAlgorithm { get; init; }
+ public byte Flags { get; init; }
+ public ushort Iterations { get; init; }
+ public byte[] Salt { get; init; } = [];
+
+ public RecordNSEC3PARAM(RecordReader rr) : base(rr)
+ {
+ HashAlgorithm = rr.ReadByte();
+ Flags = rr.ReadByte();
+ Iterations = rr.ReadUInt16();
+
+ byte saltLength = rr.ReadByte();
+ Salt = saltLength == 0 ? [] : rr.ReadBytes(saltLength);
+ }
+
+ public override string ToString()
+ {
+ string saltStr = Salt.Length == 0 ? "-" : Convert.ToHexString(Salt);
+
+ // Flags wordt altijd als decimaal getal getoond (meestal 0 of 1)
+ return $"{HashAlgorithm} {Flags} {Iterations} {saltStr}";
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordNULL.cs b/MailSharp.DNS/Records/RecordNULL.cs
new file mode 100644
index 0000000..36c4083
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNULL.cs
@@ -0,0 +1,30 @@
+/*
+3.3.10. NULL RDATA format (EXPERIMENTAL)
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / <anything> /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+Anything at all may be in the RDATA field so long as it is 65535 octets
+or less.
+
+NULL records cause no additional section processing. NULL RRs are not
+allowed in master files. NULLs are used as placeholders in some
+experimental extensions of the DNS.
+*/
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordNULL : DnsRecord
+{
+ public byte[] Data { get; } = [];
+ public RecordNULL(RecordReader rr) : base(rr)
+ {
+ ushort rdlength = rr.ReadUInt16();
+ Data = rr.ReadBytes(rdlength);
+ }
+ public override string ToString() => $"...binary... ({Data.Length} bytes)";
+}
+
diff --git a/MailSharp.DNS/Records/RecordNXT.cs b/MailSharp.DNS/Records/RecordNXT.cs
new file mode 100644
index 0000000..e0f4efa
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordNXT.cs
@@ -0,0 +1,76 @@
+using MailSharp.DNS.Records;
+using System.Text;
+/*
+ * http://tools.ietf.org/rfc/rfc2065.txt
+ *
+5.2 NXT RDATA Format
+
+ The RDATA for an NXT RR consists simply of a domain name followed by
+ a bit map.
+
+ The type number for the NXT RR is 30.
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | next domain name /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | type bit map /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+ The NXT RR type bit map is one bit per RR type present for the owner
+ name similar to the WKS socket bit map. The first bit represents RR
+ type zero (an illegal type which should not be present.) A one bit
+ indicates that at least one RR of that type is present for the owner
+ name. A zero indicates that no such RR is present. All bits not
+ specified because they are beyond the end of the bit map are assumed
+ to be zero. Note that bit 30, for NXT, will always be on so the
+ minimum bit map length is actually four octets. The NXT bit map
+ should be printed as a list of RR type mnemonics or decimal numbers
+ similar to the WKS RR.
+
+ The domain name may be compressed with standard DNS name compression
+ when being transmitted over the network. The size of the bit map can
+ be inferred from the RDLENGTH and the length of the next domain name.
+
+
+
+ */
+namespace MailSharp.DNS.Records;
+
+public record RecordNXT : DnsRecord // Obsolete (RFC 2535 → vervangen door NSEC in RFC 4034)
+{
+ public string NextDomainName { get; init; } = string.Empty;
+ public List<DnsType> TypeBitMaps { get; init; } = [];
+
+ public RecordNXT(RecordReader rr) : base(rr)
+ {
+ NextDomainName = rr.ReadDomainName();
+
+ List<DnsType> types = new();
+ ushort rdLength = rr.ReadUInt16(-2);
+ int start = rr.Position;
+
+ while (rr.Position < start + (rdLength - (rr.Position - start)))
+ {
+ byte window = rr.ReadByte();
+ byte len = rr.ReadByte();
+
+ for (int i = 0; i < len * 8; i++)
+ {
+ if ((rr.PeekPosition(i / 8) & (0x80 >> (i % 8))) != 0)
+ TypeBitMaps.Add((DnsType)(window * 256 + i));
+ }
+ rr.Position += len;
+ }
+
+ }
+
+ public override string ToString()
+ {
+ var sorted = TypeBitMaps.OrderBy(t => (ushort)t);
+ return $"{NextDomainName} {string.Join(" ", sorted)}";
+ }
+
+
+}
diff --git a/MailSharp.DNS/Records/RecordOPT.cs b/MailSharp.DNS/Records/RecordOPT.cs
new file mode 100644
index 0000000..43af93e
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordOPT.cs
@@ -0,0 +1,142 @@
+/*
+https://www.rfc-editor.org/rfc/rfc3225.html
+
+The variable part of an OPT RR may contain zero or more options in
+ the RDATA. Each option MUST be treated as a bit field. Each option
+ is encoded as:
+
+ +0 (MSB) +1 (LSB)
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ 0: | OPTION-CODE |
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ 2: | OPTION-LENGTH |
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ 4: | |
+ / OPTION-DATA /
+ / /
+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+
+ OPTION-CODE
+ Assigned by the Expert Review process as defined by the DNSEXT
+ working group and the IESG.
+
+ OPTION-LENGTH
+ Size (in octets) of OPTION-DATA.
+
+ OPTION-DATA
+ Varies per OPTION-CODE. MUST be treated as a bit field.
+ */
+
+using MailSharp.DNS.Records;
+using System.Buffers.Binary;
+using System.Net;
+using System.Text;
+
+namespace MailSharp.DNS.Records;
+
+
+public record RecordOPT : DnsRecord
+{
+ //public ushort UdpPayloadSize { get; init; }
+ //public byte ExtendedRcode { get; init; }
+ //public byte Version { get; init; } // moet 0 zijn
+ //public ushort Flags { get; init; } // bit 0 = DO (DNSSEC OK)
+ public List<EdnsOption> Options { get; init; } = new();
+
+ //public bool DNSSEC_OK => (Flags & 0x8000) != 0;
+
+ public RecordOPT(RecordReader rr) : base(rr)
+ {
+ // OPT is een pseudo-record: owner = root, type = 41, class = UDP size, TTL = speciaal
+ //UdpPayloadSize = rr.Class; // "class" veld is hier UDP payload size
+ //ExtendedRcode = (byte)(rr.Ttl >> 24);
+ //Version = (byte)(rr.Ttl >> 16);
+ //Flags = (ushort)(rr.Ttl & 0xFFFF);
+
+ ushort rdLength = rr.ReadUInt16(-2); // RDLEN
+
+ while (rr.Position < rdLength)
+ {
+ ushort code = rr.ReadUInt16();
+ ushort len = rr.ReadUInt16();
+ byte[] data = rr.ReadBytes(len);
+ Options.Add(new EdnsOption(code, data));
+ }
+ }
+
+ public override string ToString()
+ {
+ //var parts = new List<string>
+ //{
+ // $"; EDNS: version: {Version}, flags:{(DNSSEC_OK ? " do" : "")}; udp: {UdpPayloadSize}"
+ //};
+
+ //if (Options.Count > 0)
+ // parts.Add("; " + string.Join("; ", Options));
+
+ //if (ExtendedRcode != 0)
+ // parts.Insert(0, $"; EDE: {ExtendedRcode}");
+
+ return "Incomplete";// string.Join(Environment.NewLine, parts);
+ }
+
+
+
+
+ public record EdnsOption(ushort Code, byte[] Data)
+ {
+ public override string ToString()
+ {
+ return Code switch
+ {
+ // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-11
+ 1 => "LLQ", // Legacy
+ 2 => "UL", // Legacy
+ 3 => $"NSID {ToHexOrAscii(Data)}", // NSID
+ 5 => $"DAU {FormatByteList(Data)}", // DNSSEC Algorithm Understood
+ 6 => $"DHU {FormatByteList(Data)}", // DS Hash Understood
+ 7 => $"N3U {FormatByteList(Data)}", // NSEC3 Hash Understood
+ 8 => $"CLIENT-SUBNET {FormatClientSubnet(Data)}", // EDNS Client Subnet (RFC 7871)
+ 9 => $"EXPIRE {BinaryPrimitives.ReadUInt32BigEndian(Data)}",
+ 10 => $"COOKIE {Convert.ToHexString(Data)}",
+ 11 => "TCP-KEEPALIVE", // RFC 7828
+ 12 => $"PADDING {Data.Length} bytes",
+ 13 => "CHAIN", // RFC 7901
+ 14 => $"KEY-TAG {string.Join(" ", Data.Select(b => b.ToString()))}",
+ 15 => "DEVICEID", // Draft
+ 16 => "EXTENDED-ERROR", // RFC 8914
+ 26944 => "PROXY", // Custom, vaak gebruikt
+ _ => $"OPTION-{Code} {Convert.ToHexString(Data)}"
+ };
+ }
+
+ private static string ToHexOrAscii(byte[] data)
+ {
+ if (data.All(b => b >= 32 && b <= 126))
+ return $"\"{Encoding.ASCII.GetString(data)}\"";
+ return Convert.ToHexString(data);
+ }
+
+ private static string FormatByteList(byte[] data) =>
+ string.Join(" ", data);
+
+ private static string FormatClientSubnet(byte[] data)
+ {
+ if (data.Length < 4) return Convert.ToHexString(data);
+
+ ushort family = BinaryPrimitives.ReadUInt16BigEndian(data);
+ byte sourcePrefix = data[2];
+ byte scopePrefix = data[3];
+ var addrBytes = data.AsSpan(4);
+
+ string address = family switch
+ {
+ 1 => new IPAddress(addrBytes.Slice(0, 4)).ToString(),
+ 2 => new IPAddress(addrBytes.Slice(0, 16)).ToString(),
+ _ => Convert.ToHexString(addrBytes.ToArray())
+ };
+
+ return $"{family} {address}/{sourcePrefix}/{scopePrefix}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordPTR.cs b/MailSharp.DNS/Records/RecordPTR.cs
new file mode 100644
index 0000000..56c9dd5
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordPTR.cs
@@ -0,0 +1,28 @@
+/*
+ 3.3.12. PTR RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / PTRDNAME /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+PTRDNAME A <domain-name> which points to some location in the
+ domain name space.
+
+PTR records cause no additional section processing. These RRs are used
+in special domains to point to some other location in the domain space.
+These records are simple data, and don't imply any special processing
+similar to that performed by CNAME, which identifies aliases. See the
+description of the IN-ADDR.ARPA domain for an example.
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordPTR(RecordReader rr) : DnsRecord(rr)
+{
+ public string PtrName { get; } = rr.ReadDomainName();
+ public override string ToString() => PtrName;
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordPX.cs b/MailSharp.DNS/Records/RecordPX.cs
new file mode 100644
index 0000000..e8dd96f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordPX.cs
@@ -0,0 +1,83 @@
+/*
+ * http://tools.ietf.org/rfc/rfc2163.txt
+ *
+4. The new DNS resource record for MIXER mapping rules: PX
+
+ The specification of the Internet DNS (RFC1035) provides a number of
+ specific resource records (RRs) to contain specific pieces of
+ information. In particular they contain the Mail eXchanger (MX) RR
+ and the host Address (A) records which are used by the Internet SMTP
+ mailers. As we will store the RFC822 to X.400 mapping information in
+ the already existing DNS name tree, we need to define a new DNS RR in
+ order to avoid any possible clash or misuse of already existing data
+ structures. The same new RR will also be used to store the mappings
+ from X.400 to RFC822. More over the mapping information, i.e., the
+ MCGAMs, has a specific format and syntax which require an appropriate
+ data structure and processing. A further advantage of defining a new
+ RR is the ability to include flexibility for some eventual future
+ development.
+
+ The definition of the new 'PX' DNS resource record is:
+
+ class: IN (Internet)
+
+ name: PX (pointer to X.400/RFC822 mapping information)
+
+ value: 26
+
+ The PX RDATA format is:
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | PREFERENCE |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / MAP822 /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / MAPX400 /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+ where:
+
+ PREFERENCE A 16 bit integer which specifies the preference given to
+ this RR among others at the same owner. Lower values
+ are preferred;
+
+ MAP822 A <domain-name> element containing <rfc822-domain>, the
+ RFC822 part of the MCGAM;
+
+ MAPX400 A <domain-name> element containing the value of
+ <x400-in-domain-syntax> derived from the X.400 part of
+ the MCGAM (see sect. 4.2);
+
+ PX records cause no additional section processing. The PX RR format
+ is the usual one:
+
+ <name> [<class>] [<TTL>] <type> <RDATA>
+
+ When we store in DNS a 'table1' or a 'gate1' entry, then <name> will
+ be an X.400 mail domain name in DNS syntax (see sect. 4.2). When we
+ store a 'table2' or a 'gate2' table entry, <name> will be an RFC822
+ mail domain name, including both fully qualified DNS domains and mail
+ only domains (MX-only domains). All normal DNS conventions, like
+ default values, wildcards, abbreviations and message compression,
+ apply also for all the components of the PX RR. In particular <name>,
+ MAP822 and MAPX400, as <domain-name> elements, must have the final
+ "." (root) when they are fully qualified.
+
+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordPX(RecordReader rr) : DnsRecord(rr)
+{
+ public ushort Preference { get; } = rr.ReadUInt16();
+ public string Map822 { get; } = rr.ReadDomainName();
+ public string MapX400 { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{Preference} {Map822} {MapX400}";
+}
diff --git a/MailSharp.DNS/Records/RecordRP.cs b/MailSharp.DNS/Records/RecordRP.cs
new file mode 100644
index 0000000..e150946
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordRP.cs
@@ -0,0 +1,41 @@
+/* http://tools.ietf.org/rfc/rfc1183.txt
+
+2.2. The Responsible Person RR
+
+ The method uses a new RR type with mnemonic RP and type code of 17
+ (decimal).
+
+ RP has the following format:
+
+ <owner> <ttl> <class> RP <mbox-dname> <txt-dname>
+
+ Both RDATA fields are required in all RP RRs.
+
+ The first field, <mbox-dname>, is a domain name that specifies the
+ mailbox for the responsible person. Its format in master files uses
+ the DNS convention for mailbox encoding, identical to that used for
+ the RNAME mailbox field in the SOA RR. The root domain name (just
+ ".") may be specified for <mbox-dname> to indicate that no mailbox is
+ available.
+
+ The second field, <txt-dname>, is a domain name for which TXT RR's
+ exist. A subsequent query can be performed to retrieve the
+ associated TXT resource records at <txt-dname>. This provides a
+ level of indirection so that the entity can be referred to from
+ multiple places in the DNS. The root domain name (just ".") may be
+ specified for <txt-dname> to indicate that the TXT_DNAME is absent,
+ and no associated TXT RR exists.
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordRP(RecordReader rr) : DnsRecord(rr)
+{
+ public string MBox { get; } = rr.ReadDomainName();
+ public string Txt { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{MBox} {Txt}";
+}
diff --git a/MailSharp.DNS/Records/RecordRRSIG.cs b/MailSharp.DNS/Records/RecordRRSIG.cs
new file mode 100644
index 0000000..380fdef
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordRRSIG.cs
@@ -0,0 +1,78 @@
+/*
+https://www.rfc-editor.org/rfc/rfc4034.html
+
+RRSIG RDATA Wire Format
+
+ The RDATA for an RRSIG RR consists of a 2 octet Type Covered field, a
+ 1 octet Algorithm field, a 1 octet Labels field, a 4 octet Original
+ TTL field, a 4 octet Signature Expiration field, a 4 octet Signature
+ Inception field, a 2 octet Key tag, the Signer's Name field, and the
+ Signature field.
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Type Covered | Algorithm | Labels |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Original TTL |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Signature Expiration |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Signature Inception |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | Key Tag | /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Signer's Name /
+ / /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ / /
+ / Signature /
+ / /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordRRSIG : DnsRecord
+{
+ public DnsType TypeCovered { get; init; }
+ public byte Algorithm { get; init; }
+ public byte Labels { get; init; }
+ public uint OriginalTtl { get; init; }
+ public DateTime Expiration { get; init; } // UTC
+ public DateTime Inception { get; init; } // UTC
+ public ushort KeyTag { get; init; }
+ public string SignerName { get; init; } = string.Empty;
+ public byte[] Signature { get; init; } = [];
+
+ public RecordRRSIG(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+
+ TypeCovered = (DnsType)rr.ReadUInt16();
+ Algorithm = rr.ReadByte();
+ Labels = rr.ReadByte();
+ OriginalTtl = rr.ReadUInt32();
+ Expiration = DateTimeOffset.FromUnixTimeSeconds(rr.ReadUInt32()).UtcDateTime;
+ Inception = DateTimeOffset.FromUnixTimeSeconds(rr.ReadUInt32()).UtcDateTime;
+ KeyTag = rr.ReadUInt16();
+ SignerName = rr.ReadDomainName();
+
+ Signature = rr.ReadBytes(rdLength - rr.Position);
+ }
+
+ public override string ToString()
+ {
+ // Tijdstempels in YYYYMMDDHHmmSS formaat (UTC), precies zoals BIND/dig
+ string exp = Expiration.ToString("yyyyMMddHHmmss");
+ string inc = Inception.ToString("yyyyMMddHHmmss");
+
+ string sigBase64 = Signature.Length == 0
+ ? "."
+ : Convert.ToBase64String(Signature);
+
+ return $"{TypeCovered} {Algorithm} {Labels} {OriginalTtl} {exp} {inc} {KeyTag} {SignerName} {sigBase64}";
+ }
+
+}
diff --git a/MailSharp.DNS/Records/RecordRT.cs b/MailSharp.DNS/Records/RecordRT.cs
new file mode 100644
index 0000000..5a391d9
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordRT.cs
@@ -0,0 +1,64 @@
+/* http://tools.ietf.org/rfc/rfc1183.txt
+
+3.3. The Route Through RR
+
+ The Route Through RR is defined with mnemonic RT and type code 21
+ (decimal).
+
+ The RT resource record provides a route-through binding for hosts
+ that do not have their own direct wide area network addresses. It is
+ used in much the same way as the MX RR.
+
+ RT has the following format:
+
+ <owner> <ttl> <class> RT <preference> <intermediate-host>
+
+ Both RDATA fields are required in all RT RRs.
+
+ The first field, <preference>, is a 16 bit integer, representing the
+ preference of the route. Smaller numbers indicate more preferred
+ routes.
+
+ <intermediate-host> is the domain name of a host which will serve as
+ an intermediate in reaching the host specified by <owner>. The DNS
+ RRs associated with <intermediate-host> are expected to include at
+ least one A, X25, or ISDN record.
+
+ The format of the RT RR is class insensitive. RT records cause type
+ X25, ISDN, and A additional section processing for <intermediate-
+ host>.
+
+ For example,
+
+ sh.prime.com. IN RT 2 Relay.Prime.COM.
+ IN RT 10 NET.Prime.COM.
+ *.prime.com. IN RT 90 Relay.Prime.COM.
+
+ When a host is looking up DNS records to attempt to route a datagram,
+ it first looks for RT records for the destination host, which point
+ to hosts with address records (A, X25, ISDN) compatible with the wide
+ area networks available to the host. If it is itself in the set of
+ RT records, it discards any RTs with preferences higher or equal to
+ its own. If there are no (remaining) RTs, it can then use address
+ records of the destination itself.
+
+ Wild-card RTs are used exactly as are wild-card MXs. RT's do not
+ "chain"; that is, it is not valid to use the RT RRs found for a host
+ referred to by an RT.
+
+ The concrete encoding is identical to the MX RR.
+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordRT(RecordReader rr) : DnsRecord(rr)
+{
+ public ushort Preference { get; } = rr.ReadUInt16();
+ public string Intermediate { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{Preference} {Intermediate}";
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordSIG.cs b/MailSharp.DNS/Records/RecordSIG.cs
new file mode 100644
index 0000000..cb42838
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordSIG.cs
@@ -0,0 +1,51 @@
+#region Rfc info
+/*
+ * http://www.ietf.org/rfc/rfc2535.txt
+ * 4.1 SIG RDATA Format
+
+ The RDATA portion of a SIG RR is as shown below. The integrity of
+ the RDATA information is protected by the signature field.
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | type covered | algorithm | labels |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | original TTL |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | signature expiration |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | signature inception |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | key tag | |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ signer's name +
+ | /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-/
+ / /
+ / signature /
+ / /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+
+*/
+#endregion
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordSIG(RecordReader rr) : DnsRecord(rr)
+{
+ public ushort TypeCovered { get; } = rr.ReadUInt16();
+ public byte Algorithm { get; } = rr.ReadByte();
+ public byte Labels { get; } = rr.ReadByte();
+ public uint OriginalTtl { get; } = rr.ReadUInt32();
+ public uint SigExpiration { get; } = rr.ReadUInt32();
+ public uint SigInception { get; } = rr.ReadUInt32();
+ public ushort KeyTag { get; } = rr.ReadUInt16();
+ public string SignerName { get; } = rr.ReadDomainName();
+ public string Signature { get; } = rr.ReadString();
+
+ public override string ToString() =>
+ $"{TypeCovered} {Algorithm} {Labels} {OriginalTtl} {SigExpiration} {SigInception} {KeyTag} {SignerName} \"{Signature}\"";
+}
diff --git a/MailSharp.DNS/Records/RecordSINK.cs b/MailSharp.DNS/Records/RecordSINK.cs
new file mode 100644
index 0000000..97dc6cc
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordSINK.cs
@@ -0,0 +1,39 @@
+/*
+https://datatracker.ietf.org/doc/draft-eastlake-kitchen-sink/02/
+https://www.ietf.org/archive/id/draft-eastlake-kitchen-sink-02.txt
+
+The RDATA portion of the SINK RR is structured as follows:
+
+ 1 1 1 1 1 1
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | coding | subcoding |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | /
+ / data /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordSINK : DnsRecord
+{
+ public byte Coding { get; init; }
+ public byte SubCoding { get; init; }
+ public byte[] Data { get; init; } = [];
+
+ public RecordSINK(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ Coding = rr.ReadByte();
+ SubCoding = rr.ReadByte();
+ Data = rr.ReadBytes(rdLength - 2);
+ }
+
+ public override string ToString() =>
+ $"{Coding} {SubCoding} {Convert.ToBase64String(Data)}";
+}
diff --git a/MailSharp.DNS/Records/RecordSOA.cs b/MailSharp.DNS/Records/RecordSOA.cs
new file mode 100644
index 0000000..0b4e3f8
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordSOA.cs
@@ -0,0 +1,85 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+
+/*
+3.3.13. SOA RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / MNAME /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / RNAME /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | SERIAL |
+ | |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | REFRESH |
+ | |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | RETRY |
+ | |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | EXPIRE |
+ | |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | MINIMUM |
+ | |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+MNAME The <domain-name> of the name server that was the
+ original or primary source of data for this zone.
+
+RNAME A <domain-name> which specifies the mailbox of the
+ person responsible for this zone.
+
+SERIAL The unsigned 32 bit version number of the original copy
+ of the zone. Zone transfers preserve this value. This
+ value wraps and should be compared using sequence space
+ arithmetic.
+
+REFRESH A 32 bit time interval before the zone should be
+ refreshed.
+
+RETRY A 32 bit time interval that should elapse before a
+ failed refresh should be retried.
+
+EXPIRE A 32 bit time value that specifies the upper limit on
+ the time interval that can elapse before the zone is no
+ longer authoritative.
+
+MINIMUM The unsigned 32 bit minimum TTL field that should be
+ exported with any RR from this zone.
+
+SOA records cause no additional section processing.
+
+All times are in units of seconds.
+
+Most of these fields are pertinent only for name server maintenance
+operations. However, MINIMUM is used in all query operations that
+retrieve RRs from a zone. Whenever a RR is sent in a response to a
+query, the TTL field is set to the maximum of the TTL field from the RR
+and the MINIMUM field in the appropriate SOA. Thus MINIMUM is a lower
+bound on the TTL field for all RRs in a zone. Note that this use of
+MINIMUM should occur when the RRs are copied into the response and not
+when the zone is loaded from a master file or via a zone transfer. The
+reason for this provison is to allow future dynamic update facilities to
+change the SOA RR with known semantics.
+*/
+
+namespace MailSharp.DNS.Records;
+
+public record RecordSOA(RecordReader rr) : DnsRecord(rr)
+{
+ public string MName { get; } = rr.ReadDomainName();
+ public string RName { get; } = rr.ReadDomainName();
+ public uint Serial { get; } = rr.ReadUInt32();
+ public uint Refresh { get; } = rr.ReadUInt32();
+ public uint Retry { get; } = rr.ReadUInt32();
+ public uint Expire { get; } = rr.ReadUInt32();
+ public uint Minimum { get; } = rr.ReadUInt32();
+
+ public override string ToString() => $"{MName} {RName} {Serial} {Refresh} {Retry} {Expire} {Minimum}";
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordSPF.cs b/MailSharp.DNS/Records/RecordSPF.cs
new file mode 100644
index 0000000..999ae3f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordSPF.cs
@@ -0,0 +1,22 @@
+/*
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordSPF : DnsRecord
+{
+ public List<string> Texts { get; } = [];
+
+ public RecordSPF(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ int pos = rr.Position;
+ while (rr.Position - pos < rdLength)
+ Texts.Add(rr.ReadString());
+ }
+ public override string ToString() => string.Join(" ", Texts.Select(t => $"\"{t}\""));
+}
+
diff --git a/MailSharp.DNS/Records/RecordSRV.cs b/MailSharp.DNS/Records/RecordSRV.cs
new file mode 100644
index 0000000..317188e
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordSRV.cs
@@ -0,0 +1,77 @@
+/*
+ * http://www.ietf.org/rfc/rfc2782.txt
+ *
+ Priority
+ The priority of this target host. A client MUST attempt to
+ contact the target host with the lowest-numbered priority it can
+ reach; target hosts with the same priority SHOULD be tried in an
+ order defined by the weight field. The range is 0-65535. This
+ is a 16 bit unsigned integer in network byte order.
+
+ Weight
+ A server selection mechanism. The weight field specifies a
+ relative weight for entries with the same priority. Larger
+ weights SHOULD be given a proportionately higher probability of
+ being selected. The range of this number is 0-65535. This is a
+ 16 bit unsigned integer in network byte order. Domain
+ administrators SHOULD use Weight 0 when there isn't any server
+ selection to do, to make the RR easier to read for humans (less
+ noisy). In the presence of records containing weights greater
+ than 0, records with weight 0 should have a very small chance of
+ being selected.
+
+ In the absence of a protocol whose specification calls for the
+ use of other weighting information, a client arranges the SRV
+ RRs of the same Priority in the order in which target hosts,
+ specified by the SRV RRs, will be contacted. The following
+ algorithm SHOULD be used to order the SRV RRs of the same
+ priority:
+
+ To select a target to be contacted next, arrange all SRV RRs
+ (that have not been ordered yet) in any order, except that all
+ those with weight 0 are placed at the beginning of the list.
+
+ Compute the sum of the weights of those RRs, and with each RR
+ associate the running sum in the selected order. Then choose a
+ uniform random number between 0 and the sum computed
+ (inclusive), and select the RR whose running sum value is the
+ first in the selected order which is greater than or equal to
+ the random number selected. The target host specified in the
+ selected SRV RR is the next one to be contacted by the client.
+ Remove this SRV RR from the set of the unordered SRV RRs and
+ apply the described algorithm to the unordered SRV RRs to select
+ the next target host. Continue the ordering process until there
+ are no unordered SRV RRs. This process is repeated for each
+ Priority.
+
+ Port
+ The port on this target host of this service. The range is 0-
+ 65535. This is a 16 bit unsigned integer in network byte order.
+ This is often as specified in Assigned Numbers but need not be.
+
+ Target
+ The domain name of the target host. There MUST be one or more
+ address records for this name, the name MUST NOT be an alias (in
+ the sense of RFC 1034 or RFC 2181). Implementors are urged, but
+ not required, to return the address record(s) in the Additional
+ Data section. Unless and until permitted by future standards
+ action, name compression is not to be used for this field.
+
+ A Target of "." means that the service is decidedly not
+ available at this domain.
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordSRV(RecordReader rr) : DnsRecord(rr)
+{
+ public ushort Priority { get; } = rr.ReadUInt16();
+ public ushort Weight { get; } = rr.ReadUInt16();
+ public ushort Port { get; } = rr.ReadUInt16();
+ public string Target { get; } = rr.ReadDomainName();
+
+ public override string ToString() => $"{Priority} {Weight} {Port} {Target}";
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordSSHFP.cs b/MailSharp.DNS/Records/RecordSSHFP.cs
new file mode 100644
index 0000000..1f2b2b4
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordSSHFP.cs
@@ -0,0 +1,41 @@
+/*
+https://www.rfc-editor.org/rfc/rfc4255.html
+
+The SSHFP RDATA Format
+
+ The RDATA for a SSHFP RR consists of an algorithm number, fingerprint
+ type and the fingerprint of the public host key.
+
+ 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ | algorithm | fp type | /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /
+ / /
+ / fingerprint /
+ / /
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordSSHFP : DnsRecord
+{
+ public byte Algorithm { get; init; } // 1=RSA, 2=DSA, 3=ECDSA, 4=Ed25519, 6=Ed448
+ public byte FingerprintType { get; init; } // 1=SHA-1, 2=SHA-256
+ public byte[] Fingerprint { get; init; } = [];
+
+ public RecordSSHFP(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ Algorithm = rr.ReadByte();
+ FingerprintType = rr.ReadByte();
+ Fingerprint = rr.ReadBytes(rdLength - rr.Position);
+ }
+
+ public override string ToString() =>
+ $"{Algorithm} {FingerprintType} {BitConverter.ToString(Fingerprint).Replace("-", "").ToLowerInvariant()}";
+}
diff --git a/MailSharp.DNS/Records/RecordTKEY.cs b/MailSharp.DNS/Records/RecordTKEY.cs
new file mode 100644
index 0000000..3aa105f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordTKEY.cs
@@ -0,0 +1,55 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+/*
+ * http://tools.ietf.org/rfc/rfc2930.txt
+ *
+2. The TKEY Resource Record
+
+ The TKEY resource record (RR) has the structure given below. Its RR
+ type code is 249.
+
+ Field Type Comment
+ ----- ---- -------
+ Algorithm: domain
+ Inception: u_int32_t
+ Expiration: u_int32_t
+ Mode: u_int16_t
+ Error: u_int16_t
+ Key Size: u_int16_t
+ Key Data: octet-stream
+ Other Size: u_int16_t
+ Other Data: octet-stream undefined by this specification
+
+ */
+
+namespace MailSharp.DNS.Records;
+
+public record RecordTKEY : DnsRecord
+{
+ public string Algorithm { get; }
+ public uint Inception { get; }
+ public uint Expiration { get; }
+ public ushort Mode { get; }
+ public ushort Error { get; }
+ public ushort KeySize { get; }
+ public byte[] KeyData { get; }
+ public ushort OtherSize { get; }
+ public byte[] OtherData { get; }
+
+ public RecordTKEY(RecordReader rr) : base(rr)
+ {
+ Algorithm = rr.ReadDomainName();
+ Inception = rr.ReadUInt32();
+ Expiration = rr.ReadUInt32();
+ Mode = rr.ReadUInt16();
+ Error = rr.ReadUInt16();
+ KeySize = rr.ReadUInt16();
+ KeyData = rr.ReadBytes(KeySize);
+ OtherSize = rr.ReadUInt16();
+ OtherData = rr.ReadBytes(OtherSize);
+ }
+
+ public override string ToString() =>
+ $"{Algorithm} {Inception} {Expiration} {Mode} {Error}";
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordTSIG.cs b/MailSharp.DNS/Records/RecordTSIG.cs
new file mode 100644
index 0000000..21c44c9
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordTSIG.cs
@@ -0,0 +1,61 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+/*
+ * http://www.ietf.org/rfc/rfc2845.txt
+ *
+ * Field Name Data Type Notes
+ --------------------------------------------------------------
+ Algorithm Name domain-name Name of the algorithm
+ in domain name syntax.
+ Time Signed u_int48_t seconds since 1-Jan-70 UTC.
+ Fudge u_int16_t seconds of error permitted
+ in Time Signed.
+ MAC Size u_int16_t number of octets in MAC.
+ MAC octet stream defined by Algorithm Name.
+ Original ID u_int16_t original message ID
+ Error u_int16_t expanded RCODE covering
+ TSIG processing.
+ Other Len u_int16_t length, in octets, of
+ Other Data.
+ Other Data octet stream empty unless Error == BADTIME
+
+ */
+
+namespace MailSharp.DNS.Records;
+
+public record RecordTSIG : DnsRecord
+{
+ public string AlgorithmName { get; }
+ public long TimeSigned { get; }
+ public ushort Fudge { get; }
+ public ushort MacSize { get; }
+ public byte[] Mac { get; }
+ public ushort OriginalId { get; }
+ public ushort Error { get; }
+ public ushort OtherLen { get; }
+ public byte[] OtherData { get; }
+
+ public RecordTSIG(RecordReader rr) : base(rr)
+ {
+ AlgorithmName = rr.ReadDomainName();
+
+ uint high = rr.ReadUInt32();
+ uint low = rr.ReadUInt32();
+ TimeSigned = (long)high << 32 | low;
+
+ Fudge = rr.ReadUInt16();
+ MacSize = rr.ReadUInt16();
+ Mac = rr.ReadBytes(MacSize);
+ OriginalId = rr.ReadUInt16();
+ Error = rr.ReadUInt16();
+ OtherLen = rr.ReadUInt16();
+ OtherData = rr.ReadBytes(OtherLen);
+ }
+
+ public override string ToString()
+ {
+ DateTime dt = DateTime.UnixEpoch.AddSeconds(TimeSigned);
+ return $"{AlgorithmName} {dt:yyyy-MM-dd HH:mm:ss} {Fudge} {OriginalId} {Error}";
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordTXT.cs b/MailSharp.DNS/Records/RecordTXT.cs
new file mode 100644
index 0000000..b561d9f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordTXT.cs
@@ -0,0 +1,35 @@
+#region Rfc info
+/*
+3.3.14. TXT RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ / TXT-DATA /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+TXT-DATA One or more <character-string>s.
+
+TXT RRs are used to hold descriptive text. The semantics of the text
+depends on the domain where it is found.
+ *
+*/
+#endregion
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public sealed record RecordTXT : DnsRecord
+{
+ public List<string> Texts { get; } = [];
+
+ public RecordTXT(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16(-2);
+ int pos = rr.Position;
+ while (rr.Position - pos < rdLength)
+ Texts.Add(rr.ReadString());
+ }
+ public override string ToString() => string.Join(" ", Texts.Select(t => $"\"{t}\""));
+}
diff --git a/MailSharp.DNS/Records/RecordUID.cs b/MailSharp.DNS/Records/RecordUID.cs
new file mode 100644
index 0000000..daa6403
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordUID.cs
@@ -0,0 +1,19 @@
+/*
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordUID : DnsRecord
+{
+ public uint Uid { get; init; }
+
+ public RecordUID(RecordReader rr) : base(rr)
+ {
+ Uid = rr.ReadUInt32();
+ }
+ public override string ToString() => Uid.ToString();
+}
+
diff --git a/MailSharp.DNS/Records/RecordUINFO.cs b/MailSharp.DNS/Records/RecordUINFO.cs
new file mode 100644
index 0000000..0d47b90
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordUINFO.cs
@@ -0,0 +1,21 @@
+/*
+IANA-Reserved
+ */
+
+using MailSharp.DNS.Records;
+using System.Text;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordUINFO : DnsRecord
+{
+ public string Info { get; init; } = string.Empty;
+
+ public RecordUINFO(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16();
+ Info = Encoding.ASCII.GetString(rr.ReadBytes(rdLength));
+ }
+
+ public override string ToString() => $"\"{Info}\"";
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Records/RecordUNSPEC.cs b/MailSharp.DNS/Records/RecordUNSPEC.cs
new file mode 100644
index 0000000..0d7f3d2
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordUNSPEC.cs
@@ -0,0 +1,21 @@
+/*
+
+ */
+
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordUNSPEC : DnsRecord
+{
+ public byte[] Data { get; init; } = [];
+
+ public RecordUNSPEC(RecordReader rr) : base(rr)
+ {
+ ushort rdLength = rr.ReadUInt16();
+ Data = rr.ReadBytes(rdLength);
+ }
+
+ public override string ToString() =>
+ Convert.ToBase64String(Data);
+}
diff --git a/MailSharp.DNS/Records/RecordUnknown.cs b/MailSharp.DNS/Records/RecordUnknown.cs
new file mode 100644
index 0000000..e872cbb
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordUnknown.cs
@@ -0,0 +1,16 @@
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS.Records;
+
+public record RecordUnknown : DnsRecord
+{
+ public byte[] RDATA;
+ public RecordUnknown(RecordReader rr) : base(rr)
+ {
+ // re-read length
+ ushort RDLENGTH = rr.ReadUInt16(-2);
+ RDATA = rr.ReadBytes(RDLENGTH);
+ }
+
+ public override string ToString() => Convert.ToHexString(RDATA);
+}
diff --git a/MailSharp.DNS/Records/RecordWKS.cs b/MailSharp.DNS/Records/RecordWKS.cs
new file mode 100644
index 0000000..36e5c9f
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordWKS.cs
@@ -0,0 +1,59 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+using System.Net;
+/*
+ * 3.4.2. WKS RDATA format
+
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | ADDRESS |
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+ | PROTOCOL | |
+ +--+--+--+--+--+--+--+--+ |
+ | |
+ / <BIT MAP> /
+ / /
+ +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+where:
+
+ADDRESS An 32 bit Internet address
+
+PROTOCOL An 8 bit IP protocol number
+
+<BIT MAP> A variable length bit map. The bit map must be a
+ multiple of 8 bits long.
+
+The WKS record is used to describe the well known services supported by
+a particular protocol on a particular internet address. The PROTOCOL
+field specifies an IP protocol number, and the bit map has one bit per
+port of the specified protocol. The first bit corresponds to port 0,
+the second to port 1, etc. If the bit map does not include a bit for a
+protocol of interest, that bit is assumed zero. The appropriate values
+and mnemonics for ports and protocols are specified in [RFC-1010].
+
+For example, if PROTOCOL=TCP (6), the 26th bit corresponds to TCP port
+25 (SMTP). If this bit is set, a SMTP server should be listening on TCP
+port 25; if zero, SMTP service is not supported on the specified
+address.
+
+The purpose of WKS RRs is to provide availability information for
+servers for TCP and UDP. If a server supports both TCP and UDP, or has
+multiple Internet addresses, then multiple WKS RRs are used.
+
+WKS RRs cause no additional section processing.
+
+In master files, both ports and protocols are expressed using mnemonics
+or decimal numbers.
+
+ */
+namespace MailSharp.DNS.Records;
+
+public record RecordWKS(RecordReader rr) : DnsRecord(rr)
+{
+ public IPAddress Address { get; } = new(rr.ReadBytes(4));
+ public byte Protocol { get; } = rr.ReadByte();
+ public byte[] BitMap { get; } = rr.ReadBytes(rr.ReadUInt16(-2) - 5);
+
+ public override string ToString() => $"{Address} {Protocol}";
+}
diff --git a/MailSharp.DNS/Records/RecordX25.cs b/MailSharp.DNS/Records/RecordX25.cs
new file mode 100644
index 0000000..f0e9b37
--- /dev/null
+++ b/MailSharp.DNS/Records/RecordX25.cs
@@ -0,0 +1,41 @@
+using MailSharp.DNS;
+using MailSharp.DNS.Records;
+using System;
+/* http://tools.ietf.org/rfc/rfc1183.txt
+
+3.1. The X25 RR
+
+ The X25 RR is defined with mnemonic X25 and type code 19 (decimal).
+
+ X25 has the following format:
+
+ <owner> <ttl> <class> X25 <PSDN-address>
+
+ <PSDN-address> is required in all X25 RRs.
+
+ <PSDN-address> identifies the PSDN (Public Switched Data Network)
+ address in the X.121 [10] numbering plan associated with <owner>.
+ Its format in master files is a <character-string> syntactically
+ identical to that used in TXT and HINFO.
+
+ The format of X25 is class insensitive. X25 RRs cause no additional
+ section processing.
+
+ The <PSDN-address> is a string of decimal digits, beginning with the
+ 4 digit DNIC (Data Network Identification Code), as specified in
+ X.121. National prefixes (such as a 0) MUST NOT be used.
+
+ For example:
+
+ Relay.Prime.COM. X25 311061700956
+
+
+ */
+
+namespace MailSharp.DNS.Records;
+
+public record RecordX25(RecordReader rr) : DnsRecord(rr)
+{
+ public string PsdnAddress { get; } = rr.ReadString();
+ public override string ToString() => PsdnAddress;
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Request.cs b/MailSharp.DNS/Request.cs
new file mode 100644
index 0000000..d98d3cb
--- /dev/null
+++ b/MailSharp.DNS/Request.cs
@@ -0,0 +1,38 @@
+using MailSharp.DNS;
+
+namespace MailSharp.DNS;
+
+public sealed class Request
+{
+ private const int MaxUdpSize = 512;
+ private readonly byte[] buffer = new byte[MaxUdpSize];
+ private int length = 12; // start na header
+
+ public DnsHeader Header { get; }
+
+ public Request()
+ {
+ Header = new DnsHeader(buffer)
+ {
+ OPCODE = OPCode.Query,
+ RD = true
+ };
+ }
+
+ public void AddQuestion(QuestionRecord question)
+ {
+ // Eerst droog simuleren
+ int nameBytes = DnsWriter.MeasureDomainName(question.Name);
+ int needed = nameBytes + 4; // + DnsQType + DnsQClass
+
+ if (length + needed > MaxUdpSize)
+ throw new InvalidOperationException(
+ $"DNS UDP packet would exceed 512 bytes ({length + needed} > {MaxUdpSize})");
+
+ int written = question.WriteTo(buffer.AsSpan(length));
+ length += written;
+ Header.QDCount++;
+ }
+
+ public Memory<byte> Data => buffer.AsMemory(0, length);
+}
diff --git a/MailSharp.DNS/Resolver.Cache.cs b/MailSharp.DNS/Resolver.Cache.cs
new file mode 100644
index 0000000..3de1f32
--- /dev/null
+++ b/MailSharp.DNS/Resolver.Cache.cs
@@ -0,0 +1,73 @@
+using System.Collections.Concurrent;
+
+namespace MailSharp.DNS;
+
+public partial class Resolver
+{
+ private readonly ConcurrentDictionary<string, List<RR>> recordCache = [];
+ private Response? SearchInRecordCache(QuestionRecord question)
+ {
+ if (!useCache)
+ return null;
+
+ List<RR> list = [];
+ if (recordCache.TryGetValue(question.Name, out List<RR>? value))
+ {
+ list = value.Where(x => x.TTL > 0 && x.Class == (DnsClass)question.Class &&
+ (question.Type == DnsQType.ANY || x.Type == (DnsType)question.Type))
+ .ToList();
+ }
+ if (list.Count == 0)
+ return null;
+
+ Response response = new()
+ {
+ Questions = [question],
+ Authorities = list
+ };
+
+ response.Header.QDCount = (ushort)response.Questions.Count;
+ response.Header.NSCount = (ushort)list.Count;
+ return response;
+ }
+
+ private void AddToCache(Response response)
+ {
+ if (!useCache || response.Questions.Count == 0 || response.Header.RCODE != RCode.NoError)
+ return;
+
+ foreach (var rr in response.RecordsRR)
+ {
+ rr.TimeStamp = DateTime.Now;
+ recordCache.AddOrUpdate(rr.Name,
+ [rr],
+ (_, list) =>
+ {
+ list.Add(rr);
+ return list;
+ });
+ }
+ }
+
+ internal class QuestionComparer : IEqualityComparer<QuestionRecord>
+ {
+ public int GetHashCode(QuestionRecord obj)
+ {
+ int hash = 17;
+ hash = hash * 31 + obj.Class.GetHashCode();
+ hash = hash * 31 + obj.Type.GetHashCode();
+ hash = hash * 31 + (obj.Name != null ? StringComparer.Ordinal.GetHashCode(obj.Name) : 0);
+ return hash;
+ }
+ public bool Equals(QuestionRecord? x, QuestionRecord? y)
+ {
+ if(x == null || y == null)
+ return false;
+ if(x.Class != y.Class)
+ return false;
+ if (x.Type != y.Type)
+ return false;
+ return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Resolver.Events.cs b/MailSharp.DNS/Resolver.Events.cs
new file mode 100644
index 0000000..4aabf92
--- /dev/null
+++ b/MailSharp.DNS/Resolver.Events.cs
@@ -0,0 +1,29 @@
+namespace MailSharp.DNS;
+
+public partial class Resolver
+{
+ /// <summary>
+ /// Verbose messages from internal operations
+ /// </summary>
+ public event VerboseEventHandler? OnVerbose;
+
+ public delegate void VerboseEventHandler(object sender, VerboseEventArgs e);
+
+ public sealed class VerboseEventArgs : EventArgs
+ {
+ public string Message { get; }
+
+ public VerboseEventArgs(string message)
+ {
+ Message = message;
+ }
+ }
+
+ private void Verbose(string format, params object[] args)
+ {
+ if (OnVerbose is null)
+ return;
+
+ OnVerbose(this, new VerboseEventArgs(string.Format(format, args)));
+ }
+}
diff --git a/MailSharp.DNS/Resolver.Query.cs b/MailSharp.DNS/Resolver.Query.cs
new file mode 100644
index 0000000..34b5c38
--- /dev/null
+++ b/MailSharp.DNS/Resolver.Query.cs
@@ -0,0 +1,71 @@
+using MailSharp.DNS.Records;
+using System.Net;
+
+namespace MailSharp.DNS;
+
+public partial class Resolver
+{
+ public async Task<Response> QueryAsync(IPEndPoint server, string name, DnsQType type, DnsQClass @class, CancellationToken ct = default)
+ {
+ if (!name.EndsWith('.'))
+ name += '.';
+
+ if (recordCache.IsEmpty)
+ await BootstrapRootAsync(ct);
+
+ var question = new QuestionRecord(name, type, @class);
+ if (SearchInRecordCache(question) is Response cached)
+ return cached;
+
+ var request = new Request();
+ request.AddQuestion(question);
+ return await GetResponseAsync(server, request);
+ }
+
+ private async Task BootstrapRootAsync(CancellationToken ct)
+ {
+ if (!File.Exists("root\\named_root.txt"))
+ return;
+ List<RR> authorities = [];
+ await foreach (var authority in RootHintsParser.ParseAsync("root\\named_root.txt", ct))
+ authorities.Add(authority);
+ AddToCache(new Response
+ {
+ Questions = [new QuestionRecord(".", DnsQType.NS, DnsQClass.IN)],
+ Authorities = authorities
+ });
+ }
+
+ public async Task<IPHostEntry> GetHostEntryAsync(IPEndPoint server, string hostNameOrAddress, CancellationToken ct = default)
+ => IPAddress.TryParse(hostNameOrAddress, out var ip)
+ ? await GetHostEntryAsync(server, ip, ct)
+ : await ResolveForwardAsync(server, hostNameOrAddress, ct);
+
+ public async Task<IPHostEntry> GetHostEntryAsync(IPEndPoint server, IPAddress ip, CancellationToken ct = default)
+ {
+ var response = await QueryAsync(server, GetArpaFromIp(ip), DnsQType.PTR, DnsQClass.IN, ct);
+ return response.RecordsPTR.Length > 0
+ ? await ResolveForwardAsync(server, response.RecordsPTR[0].PtrName, ct)
+ : new IPHostEntry();
+ }
+
+ private async Task<IPHostEntry> ResolveForwardAsync(IPEndPoint server, string hostName, CancellationToken ct)
+ {
+ var response = await QueryAsync(server, hostName, DnsQType.A, DnsQClass.IN, ct);
+ var entry = new IPHostEntry { HostName = hostName };
+ var addresses = new List<IPAddress>();
+ var aliases = new List<string>();
+
+ foreach (var rr in response.Answers)
+ {
+ if (rr.Type == DnsType.A && rr.Record is RecordA a)
+ addresses.Add(a.Address);
+ else if (rr.Type == DnsType.CNAME)
+ aliases.Add(rr.Name);
+ }
+
+ entry.AddressList = [.. addresses];
+ entry.Aliases = [.. aliases];
+ return entry;
+ }
+}
diff --git a/MailSharp.DNS/Resolver.Transport.cs b/MailSharp.DNS/Resolver.Transport.cs
new file mode 100644
index 0000000..bdb4ecb
--- /dev/null
+++ b/MailSharp.DNS/Resolver.Transport.cs
@@ -0,0 +1,89 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace MailSharp.DNS;
+
+public partial class Resolver
+{
+ private async Task<Response> GetResponseAsync(IPEndPoint server, Request request)
+ {
+ request.Header.ID = (ushort)Interlocked.Increment(ref uniqueId);
+ request.Header.RD = recursion;
+
+ return transportType switch
+ {
+ TransportType.Udp => await UdpRequestAsync(server, request),
+ TransportType.Tcp => await TcpRequestAsync(server, request),
+ _ => new Response { ErrorMessage = "Unknown TransportType" }
+ };
+ }
+
+ private async Task<Response> UdpRequestAsync(IPEndPoint server, Request request)
+ {
+ var buffer = new byte[512];
+
+ for (int attempt = 0; attempt < retries; attempt++)
+ {
+ using var socket = new Socket(server.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
+ socket.ReceiveTimeout = timeoutSeconds * 1000;
+
+ try
+ {
+ await socket.SendToAsync(request.Data.ToArray(), SocketFlags.None, server);
+ var received = await socket.ReceiveAsync(buffer, SocketFlags.None);
+ var data = buffer[..received];
+ var response = new Response(data);
+ AddToCache(response);
+ return response;
+ }
+ catch (SocketException) { /* next server/attempt */ }
+
+ }
+
+ return new Response { ErrorMessage = "Timeout Error" };
+ }
+
+ private async Task<Response> TcpRequestAsync(IPEndPoint server, Request request)
+ {
+ Memory<byte> data = request.Data;
+
+ for (int attempt = 0; attempt < retries; attempt++)
+ {
+ using var client = new TcpClient(server.AddressFamily);
+ client.ReceiveTimeout = timeoutSeconds * 1000;
+ client.SendTimeout = timeoutSeconds * 1000;
+
+ try
+ {
+ await client.ConnectAsync(server.Address, server.Port);
+ await using var stream = client.GetStream();
+
+ // 2-byte length prefix (big-endian) – zonder WriteByteAsync
+ byte[] lengthPrefix = [(byte)(data.Length >> 8), (byte)(data.Length & 0xFF)];
+ await stream.WriteAsync(lengthPrefix);
+ await stream.WriteAsync(data.ToArray());
+ await stream.FlushAsync();
+
+ // lees 2-byte length prefix
+ byte[] lengthBuffer = new byte[2];
+ await stream.ReadExactlyAsync(lengthBuffer);
+ int length = (lengthBuffer[0] << 8) | lengthBuffer[1];
+
+ // lees het echte DNS-bericht
+ byte[] responseData = new byte[length];
+ await stream.ReadExactlyAsync(responseData);
+
+ Response response = new(responseData);
+ AddToCache(response);
+ return response;
+ }
+ catch (Exception)
+ {
+ // volgende server / poging
+ }
+ }
+
+ return new Response { ErrorMessage = "Timeout Error" };
+ }
+
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Resolver.Utilities.cs b/MailSharp.DNS/Resolver.Utilities.cs
new file mode 100644
index 0000000..cf12676
--- /dev/null
+++ b/MailSharp.DNS/Resolver.Utilities.cs
@@ -0,0 +1,64 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace MailSharp.DNS;
+
+public partial class Resolver
+{
+ public static string GetArpaFromIp(IPAddress ip)
+ {
+ if (ip.AddressFamily == AddressFamily.InterNetwork)
+ {
+ var sb = new StringBuilder("in-addr.arpa.");
+ foreach (var b in ip.GetAddressBytes())
+ sb.Insert(0, $"{b}.");
+ return sb.ToString();
+ }
+
+ if (ip.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ var sb = new StringBuilder("ip6.arpa.");
+ foreach (var b in ip.GetAddressBytes())
+ {
+ sb.Insert(0, $"{(b & 0xF):x}.{(b >> 4):x}.");
+ return sb.ToString();
+ }
+ }
+
+ return "?";
+ }
+
+ /// <summary>
+ /// Converts an E.164 phone number to an arpa domain name (RFC 3761)
+ /// Example: +31161234567 → 7.6.5.4.3.2.1.6.1.1.3.e164.arpa.
+ /// </summary>
+ /// <param name="strEnum">The E.164 number (with or without + and non-digits)</param>
+ /// <returns>The ENUM arpa name</returns>
+ public static string GetArpaFromEnum(string strEnum)
+ {
+ if (string.IsNullOrEmpty(strEnum))
+ return "e164.arpa.";
+
+ // Remove everything that is not a digit
+ string digitsOnly = NonDigitRegex().Replace(strEnum, "");
+
+ StringBuilder sb = new StringBuilder();
+ sb.Append("e164.arpa.");
+
+ // Insert dots from right to left
+ foreach (char c in digitsOnly)
+ {
+ sb.Insert(0, c);
+ sb.Insert(0, '.');
+ }
+
+ return sb.ToString();
+ }
+
+ [GeneratedRegex("[^0-9]")]
+ private static partial Regex NonDigitRegex();
+
+
+}
\ No newline at end of file
diff --git a/MailSharp.DNS/Resolver.cs b/MailSharp.DNS/Resolver.cs
new file mode 100644
index 0000000..4abd3aa
--- /dev/null
+++ b/MailSharp.DNS/Resolver.cs
@@ -0,0 +1,25 @@
+using System.Reflection;
+
+namespace MailSharp.DNS;
+
+public partial class Resolver
+{
+ public static string Version =>
+ Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? string.Empty;
+
+ private uint uniqueId = (uint)new Random().Next();
+ private bool useCache = true;
+ private bool recursion = true;
+ private int retries = 3;
+ private int timeoutSeconds = 1;
+ private TransportType transportType = TransportType.Udp;
+
+ public int TimeOut { get => timeoutSeconds; set => timeoutSeconds = value; }
+ public int Retries { get => retries; set => retries = value >= 1 ? value : retries; }
+ public bool Recursion { get => recursion; set => recursion = value; }
+ public TransportType TransportType { get => transportType; set => transportType = value; }
+ public bool UseCache { get => useCache; set { useCache = value; if (!value) recordCache.Clear(); } }
+
+ public void ClearCache() => recordCache.Clear();
+
+}
diff --git a/MailSharp.DNS/Response.cs b/MailSharp.DNS/Response.cs
new file mode 100644
index 0000000..fb1149f
--- /dev/null
+++ b/MailSharp.DNS/Response.cs
@@ -0,0 +1,109 @@
+using MailSharp.DNS.Records;
+
+namespace MailSharp.DNS;
+
+public class Response
+{
+ private readonly byte[] data = [];
+
+ public List<QuestionRecord> Questions { get; set; } = [];
+ public List<RR> Answers { get; set; } = [];
+ public List<RR> Authorities { get; set; } = [];
+ public List<RR> Additionals { get; set; } = [];
+
+ /// <summary>
+ /// Error message, empty when no error
+ /// </summary>
+ public string Error => Header.RCODE == RCode.NoError ? string.Empty : Header.RCODE.ToString();
+
+ public string ErrorMessage { get; set; } = string.Empty;
+ /// <summary>
+ /// TimeStamp when cached
+ /// </summary>
+ public DateTime TimeStamp;
+
+ public Response()
+ {
+ this.data = new byte[12];
+
+ TimeStamp = DateTime.Now;
+ }
+
+ public int MessageSize => this.data.Length;
+
+ public DnsHeader Header => new(this.data);
+
+ public Response(byte[] data)
+ {
+ this.data = data;
+
+ RecordReader rr = new(this.data, 12);
+ for (int intI = 0; intI < this.Header.QDCount; intI++)
+ {
+ this.Questions.Add(new QuestionRecord(ref rr));
+ }
+ for (int intI = 0; intI < this.Header.ANCount; intI++)
+ {
+ this.Answers.Add(new RR(rr));
+ }
+ for (int intI = 0; intI < this.Header.NSCount; intI++)
+ {
+ this.Authorities.Add(new RR(rr));
+ }
+ for (int intI = 0; intI < this.Header.ARCount; intI++)
+ {
+ this.Additionals.Add(new RR(rr));
+ }
+
+ TimeStamp = DateTime.Now;
+ }
+
+ /// <summary>
+ /// List of RecordMX in Response.Answers
+ /// </summary>
+ public RecordMX[] RecordsMX => [.. Answers.Select(a => a.Record).OfType<RecordMX>()];
+
+ /// <summary>
+ /// List of RecordTXT in Response.Answers
+ /// </summary>
+ public RecordTXT[] RecordsTXT => [.. Answers.Select(a => a.Record).OfType<RecordTXT>()];
+
+ /// <summary>
+ /// List of RecordA in Response.Answers
+ /// </summary>
+ public RecordA[] RecordsA => [.. Answers.Select(a => a.Record).OfType<RecordA>()];
+
+ /// <summary>
+ /// List of RecordPTR in Response.Answers
+ /// </summary>
+ public RecordPTR[] RecordsPTR => [.. Answers.Select(a => a.Record).OfType<RecordPTR>()];
+
+ /// <summary>
+ /// List of RecordCNAME in Response.Answers
+ /// </summary>
+ public RecordCNAME[] RecordsCNAME => [.. Answers.Select(a => a.Record).OfType<RecordCNAME>()];
+
+ /// <summary>
+ /// List of RecordAAAA in Response.Answers
+ /// </summary>
+ public RecordAAAA[] RecordsAAAA => [.. Answers.Select(a => a.Record).OfType<RecordAAAA>()];
+
+ /// <summary>
+ /// List of RecordNS in Response.Answers
+ /// </summary>
+ public RecordNS[] RecordsNS => [.. Answers.Select(a => a.Record).OfType<RecordNS>()];
+
+ /// <summary>
+ /// List of RecordSOA in Response.Answers
+ /// </summary>
+ public RecordSOA[] RecordsSOA => [.. Answers.Select(a => a.Record).OfType<RecordSOA>()];
+
+ public RR[] RecordsRR
+ {
+ get
+ {
+ List<RR> list = [.. this.Answers, .. this.Authorities, .. this.Additionals];
+ return [.. list];
+ }
+ }
+}
diff --git a/MailSharp.DNS/Root/named_root.txt b/MailSharp.DNS/Root/named_root.txt
new file mode 100644
index 0000000..70caf60
--- /dev/null
+++ b/MailSharp.DNS/Root/named_root.txt
@@ -0,0 +1,92 @@
+; This file holds the information on root name servers needed to
+; initialize cache of Internet domain name servers
+; (e.g. reference this file in the "cache . <file>"
+; configuration file of BIND domain name servers).
+;
+; This file is made available by InterNIC
+; under anonymous FTP as
+; file /domain/named.cache
+; on server FTP.INTERNIC.NET
+; -OR- RS.INTERNIC.NET
+;
+; last update: November 20, 2025
+; related version of root zone: 2025112001
+;
+; FORMERLY NS.INTERNIC.NET
+;
+. 3600000 NS A.ROOT-SERVERS.NET.
+A.ROOT-SERVERS.NET. 3600000 A 198.41.0.4
+A.ROOT-SERVERS.NET. 3600000 AAAA 2001:503:ba3e::2:30
+;
+; FORMERLY NS1.ISI.EDU
+;
+. 3600000 NS B.ROOT-SERVERS.NET.
+B.ROOT-SERVERS.NET. 3600000 A 170.247.170.2
+B.ROOT-SERVERS.NET. 3600000 AAAA 2801:1b8:10::b
+;
+; FORMERLY C.PSI.NET
+;
+. 3600000 NS C.ROOT-SERVERS.NET.
+C.ROOT-SERVERS.NET. 3600000 A 192.33.4.12
+C.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:2::c
+;
+; FORMERLY TERP.UMD.EDU
+;
+. 3600000 NS D.ROOT-SERVERS.NET.
+D.ROOT-SERVERS.NET. 3600000 A 199.7.91.13
+D.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:2d::d
+;
+; FORMERLY NS.NASA.GOV
+;
+. 3600000 NS E.ROOT-SERVERS.NET.
+E.ROOT-SERVERS.NET. 3600000 A 192.203.230.10
+E.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:a8::e
+;
+; FORMERLY NS.ISC.ORG
+;
+. 3600000 NS F.ROOT-SERVERS.NET.
+F.ROOT-SERVERS.NET. 3600000 A 192.5.5.241
+F.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:2f::f
+;
+; FORMERLY NS.NIC.DDN.MIL
+;
+. 3600000 NS G.ROOT-SERVERS.NET.
+G.ROOT-SERVERS.NET. 3600000 A 192.112.36.4
+G.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:12::d0d
+;
+; FORMERLY AOS.ARL.ARMY.MIL
+;
+. 3600000 NS H.ROOT-SERVERS.NET.
+H.ROOT-SERVERS.NET. 3600000 A 198.97.190.53
+H.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:1::53
+;
+; FORMERLY NIC.NORDU.NET
+;
+. 3600000 NS I.ROOT-SERVERS.NET.
+I.ROOT-SERVERS.NET. 3600000 A 192.36.148.17
+I.ROOT-SERVERS.NET. 3600000 AAAA 2001:7fe::53
+;
+; OPERATED BY VERISIGN, INC.
+;
+. 3600000 NS J.ROOT-SERVERS.NET.
+J.ROOT-SERVERS.NET. 3600000 A 192.58.128.30
+J.ROOT-SERVERS.NET. 3600000 AAAA 2001:503:c27::2:30
+;
+; OPERATED BY RIPE NCC
+;
+. 3600000 NS K.ROOT-SERVERS.NET.
+K.ROOT-SERVERS.NET. 3600000 A 193.0.14.129
+K.ROOT-SERVERS.NET. 3600000 AAAA 2001:7fd::1
+;
+; OPERATED BY ICANN
+;
+. 3600000 NS L.ROOT-SERVERS.NET.
+L.ROOT-SERVERS.NET. 3600000 A 199.7.83.42
+L.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:9f::42
+;
+; OPERATED BY WIDE
+;
+. 3600000 NS M.ROOT-SERVERS.NET.
+M.ROOT-SERVERS.NET. 3600000 A 202.12.27.33
+M.ROOT-SERVERS.NET. 3600000 AAAA 2001:dc3::35
+; End of file
diff --git a/MailSharp.DNS/RootHintsParser.cs b/MailSharp.DNS/RootHintsParser.cs
new file mode 100644
index 0000000..be00c78
--- /dev/null
+++ b/MailSharp.DNS/RootHintsParser.cs
@@ -0,0 +1,46 @@
+using MailSharp.DNS.Records;
+using System.Runtime.CompilerServices;
+
+namespace MailSharp.DNS;
+
+public record RootHint(string Domain, long TTL, string RecordType, string Value);
+
+public static class RootHintsParser
+{
+ public static async IAsyncEnumerable<RR> ParseAsync(string filePath,
+ [EnumeratorCancellation]
+ CancellationToken ct)
+ {
+ await foreach (var line in File.ReadLinesAsync(filePath, ct))
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ var clean = line.Split(';', 2)[0].TrimEnd();
+ if (string.IsNullOrWhiteSpace(clean))
+ continue;
+
+ var fields = clean.Split(' ', System.StringSplitOptions.RemoveEmptyEntries);
+ if (fields.Length < 4)
+ continue;
+
+ RR hint = new()
+ {
+ Name = fields[0],
+ TTL = uint.Parse(fields[1]),
+ Type = Enum.Parse<DnsType>(fields[2]),
+ Class = DnsClass.IN,
+ };
+ hint.Record = hint.Type switch
+ {
+ DnsType.A => new RecordA(fields[3]),
+ DnsType.AAAA => new RecordAAAA(fields[3]),
+ DnsType.NS => new RecordNS(fields[3]),
+ _ => throw new Exception($"Unsupported root hint type: {fields[2]}"),
+ };
+ yield return hint;
+ }
+ }
+
+
+}
diff --git a/MailSharp.IMAP/Extensions/ImapServiceExtensions.cs b/MailSharp.IMAP/Extensions/ImapServiceExtensions.cs
new file mode 100644
index 0000000..8c9585d
--- /dev/null
+++ b/MailSharp.IMAP/Extensions/ImapServiceExtensions.cs
@@ -0,0 +1,17 @@
+using MailSharp.IMAP.Metrics;
+using MailSharp.IMAP.Services;
+
+namespace MailSharp.IMAP.Extensions;
+
+public static class ImapServiceExtensions
+{
+ // Adds IMAP services to the specified IServiceCollection
+ public static IServiceCollection AddImapServices(this IServiceCollection services)
+ {
+ services.AddSingleton<ImapMetrics>();
+ services.AddSingleton<ImapService>();
+ services.AddHostedService(provider => provider.GetRequiredService<ImapService>());
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.IMAP/MailSharp.IMAP.csproj b/MailSharp.IMAP/MailSharp.IMAP.csproj
new file mode 100644
index 0000000..0cd7069
--- /dev/null
+++ b/MailSharp.IMAP/MailSharp.IMAP.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <OutputType>Library</OutputType>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\MailSharp.Common\MailSharp.Common.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/MailSharp.IMAP/Metrics/SmtpMetrics.cs b/MailSharp.IMAP/Metrics/SmtpMetrics.cs
new file mode 100644
index 0000000..a18ba11
--- /dev/null
+++ b/MailSharp.IMAP/Metrics/SmtpMetrics.cs
@@ -0,0 +1,52 @@
+using System.Collections.Concurrent;
+
+namespace MailSharp.IMAP.Metrics;
+
+public sealed class ImapMetrics
+{
+ private long totalConnections;
+ private long activeSessions;
+ private long loginSuccess;
+ private long loginFailed;
+ private long messagesFetched;
+ private long bytesSent;
+ private long commandsProcessed;
+ private long folderSelects;
+
+ public long TotalConnections => totalConnections;
+ public long ActiveSessions => activeSessions;
+ public long LoginSuccess => loginSuccess;
+ public long LoginFailed => loginFailed;
+ public long MessagesFetched => messagesFetched;
+ public long BytesSent => bytesSent;
+ public long CommandsProcessed => commandsProcessed;
+ public long FolderSelects => folderSelects;
+ public long UptimeSeconds => (long)(DateTime.UtcNow - StartTime).TotalSeconds;
+ public bool IsRunning { get; set; } = false;
+ public DateTime StartTime { get; } = DateTime.UtcNow;
+ public DateTime? LastActivityAt { get; private set; }
+
+ private readonly ConcurrentDictionary<string, long> activeUsers = new(StringComparer.OrdinalIgnoreCase);
+ public IReadOnlyDictionary<string, long> TopUsers => activeUsers;
+
+ public void IncrementConnections() => Interlocked.Increment(ref totalConnections);
+ public void IncrementActive() => Interlocked.Increment(ref activeSessions);
+ public void DecrementActive() => Interlocked.Decrement(ref activeSessions);
+ public void IncrementLoginFailed() => Interlocked.Increment(ref loginFailed);
+ public void IncrementCommands() => Interlocked.Increment(ref commandsProcessed);
+ public void IncrementFolderSelects() => Interlocked.Increment(ref folderSelects);
+
+ public void LoginSucceeded(string username)
+ {
+ Interlocked.Increment(ref loginSuccess);
+ LastActivityAt = DateTime.UtcNow;
+ activeUsers.AddOrUpdate(username, 1, (_, c) => c + 1);
+ }
+
+ public void MessageFetched(long bytes)
+ {
+ Interlocked.Increment(ref messagesFetched);
+ Interlocked.Add(ref bytesSent, bytes);
+ LastActivityAt = DateTime.UtcNow;
+ }
+}
diff --git a/MailSharp.IMAP/Properties/launchSettings.json b/MailSharp.IMAP/Properties/launchSettings.json
new file mode 100644
index 0000000..2424f3d
--- /dev/null
+++ b/MailSharp.IMAP/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "MailSharp.IMAP": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:10440;http://localhost:10441"
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.IMAP/Server/ImapServer.cs b/MailSharp.IMAP/Server/ImapServer.cs
new file mode 100644
index 0000000..4469187
--- /dev/null
+++ b/MailSharp.IMAP/Server/ImapServer.cs
@@ -0,0 +1,139 @@
+using MailSharp.Common;
+using MailSharp.Common.Services;
+using MailSharp.IMAP.Metrics;
+using MailSharp.IMAP.Session;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MailSharp.IMAP.Server;
+
+public class ImapServer
+{
+ private readonly IConfiguration configuration;
+ private readonly ILogger<ImapServer> logger;
+ private readonly ILogger<ImapSession> sessionLogger;
+ private readonly AuthenticationService authService;
+ private readonly MailboxService mailboxService;
+ private readonly List<ServerContext> listeners = [];
+ private readonly ImapMetrics metrics;
+ private CancellationTokenSource? cts;
+
+ public ImapServer(
+ IConfiguration configuration,
+ ILogger<ImapServer> logger,
+ ILogger<ImapSession> sessionLogger,
+ AuthenticationService authService,
+ MailboxService mailboxService,
+ ImapMetrics metrics)
+ {
+ this.configuration = configuration;
+ this.logger = logger;
+ this.sessionLogger = sessionLogger;
+ this.authService = authService;
+ this.mailboxService = mailboxService;
+ this.metrics = metrics;
+
+ var ports = configuration.GetSection("ImapSettings:Ports").Get<List<PortConfig>>()
+ ?? throw new InvalidOperationException("Ports not configured");
+
+ foreach (var port in ports)
+ {
+ listeners.Add(new(new TcpListener(IPAddress.Parse(port.Host), port.Port), port.Security));
+ }
+ }
+
+ public async Task StartAsync()
+ {
+ cts = new CancellationTokenSource();
+ var active = new List<ServerContext>();
+ foreach (var context in listeners)
+ {
+ try
+ {
+ context.Listener.Start();
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ServerStarted").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ServerStarted");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["ImapLogMessages:ServerStarted"],
+ context.Listener.LocalEndpoint, context.Security);
+ active.Add(context);
+ }
+ catch (SocketException ex)
+ {
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ServerStartFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ServerStartFailed");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["ImapLogMessages:ServerStartFailed"],
+ ((IPEndPoint)context.Listener.LocalEndpoint).Port,
+ ex.Message);
+ }
+ }
+ if (active.Count > 0)
+ await Task.WhenAll(active.Select(context =>
+ Task.Run(() => AcceptClientsAsync(context, cts.Token), cts.Token)));
+ await StopAsync();
+ }
+
+ private async Task AcceptClientsAsync(ServerContext context, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var client = await context.Listener.AcceptTcpClientAsync(cancellationToken);
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ClientAccepted").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ClientAccepted");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["ImapLogMessages:ClientAccepted"],
+ client.Client.RemoteEndPoint);
+
+ var session = new ImapSession(client, configuration, context.Security, authService, mailboxService, metrics, sessionLogger);
+ _ = session.ProcessAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ListenerStopped").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ListenerStopped");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["ImapLogMessages:ListenerStopped"],
+ context.Listener.LocalEndpoint);
+ break;
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ClientAcceptError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ClientAcceptError");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["ImapLogMessages:ClientAcceptError"],
+ context.Listener.LocalEndpoint);
+ }
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ if (cts != null)
+ {
+ await cts.CancelAsync();
+ }
+ foreach (var context in listeners)
+ {
+ context.Listener.Stop();
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ListenerStopped").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ListenerStopped");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["ImapLogMessages:ListenerStopped"],
+ context.Listener.LocalEndpoint);
+ context.Listener.Dispose();
+ }
+ listeners.Clear();
+ }
+}
diff --git a/MailSharp.IMAP/Services/ImapService.cs b/MailSharp.IMAP/Services/ImapService.cs
new file mode 100644
index 0000000..3b0f74c
--- /dev/null
+++ b/MailSharp.IMAP/Services/ImapService.cs
@@ -0,0 +1,69 @@
+using MailSharp.Common;
+using MailSharp.Common.Services;
+using MailSharp.IMAP.Metrics;
+using MailSharp.IMAP.Server;
+using MailSharp.IMAP.Session;
+
+namespace MailSharp.IMAP.Services;
+
+public class ImapService : BackgroundService
+{
+ private readonly ImapServer server;
+ private readonly IConfiguration configuration;
+ private readonly ImapMetrics metrics;
+ private readonly ILogger<ImapService> logger;
+
+ public ImapService(
+ IConfiguration configuration,
+ ILogger<ImapService> logger,
+ ILogger<ImapServer> serverLogger,
+ ILogger<ImapSession> sessionLogger,
+ AuthenticationService authService,
+ MailboxService mailboxService,
+ ImapMetrics metrics)
+ {
+ this.configuration = configuration;
+ this.metrics = metrics;
+ this.logger = logger;
+ this.server = new ImapServer(configuration, serverLogger, sessionLogger, authService, mailboxService, metrics);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ metrics.IsRunning = true;
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ServiceStarting").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ServiceStarting");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["ImapLogMessages:ServiceStarting"]);
+
+ await server.StartAsync();
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ServiceError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ServiceError");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["ImapLogMessages:ServiceError"]);
+ metrics.IsRunning = false;
+ throw;
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("ImapEventIds:ServiceStopping").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:ServiceStopping");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["ImapLogMessages:ServiceStopping"]);
+ metrics.IsRunning = false;
+ await server.StopAsync();
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/MailSharp.IMAP/Session/ImapSession.cs b/MailSharp.IMAP/Session/ImapSession.cs
new file mode 100644
index 0000000..ac5fd4f
--- /dev/null
+++ b/MailSharp.IMAP/Session/ImapSession.cs
@@ -0,0 +1,306 @@
+using MailSharp.Common;
+using MailSharp.Common.Services;
+using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Net.Security;
+using System.Text;
+using MailSharp.IMAP.Metrics;
+
+namespace MailSharp.IMAP.Session;
+
+public class ImapSession(
+ TcpClient client,
+ IConfiguration configuration,
+ SecurityEnum security,
+ AuthenticationService authService,
+ MailboxService mailboxService,
+ ImapMetrics metrics,
+ ILogger<ImapSession> logger)
+{
+ private Stream? stream;
+ private string? selectedFolder;
+
+ public async Task ProcessAsync(CancellationToken cancellationToken)
+ {
+ metrics.IncrementConnections();
+ metrics.IncrementActive();
+ try
+ {
+ stream = security == SecurityEnum.Tls ? await InitializeTlsStreamAsync() : client.GetStream();
+ await SendResponseAsync("* OK IMAP4rev1 server ready", cancellationToken);
+ bool isAuthenticated = false;
+ string? username = null;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ string? command = await ReadCommandAsync(cancellationToken);
+ if (string.IsNullOrEmpty(command))
+ {
+ break;
+ }
+ var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2)
+ {
+ await SendResponseAsync($"{parts[0]} BAD Invalid command", cancellationToken);
+ continue;
+ }
+ string tag = parts[0];
+ string cmd = parts[1].ToUpper();
+ metrics.IncrementCommands();
+ switch (cmd)
+ {
+ case "LOGIN":
+ if (parts.Length != 4)
+ {
+ await SendResponseAsync($"{tag} BAD LOGIN requires username and password", cancellationToken);
+ continue;
+ }
+ username = parts[2];
+ isAuthenticated = await authService.AuthenticateAsync(username, parts[3], cancellationToken);
+ if (isAuthenticated)
+ metrics.LoginSucceeded(username);
+ else
+ metrics.IncrementLoginFailed();
+ await SendResponseAsync(
+ isAuthenticated ? $"{tag} OK LOGIN completed" : $"{tag} NO LOGIN failed",
+ cancellationToken);
+ break;
+ case "LIST":
+ if (!isAuthenticated)
+ {
+ await SendResponseAsync($"{tag} NO Authentication required", cancellationToken);
+ continue;
+ }
+ var folders = await mailboxService.ListFoldersAsync(username!, cancellationToken);
+ foreach (var folder1 in folders)
+ {
+ await SendResponseAsync($"* LIST (\\NoSelect) \"/\" \"{folder1}\"", cancellationToken);
+ }
+ await SendResponseAsync($"{tag} OK LIST completed", cancellationToken);
+ break;
+ case "SELECT":
+ if (!isAuthenticated)
+ {
+ await SendResponseAsync($"{tag} NO Authentication required", cancellationToken);
+ continue;
+ }
+ metrics.IncrementFolderSelects();
+ if (parts.Length != 3)
+ {
+ await SendResponseAsync($"{tag} BAD SELECT requires folder name", cancellationToken);
+ continue;
+ }
+ string folder = parts[2].Trim('"');
+ var folderExists = (await mailboxService.ListFoldersAsync(username!, cancellationToken)).Contains(folder);
+ if (!folderExists)
+ {
+ await SendResponseAsync($"{tag} NO Folder does not exist", cancellationToken);
+ continue;
+ }
+ selectedFolder = folder;
+ var messages = await mailboxService.GetMessagesAsync(username!, folder, cancellationToken);
+ await SendResponseAsync($"* {messages.Count} EXISTS", cancellationToken);
+ await SendResponseAsync($"{tag} OK SELECT completed", cancellationToken);
+ break;
+ case "CREATE":
+ if (!isAuthenticated)
+ {
+ await SendResponseAsync($"{tag} NO Authentication required", cancellationToken);
+ continue;
+ }
+ if (parts.Length != 3)
+ {
+ await SendResponseAsync($"{tag} BAD CREATE requires folder name", cancellationToken);
+ continue;
+ }
+ string newFolder = parts[2].Trim('"');
+ bool created = await mailboxService.CreateFolderAsync(username!, newFolder, cancellationToken);
+ await SendResponseAsync(
+ created ? $"{tag} OK CREATE completed" : $"{tag} NO Folder creation failed",
+ cancellationToken);
+ break;
+ case "DELETE":
+ if (!isAuthenticated)
+ {
+ await SendResponseAsync($"{tag} NO Authentication required", cancellationToken);
+ continue;
+ }
+ if (parts.Length != 3)
+ {
+ await SendResponseAsync($"{tag} BAD DELETE requires folder name", cancellationToken);
+ continue;
+ }
+ string deleteFolder = parts[2].Trim('"');
+ if (deleteFolder == "INBOX")
+ {
+ await SendResponseAsync($"{tag} NO Cannot delete INBOX", cancellationToken);
+ continue;
+ }
+ bool deleted = await mailboxService.DeleteFolderAsync(username!, deleteFolder, cancellationToken);
+ await SendResponseAsync(
+ deleted ? $"{tag} OK DELETE completed" : $"{tag} NO Folder deletion failed",
+ cancellationToken);
+ break;
+ case "FETCH":
+ if (!isAuthenticated || selectedFolder == null)
+ {
+ await SendResponseAsync($"{tag} NO Authentication or folder selection required", cancellationToken);
+ continue;
+ }
+ if (parts.Length < 4)
+ {
+ await SendResponseAsync($"{tag} BAD FETCH requires message sequence and items", cancellationToken);
+ continue;
+ }
+ string sequence = parts[2];
+ string items = parts[3].ToUpper();
+ var fetchMessages = await mailboxService.GetMessagesAsync(username!, selectedFolder, cancellationToken);
+ int seqNum;
+ if (!int.TryParse(sequence, out seqNum) || seqNum < 1 || seqNum > fetchMessages.Count)
+ {
+ await SendResponseAsync($"{tag} NO Invalid message sequence", cancellationToken);
+ continue;
+ }
+ var message = fetchMessages[seqNum - 1];
+ if (items.Contains("FLAGS"))
+ {
+ string flags = $"FLAGS (\\{(message.Flags.Seen ? "Seen" : "")} \\{(message.Flags.Deleted ? "Deleted" : "")} \\{(message.Flags.Flagged ? "Flagged" : "")} \\{(message.Flags.Answered ? "Answered" : "")})";
+ await SendResponseAsync($"* {seqNum} FETCH ({flags})", cancellationToken);
+ }
+ if (items.Contains("BODY[]"))
+ {
+ string? content = await mailboxService.GetMessageContentAsync(username!, selectedFolder, message.Uid, cancellationToken);
+ if (content != null)
+ {
+ await SendResponseAsync($"* {seqNum} FETCH (BODY[] {{{content.Length}}}", cancellationToken);
+ await SendResponseAsync(content, cancellationToken);
+ await SendResponseAsync(")", cancellationToken);
+ metrics.MessageFetched(content.Length);
+ }
+ }
+ await SendResponseAsync($"{tag} OK FETCH completed", cancellationToken);
+ break;
+ case "STORE":
+ if (!isAuthenticated || selectedFolder == null)
+ {
+ await SendResponseAsync($"{tag} NO Authentication or folder selection required", cancellationToken);
+ continue;
+ }
+ if (parts.Length < 5)
+ {
+ await SendResponseAsync($"{tag} BAD STORE requires message sequence, operation, and flags", cancellationToken);
+ continue;
+ }
+ string storeSequence = parts[2];
+ string operation = parts[3].ToUpper();
+ string flagList = parts[4].Trim('(', ')');
+ if (!int.TryParse(storeSequence, out int storeSeqNum) || storeSeqNum < 1 || storeSeqNum > (await mailboxService.GetMessagesAsync(username!, selectedFolder, cancellationToken)).Count)
+ {
+ await SendResponseAsync($"{tag} NO Invalid message sequence", cancellationToken);
+ continue;
+ }
+ var storeMessage = (await mailboxService.GetMessagesAsync(username!, selectedFolder, cancellationToken))[storeSeqNum - 1];
+ var newFlags = new MessageFlags();
+ foreach (string flag in flagList.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (flag == "\\Seen") newFlags.Seen = true;
+ if (flag == "\\Deleted") newFlags.Deleted = true;
+ if (flag == "\\Flagged") newFlags.Flagged = true;
+ if (flag == "\\Answered") newFlags.Answered = true;
+ }
+ bool updated = await mailboxService.SetMessageFlagsAsync(username!, selectedFolder, storeMessage.Uid, newFlags, cancellationToken);
+ await SendResponseAsync(
+ updated ? $"{tag} OK STORE completed" : $"{tag} NO STORE failed",
+ cancellationToken);
+ break;
+ case "LOGOUT":
+ await SendResponseAsync("* BYE IMAP4rev1 server logging out", cancellationToken);
+ await SendResponseAsync($"{tag} OK LOGOUT completed", cancellationToken);
+ return;
+ case "STARTTLS":
+ if (security != SecurityEnum.StartTls)
+ {
+ await SendResponseAsync($"{tag} NO STARTTLS not supported", cancellationToken);
+ continue;
+ }
+ await SendResponseAsync($"{tag} OK Begin TLS negotiation", cancellationToken);
+ stream = await InitializeTlsStreamAsync();
+ break;
+ default:
+ await SendResponseAsync($"{tag} BAD Unknown command", cancellationToken);
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("ImapEventIds:SessionError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:SessionError");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["ImapLogMessages:SessionError"],
+ client.Client.RemoteEndPoint);
+ }
+ finally
+ {
+ metrics.DecrementActive();
+ stream?.Dispose();
+ client.Close();
+ }
+ }
+
+ // Initialize TLS stream for IMAP session
+ private async Task<Stream> InitializeTlsStreamAsync()
+ {
+ try
+ {
+ string certPath = configuration["ImapSettings:CertificatePath"] ?? throw new InvalidOperationException("CertificatePath not configured");
+ string certPassword = configuration["ImapSettings:CertificatePassword"] ?? string.Empty;
+ X509Certificate2 certificate = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
+ SslStream sslStream = new(client.GetStream(), false);
+ await sslStream.AuthenticateAsServerAsync(certificate, false, System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13, false);
+
+ var eventIdConfig = configuration.GetSection("ImapEventIds:TlsInitialized").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:TlsInitialized");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["ImapLogMessages:TlsInitialized"],
+ client.Client.RemoteEndPoint);
+
+ return sslStream;
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("ImapEventIds:TlsInitializationFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing ImapEventIds:TlsInitializationFailed");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["ImapLogMessages:TlsInitializationFailed"],
+ client.Client.RemoteEndPoint);
+ throw;
+ }
+ }
+
+ private async Task<string?> ReadCommandAsync(CancellationToken cancellationToken)
+ {
+ if (stream == null)
+ {
+ return null;
+ }
+ byte[] buffer = new byte[1024];
+ int bytesRead = await stream.ReadAsync(buffer, cancellationToken);
+ return Encoding.ASCII.GetString(buffer, 0, bytesRead).Trim();
+ }
+
+ private async Task SendResponseAsync(string response, CancellationToken cancellationToken)
+ {
+ if (stream == null)
+ {
+ return;
+ }
+ byte[] data = Encoding.ASCII.GetBytes(response + "\r\n");
+ await stream.WriteAsync(data, cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+ }
+}
diff --git a/MailSharp.MailClient/Controllers/AccountController.cs b/MailSharp.MailClient/Controllers/AccountController.cs
new file mode 100644
index 0000000..6a128cc
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/AccountController.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.MailClient.Controllers;
+
+// Serves only the page shell - all data (login defaults, translations, the actual
+// authentication call) is fetched client-side from Controllers/Api via login.js.
+// Must stay reachable while logged out (this IS the login page), and since MapControllers()
+// and MapControllerRoute() share the same endpoint data source, RequireAuthorization() on the
+// API routes would otherwise leak onto this conventionally-routed controller too.
+[AllowAnonymous]
+public class AccountController : Controller
+{
+ [HttpGet]
+ public IActionResult Login() => View();
+
+ // Cookie auth's default AccessDeniedPath ("/Account/AccessDenied") needs a real action to land
+ // on - without one this 404s instead of explaining anything. Most common cause: the account was
+ // just promoted to admin (see Program.cs auto-promote, or the claim-admin bootstrap endpoint)
+ // but the current session's cookie predates that and still lacks the admin claim - re-issued
+ // only on next login/account-switch (see SessionAccountManager.SignInAsync).
+ [HttpGet]
+ public IActionResult AccessDenied() => View();
+}
diff --git a/MailSharp.MailClient/Controllers/Api/AuthApiController.cs b/MailSharp.MailClient/Controllers/Api/AuthApiController.cs
new file mode 100644
index 0000000..dc03f95
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/AuthApiController.cs
@@ -0,0 +1,157 @@
+using MailSharp.MailClient.Models;
+using MailSharp.MailClient.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+[ApiController]
+[Route("api/auth")]
+public class AuthApiController(
+ IAccountStore accountStore,
+ SessionAccountManager sessionManager,
+ IImapService imapService,
+ IOptions<MailSettings> mailSettings,
+ LocalizationService localizer,
+ ILogger<AuthApiController> logger) : ControllerBase
+{
+ private readonly MailSettings _settings = mailSettings.Value;
+
+ // Must stay reachable while logged out - the login page calls this first to decide whether
+ // to show the login form or redirect straight to the mail app.
+ [AllowAnonymous]
+ [HttpGet("state")]
+ public IActionResult State()
+ {
+ var accounts = sessionManager.GetLoggedInAccountIds()
+ .Select(id => accountStore.Get(id))
+ .Where(a => a != null)
+ .Select(a => AccountDto.From(a!))
+ .ToList();
+
+ var activeId = sessionManager.GetActiveAccountId();
+ var active = activeId != null ? accountStore.Get(activeId.Value) : null;
+
+ return Ok(new AuthStateDto
+ {
+ IsLoggedIn = sessionManager.IsLoggedIn,
+ Account = active != null ? AccountDto.From(active) : null,
+ Accounts = accounts
+ });
+ }
+
+ [AllowAnonymous]
+ [HttpGet("login-defaults")]
+ public IActionResult LoginDefaults() => Ok(new LoginDefaultsDto
+ {
+ ImapHost = _settings.DefaultImapServer,
+ ImapPort = _settings.DefaultImapPort,
+ ImapSecurity = _settings.DefaultImapSecurity,
+ SmtpHost = _settings.DefaultSmtpServer,
+ SmtpPort = _settings.DefaultSmtpPort,
+ SmtpSecurity = _settings.DefaultSmtpSecurity
+ });
+
+ // The login call itself must obviously work without already being authenticated.
+ [AllowAnonymous]
+ [HttpPost("login")]
+ public async Task<IActionResult> Login(LoginRequest model)
+ {
+ if (string.IsNullOrWhiteSpace(model.EmailAddress) || string.IsNullOrWhiteSpace(model.Password))
+ return BadRequest(new { error = localizer["login_error_email_password_required"] });
+
+ var username = string.IsNullOrWhiteSpace(model.Username) ? model.EmailAddress : model.Username;
+
+ Account account;
+ if (model.Advanced)
+ {
+ if (string.IsNullOrWhiteSpace(model.ImapHost) || string.IsNullOrWhiteSpace(model.SmtpHost))
+ return BadRequest(new { error = localizer["login_error_servers_required"] });
+
+ account = new Account
+ {
+ DisplayName = model.EmailAddress,
+ EmailAddress = model.EmailAddress,
+ Username = username,
+ ImapHost = model.ImapHost,
+ ImapPort = model.ImapPort,
+ ImapSecurity = model.ImapSecurity,
+ SmtpHost = model.SmtpHost,
+ SmtpPort = model.SmtpPort,
+ SmtpSecurity = model.SmtpSecurity
+ };
+ }
+ else
+ {
+ var domain = model.EmailAddress.Split('@').Last();
+ account = new Account
+ {
+ DisplayName = model.EmailAddress,
+ EmailAddress = model.EmailAddress,
+ Username = username,
+ ImapHost = $"imap.{domain}",
+ ImapPort = _settings.DefaultImapPort,
+ ImapSecurity = _settings.DefaultImapSecurity,
+ SmtpHost = $"smtp.{domain}",
+ SmtpPort = _settings.DefaultSmtpPort,
+ SmtpSecurity = _settings.DefaultSmtpSecurity
+ };
+ }
+
+ // New accounts start with the app-wide defaults (Settings shows real numbers immediately,
+ // not blank inputs) rather than 0 - see also SettingsApiController.Get, which applies the
+ // same fallback for accounts created before these settings existed.
+ account.MessagesPerPage = _settings.MessageListPageSize;
+ account.ContactsPerPage = _settings.ContactMailsPageSize;
+
+ try
+ {
+ await imapService.GetFoldersAsync(account, model.Password);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Login connectivity check failed for {EmailAddress} against {ImapHost}", account.EmailAddress, account.ImapHost);
+ return BadRequest(new { error = localizer.Format("login_error_connect_failed", ex.Message) });
+ }
+
+ var existing = accountStore.GetAll().FirstOrDefault(a =>
+ a.EmailAddress.Equals(account.EmailAddress, StringComparison.OrdinalIgnoreCase) &&
+ a.ImapHost.Equals(account.ImapHost, StringComparison.OrdinalIgnoreCase));
+
+ // The very first account ever created on a fresh install becomes admin automatically, so
+ // there's always someone who can reach the Maintenance page without a separate setup step.
+ if (existing == null && accountStore.GetAll().Count == 0)
+ {
+ account.IsAdmin = true;
+ }
+
+ var isNewAccount = existing == null;
+ existing ??= accountStore.Add(account, model.Password);
+
+ await sessionManager.AddOrUpdateAsync(existing.Id);
+ logger.LogInformation("Account {AccountId} ({EmailAddress}) logged in{NewAccount}", existing.Id, existing.EmailAddress, isNewAccount ? " (new account, admin=" + existing.IsAdmin + ")" : "");
+ return Ok(AccountDto.From(existing));
+ }
+
+ [HttpPost("switch")]
+ public async Task<IActionResult> Switch(SwitchAccountRequest request)
+ {
+ await sessionManager.SetActiveAccountIdAsync(request.AccountId);
+ return Ok();
+ }
+
+ [HttpPost("remove")]
+ public async Task<IActionResult> Remove(RemoveAccountRequest request)
+ {
+ await sessionManager.RemoveAsync(request.AccountId);
+ return Ok(new { isLoggedIn = sessionManager.IsLoggedIn });
+ }
+
+ [HttpPost("logout")]
+ public async Task<IActionResult> Logout()
+ {
+ await sessionManager.LogoutAsync();
+ return Ok();
+ }
+}
diff --git a/MailSharp.MailClient/Controllers/Api/ContactsApiController.cs b/MailSharp.MailClient/Controllers/Api/ContactsApiController.cs
new file mode 100644
index 0000000..e7cf275
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/ContactsApiController.cs
@@ -0,0 +1,66 @@
+using MailSharp.MailClient.Models;
+using MailSharp.MailClient.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+[ApiController]
+[Route("api/contacts")]
+public class ContactsApiController(
+ IAccountStore accountStore,
+ SessionAccountManager sessionManager,
+ IImapService imapService,
+ IContactStore contactStore) : ControllerBase
+{
+ private (Account account, string password)? GetActive()
+ {
+ var id = sessionManager.GetActiveAccountId();
+ if (id == null) return null;
+ var account = accountStore.Get(id.Value);
+ var password = sessionManager.GetPassword(id.Value);
+ if (account == null || password == null) return null;
+ return (account, password);
+ }
+
+ [HttpGet]
+ public async Task<IActionResult> List(CancellationToken ct)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+
+ var sentContacts = contactStore.GetCachedContacts(account.Id);
+ if (sentContacts == null)
+ {
+ sentContacts = await imapService.GetSentContactsAsync(account, password, ct);
+ contactStore.SaveContacts(account.Id, sentContacts);
+ }
+ var result = sentContacts.Select(c => new ContactDto
+ {
+ Email = c.Email,
+ DisplayName = contactStore.GetDisplayName(account.Id, c.Email) ?? (string.IsNullOrWhiteSpace(c.Name) ? c.Email : c.Name)
+ }).OrderBy(c => c.DisplayName).ToList();
+
+ return Ok(result);
+ }
+
+ [HttpPost("update")]
+ public IActionResult Update(UpdateContactRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ if (string.IsNullOrWhiteSpace(request.Email)) return BadRequest();
+
+ contactStore.SetDisplayName(active.Value.account.Id, request.Email, request.DisplayName);
+ return Ok();
+ }
+
+ [HttpGet("mails")]
+ public async Task<IActionResult> Mails(string email, bool refresh = false, CancellationToken ct = default)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ return Ok(await imapService.SearchByAddressAsync(account, password, email, refresh, ct));
+ }
+}
diff --git a/MailSharp.MailClient/Controllers/Api/Dtos.cs b/MailSharp.MailClient/Controllers/Api/Dtos.cs
new file mode 100644
index 0000000..137ca06
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/Dtos.cs
@@ -0,0 +1,171 @@
+using MailSharp.MailClient.Models;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+public class AccountDto
+{
+ public int Id { get; set; }
+ public string DisplayName { get; set; } = "";
+ public string EmailAddress { get; set; } = "";
+ public string Signature { get; set; } = "";
+ public int MessagesPerPage { get; set; }
+ public int ContactsPerPage { get; set; }
+ public int AutoCheckIntervalMinutes { get; set; }
+ public string TimeZoneId { get; set; } = "";
+ public bool IsAdmin { get; set; }
+
+ public static AccountDto From(Account a) => new()
+ {
+ Id = a.Id,
+ DisplayName = a.DisplayName,
+ EmailAddress = a.EmailAddress,
+ Signature = a.Signature,
+ MessagesPerPage = a.MessagesPerPage,
+ ContactsPerPage = a.ContactsPerPage,
+ AutoCheckIntervalMinutes = a.AutoCheckIntervalMinutes,
+ TimeZoneId = a.TimeZoneId,
+ IsAdmin = a.IsAdmin
+ };
+}
+
+public class AuthStateDto
+{
+ public bool IsLoggedIn { get; set; }
+ public AccountDto? Account { get; set; }
+ public List<AccountDto> Accounts { get; set; } = [];
+}
+
+public class LoginDefaultsDto
+{
+ public string ImapHost { get; set; } = string.Empty;
+ public int ImapPort { get; set; }
+ public SecurityMode ImapSecurity { get; set; }
+ public string SmtpHost { get; set; } = string.Empty;
+ public int SmtpPort { get; set; }
+ public SecurityMode SmtpSecurity { get; set; }
+}
+
+public class LoginRequest
+{
+ public string EmailAddress { get; set; } = "";
+ public string Username { get; set; } = "";
+ public string Password { get; set; } = "";
+ public bool Advanced { get; set; }
+
+ public string ImapHost { get; set; } = "";
+ public int ImapPort { get; set; }
+ public SecurityMode ImapSecurity { get; set; }
+
+ public string SmtpHost { get; set; } = "";
+ public int SmtpPort { get; set; }
+ public SecurityMode SmtpSecurity { get; set; }
+}
+
+public class SwitchAccountRequest { public int AccountId { get; set; } }
+public class RemoveAccountRequest { public int AccountId { get; set; } }
+
+public class MessagesResponse
+{
+ public List<MessageListItem> Messages { get; set; } = [];
+ public int Page { get; set; }
+ public int PageSize { get; set; }
+ public int TotalCount { get; set; }
+ public int TotalPages { get; set; }
+}
+
+public class AllowImagesRequest
+{
+ public string Folder { get; set; } = "";
+ public uint Uid { get; set; }
+ public string Sender { get; set; } = "";
+}
+
+public class LanguageOptionDto
+{
+ public string Code { get; set; } = "";
+ public string Name { get; set; } = "";
+}
+
+public class LanguageStateDto
+{
+ public string Current { get; set; } = "";
+ public List<LanguageOptionDto> Languages { get; set; } = [];
+ public Dictionary<string, string> Strings { get; set; } = [];
+}
+
+public class SetLanguageRequest { public string Lang { get; set; } = ""; }
+
+public class UidsRequest
+{
+ public string Folder { get; set; } = "";
+ public List<uint> Uids { get; set; } = [];
+}
+
+public class MoveRequest
+{
+ public string Folder { get; set; } = "";
+ public List<uint> Uids { get; set; } = [];
+ public string TargetFolder { get; set; } = "";
+}
+
+public class FolderRequest
+{
+ public string Folder { get; set; } = "";
+}
+
+public class ContactDto
+{
+ public string Email { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+}
+
+public class UpdateContactRequest
+{
+ public string Email { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+}
+
+public class AccountSettingsDto
+{
+ public string FriendlyName { get; set; } = "";
+ public string Signature { get; set; } = "";
+ public int MessagesPerPage { get; set; }
+ public int ContactsPerPage { get; set; }
+ public int AutoCheckIntervalMinutes { get; set; }
+ public string TimeZoneId { get; set; } = "";
+ public string Language { get; set; } = "";
+
+ public static AccountSettingsDto From(Account a) => new()
+ {
+ FriendlyName = a.DisplayName,
+ Signature = a.Signature,
+ MessagesPerPage = a.MessagesPerPage,
+ ContactsPerPage = a.ContactsPerPage,
+ AutoCheckIntervalMinutes = a.AutoCheckIntervalMinutes,
+ TimeZoneId = a.TimeZoneId,
+ Language = a.Language
+ };
+}
+
+public class SettingsStateDto
+{
+ public AccountSettingsDto Settings { get; set; } = new();
+ public List<string> TimeZones { get; set; } = [];
+ public List<LanguageOptionDto> Languages { get; set; } = [];
+}
+
+public class FolderSettingItem
+{
+ public string FullName { get; set; } = "";
+ public FolderSyncMode SyncMode { get; set; }
+ public bool Subscribed { get; set; }
+}
+
+public class SaveFolderSettingsRequest
+{
+ public List<FolderSettingItem> Folders { get; set; } = [];
+}
+
+public class AddFolderRequest { public string Name { get; set; } = ""; }
+
+public class DeleteFoldersRequest { public List<string> FolderNames { get; set; } = []; }
diff --git a/MailSharp.MailClient/Controllers/Api/ImageCacheController.cs b/MailSharp.MailClient/Controllers/Api/ImageCacheController.cs
new file mode 100644
index 0000000..cccefcb
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/ImageCacheController.cs
@@ -0,0 +1,84 @@
+using MailSharp.MailClient.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.StaticFiles;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+// Serves cached external images referenced by allowed-sender messages, at
+// /cache/images/{emailAddress}/{messageId}/{fileName} (fileName is "01.jpg" etc., see
+// ImageSanitizer) so it's obvious on disk which images belong to which message. On the first
+// request for a given file it doesn't exist yet: the handler looks up the original URL,
+// downloads it now, saves it to disk, and serves it - every later request for that same file is
+// then a plain disk read. Anonymous on purpose: the <img> lives inside a sandboxed srcdoc iframe
+// (opaque origin), so the auth cookie would not reliably be sent for this subresource anyway -
+// and a cached image is just already-public picture content, not account data, so serving it
+// without a session is a low-risk trade-off.
+[AllowAnonymous]
+[ApiController]
+public class ImageCacheController(IMailCacheStore cacheStore, IHttpClientFactory httpClientFactory) : ControllerBase
+{
+ private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new();
+
+ [HttpGet("/cache/images/{emailAddress}/{messageId}/{fileName}")]
+ public async Task<IActionResult> GetImage(string emailAddress, string messageId, string fileName)
+ {
+ var existingPath = cacheStore.GetCachedImagePath(emailAddress, messageId, fileName);
+ if (existingPath != null)
+ {
+ return PhysicalFile(existingPath, ResolveContentType(existingPath));
+ }
+
+ var url = cacheStore.GetImageSourceUrl(emailAddress, messageId, fileName);
+ if (url == null) return NotFound();
+
+ try
+ {
+ var client = httpClientFactory.CreateClient("ImageDownload");
+ using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
+ if (!response.IsSuccessStatusCode) return NotFound();
+
+ var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
+ if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return NotFound();
+
+ var bytes = await response.Content.ReadAsByteArrayAsync();
+ if (bytes.Length == 0 || bytes.Length > 10_000_000) return NotFound();
+
+ // The source URL had no extension (e.g. "/picture" with no ".jpg"): fall back to
+ // guessing one from the response's actual Content-Type so the cached file isn't left
+ // extensionless on disk.
+ var finalFileName = EnsureExtension(fileName, contentType);
+ var savedPath = cacheStore.SaveCachedImageFile(emailAddress, messageId, finalFileName, bytes);
+ return PhysicalFile(savedPath, contentType);
+ }
+ catch
+ {
+ return NotFound();
+ }
+ }
+
+ private static string EnsureExtension(string fileName, string contentType)
+ {
+ if (fileName.Contains('.')) return fileName;
+
+ var extension = contentType.ToLowerInvariant() switch
+ {
+ "image/jpeg" or "image/jpg" => ".jpg",
+ "image/png" => ".png",
+ "image/gif" => ".gif",
+ "image/webp" => ".webp",
+ "image/bmp" => ".bmp",
+ "image/svg+xml" => ".svg",
+ "image/tiff" => ".tiff",
+ "image/x-icon" or "image/vnd.microsoft.icon" => ".ico",
+ "image/avif" => ".avif",
+ "image/heic" => ".heic",
+ _ => ""
+ };
+
+ return fileName + extension;
+ }
+
+ private static string ResolveContentType(string path) =>
+ ContentTypeProvider.TryGetContentType(path, out var contentType) ? contentType : "application/octet-stream";
+}
diff --git a/MailSharp.MailClient/Controllers/Api/LanguageApiController.cs b/MailSharp.MailClient/Controllers/Api/LanguageApiController.cs
new file mode 100644
index 0000000..827e79b
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/LanguageApiController.cs
@@ -0,0 +1,39 @@
+using MailSharp.MailClient.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+// Translations must be available on the (logged-out) login page too, so this whole
+// controller stays anonymous-accessible even though most other API controllers now require login.
+[AllowAnonymous]
+[ApiController]
+[Route("api/language")]
+public class LanguageApiController(LocalizationService localizer) : ControllerBase
+{
+ [HttpGet("state")]
+ public IActionResult State() => Ok(BuildState(localizer.CurrentLanguage));
+
+ [HttpPost("set")]
+ public IActionResult Set(SetLanguageRequest request)
+ {
+ if (!localizer.LanguageExists(request.Lang)) return BadRequest();
+
+ Response.Cookies.Append("lang", request.Lang, new CookieOptions
+ {
+ Expires = DateTimeOffset.UtcNow.AddYears(1),
+ IsEssential = true,
+ SameSite = SameSiteMode.Lax,
+ HttpOnly = true
+ });
+
+ return Ok(BuildState(request.Lang));
+ }
+
+ private LanguageStateDto BuildState(string lang) => new()
+ {
+ Current = lang,
+ Languages = [.. localizer.AvailableLanguages().Select(l => new LanguageOptionDto { Code = l.Code, Name = l.Name })],
+ Strings = localizer.GetAllStrings(lang)
+ };
+}
diff --git a/MailSharp.MailClient/Controllers/Api/MailApiController.cs b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
new file mode 100644
index 0000000..df28c81
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
@@ -0,0 +1,319 @@
+using MailSharp.MailClient.Models;
+using MailSharp.MailClient.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.Options;
+using MimeKit;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+[ApiController]
+[Route("api/mail")]
+public class MailApiController(
+ IAccountStore accountStore,
+ SessionAccountManager sessionManager,
+ IImapService imapService,
+ ISmtpService smtpService,
+ IMailCacheStore mailCacheStore,
+ IContactStore contactStore,
+ IOptions<MailSettings> mailSettings,
+ LocalizationService localizer,
+ ILogger<MailApiController> logger) : ControllerBase
+{
+ private readonly MailSettings _settings = mailSettings.Value;
+ private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new();
+
+ private (Account account, string password)? GetActive()
+ {
+ var id = sessionManager.GetActiveAccountId();
+ if (id == null) return null;
+ var account = accountStore.Get(id.Value);
+ var password = sessionManager.GetPassword(id.Value);
+ if (account == null || password == null) return null;
+ return (account, password);
+ }
+
+ [HttpGet("folders")]
+ public async Task<IActionResult> Folders(CancellationToken ct)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ return Ok(await imapService.GetFoldersAsync(account, password, ct: ct));
+ }
+
+ [HttpGet("messages")]
+ public async Task<IActionResult> Messages(string folder = "INBOX", string sort = "date", bool desc = true, int page = 1, bool refresh = false, CancellationToken ct = default)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+
+ var pageSize = account.MessagesPerPage > 0 ? account.MessagesPerPage : _settings.MessageListPageSize;
+ var messages = await imapService.GetMessagesAsync(account, password, folder, refresh, ct);
+
+ messages = sort switch
+ {
+ "subject" => desc ? [.. messages.OrderByDescending(m => m.Subject)] : [.. messages.OrderBy(m => m.Subject)],
+ "from" => desc ? [.. messages.OrderByDescending(m => m.From)] : [.. messages.OrderBy(m => m.From)],
+ "size" => desc ? [.. messages.OrderByDescending(m => m.SizeBytes)] : [.. messages.OrderBy(m => m.SizeBytes)],
+ _ => desc ? [.. messages.OrderByDescending(m => m.Date)] : [.. messages.OrderBy(m => m.Date)],
+ };
+
+ var totalCount = messages.Count;
+ var totalPages = Math.Max(1, (int)Math.Ceiling(totalCount / (double)pageSize));
+ page = Math.Min(Math.Max(1, page), totalPages);
+
+ return Ok(new MessagesResponse
+ {
+ Messages = [.. messages.Skip((page - 1) * pageSize).Take(pageSize)],
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = totalCount,
+ TotalPages = totalPages
+ });
+ }
+
+ [HttpGet("messages/{uid}")]
+ public async Task<IActionResult> Message(uint uid, [FromQuery] string folder = "INBOX", CancellationToken ct = default)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ return Ok(await imapService.GetMessageAsync(account, password, folder, uid, ct));
+ }
+
+ [HttpPost("allow-images")]
+ public IActionResult AllowImages(AllowImagesRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+
+ if (!string.IsNullOrWhiteSpace(request.Sender))
+ mailCacheStore.SetAllowExternalImages(active.Value.account.Id, request.Sender, true);
+
+ return Ok();
+ }
+
+ // Fetched via a plain authenticated fetch() call, never navigated to directly - the client
+ // turns the response into a blob and opens/downloads it via a local blob: URL, so this
+ // address is never shown in an address bar, new tab, or browser history.
+ [HttpGet("attachments/{uid}/{part}")]
+ public async Task<IActionResult> Attachment(uint uid, int part, [FromQuery] string folder = "INBOX", CancellationToken ct = default)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+
+ var detail = await imapService.GetMessageAsync(account, password, folder, uid, ct);
+ var info = detail.Attachments.FirstOrDefault(a => a.PartIndex == part);
+ var fileName = info?.FileName ?? "attachment";
+ var contentType = ResolveContentType(info?.ContentType, fileName);
+ var stream = await imapService.GetAttachmentAsync(account, password, folder, uid, part, ct);
+
+ return File(stream, contentType);
+ }
+
+ // Prefers the type implied by the file's own extension over the Content-Type the sender's
+ // mail client declared for the MIME part: senders frequently stamp every attachment with a
+ // generic/wrong type (e.g. application/octet-stream, or a stale type left over from a
+ // forward), while the extension in the actual filename is what the user - and the browser's
+ // viewer - can see and trust.
+ private static string ResolveContentType(string? storedContentType, string fileName)
+ {
+ if (ContentTypeProvider.TryGetContentType(fileName, out var guessedFromExtension))
+ {
+ return guessedFromExtension;
+ }
+
+ return !string.IsNullOrWhiteSpace(storedContentType) ? storedContentType : "application/octet-stream";
+ }
+
+ [HttpPost("mark-read")]
+ public async Task<IActionResult> MarkRead(UidsRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.SetSeenAsync(account, password, request.Folder, request.Uids, true);
+ return Ok();
+ }
+
+ [HttpPost("mark-unread")]
+ public async Task<IActionResult> MarkUnread(UidsRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.SetSeenAsync(account, password, request.Folder, request.Uids, false);
+ return Ok();
+ }
+
+ [HttpPost("flag")]
+ public async Task<IActionResult> Flag(UidsRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.SetFlaggedAsync(account, password, request.Folder, request.Uids, true);
+ return Ok();
+ }
+
+ [HttpPost("unflag")]
+ public async Task<IActionResult> Unflag(UidsRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.SetFlaggedAsync(account, password, request.Folder, request.Uids, false);
+ return Ok();
+ }
+
+ [HttpPost("mark-all-read")]
+ public async Task<IActionResult> MarkAllRead(FolderRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.MarkAllAsync(account, password, request.Folder, true);
+ return Ok();
+ }
+
+ [HttpPost("mark-all-unread")]
+ public async Task<IActionResult> MarkAllUnread(FolderRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.MarkAllAsync(account, password, request.Folder, false);
+ return Ok();
+ }
+
+ [HttpPost("move")]
+ public async Task<IActionResult> Move(MoveRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.MoveAsync(account, password, request.Folder, request.Uids, request.TargetFolder);
+ return Ok();
+ }
+
+ [HttpPost("delete")]
+ public async Task<IActionResult> Delete(UidsRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.DeleteAsync(account, password, request.Folder, request.Uids);
+ return Ok();
+ }
+
+ [HttpPost("empty-folder")]
+ public async Task<IActionResult> EmptyFolder(FolderRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ await imapService.EmptyFolderAsync(account, password, request.Folder);
+ return Ok();
+ }
+
+ [HttpPost("send")]
+ public async Task<IActionResult> Send([FromForm] ComposeModel model, [FromForm] List<IFormFile>? files)
+ {
+ var account = accountStore.Get(model.AccountId);
+ var password = sessionManager.GetPassword(model.AccountId);
+ if (account == null || password == null) return Unauthorized();
+
+ var attachments = await ReadAttachments(files);
+ var inlineImages = await ReadInlineImages();
+ MimeMessage message;
+ try
+ {
+ message = MimeMessageFactory.Build(account, model, attachments, inlineImages);
+ await smtpService.SendAsync(account, password, message, HttpContext.RequestAborted);
+ }
+ finally
+ {
+ foreach (var (_, content) in attachments) content.Dispose();
+ foreach (var (_, _, content) in inlineImages) content.Dispose();
+ }
+
+ // Best-effort: the message has already been sent, so a failure to save it to Sent (e.g. no
+ // such folder, transient IMAP error) shouldn't fail the request - but it's surfaced in the
+ // response so the UI can warn instead of silently losing the copy.
+ string? sentError = null;
+ try
+ {
+ await imapService.AppendSentAsync(account, password, message, HttpContext.RequestAborted);
+ }
+ catch (Exception ex)
+ {
+ sentError = ex.Message;
+ logger.LogWarning(ex, "Message sent for account {AccountId} but saving a copy to Sent failed", account.Id);
+ }
+
+ var recipients = message.To.Concat(message.Cc).OfType<MailboxAddress>()
+ .Select(mb => new ContactAddress { Email = mb.Address, Name = mb.Name ?? "" });
+ contactStore.AddContacts(account.Id, recipients);
+
+ return Ok(new { message = localizer["compose_sent"], sentError });
+ }
+
+ [HttpPost("save-draft")]
+ public async Task<IActionResult> SaveDraft([FromForm] ComposeModel model, [FromForm] List<IFormFile>? files)
+ {
+ var account = accountStore.Get(model.AccountId);
+ var password = sessionManager.GetPassword(model.AccountId);
+ if (account == null || password == null) return Unauthorized();
+
+ var attachments = await ReadAttachments(files);
+ var inlineImages = await ReadInlineImages();
+ try
+ {
+ await imapService.SaveDraftAsync(account, password, model, attachments, inlineImages);
+ }
+ finally
+ {
+ foreach (var (_, content) in attachments) content.Dispose();
+ foreach (var (_, _, content) in inlineImages) content.Dispose();
+ }
+
+ return Ok(new { message = localizer["compose_draft_saved"] });
+ }
+
+ private static async Task<List<(string FileName, Stream Content)>> ReadAttachments(List<IFormFile>? files)
+ {
+ var result = new List<(string, Stream)>();
+ if (files == null) return result;
+ foreach (var file in files)
+ {
+ if (file.Length == 0) continue;
+ var ms = new MemoryStream();
+ await file.CopyToAsync(ms);
+ ms.Position = 0;
+ result.Add((file.FileName, ms));
+ }
+ return result;
+ }
+
+ // Inline images (pictures inserted into the compose editor via file upload, see compose.js)
+ // arrive as regular form files, but under a "inline:<cid>" field name rather than "files" - the
+ // [FromForm] List<IFormFile> binder above only picks up fields literally named "files", so these
+ // are read directly off Request.Form.Files instead to recover the cid <-> content mapping.
+ private async Task<List<(string Cid, string FileName, Stream Content)>> ReadInlineImages()
+ {
+ var result = new List<(string, string, Stream)>();
+ foreach (var file in Request.Form.Files)
+ {
+ if (!file.Name.StartsWith("inline:", StringComparison.Ordinal) || file.Length == 0) continue;
+ var cid = file.Name["inline:".Length..];
+ var ms = new MemoryStream();
+ await file.CopyToAsync(ms);
+ ms.Position = 0;
+ result.Add((cid, file.FileName, ms));
+ }
+ return result;
+ }
+}
diff --git a/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs b/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
new file mode 100644
index 0000000..a555fb6
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
@@ -0,0 +1,140 @@
+using MailSharp.MailClient.Services;
+using MailSharp.MailClient.Services.Logging;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+[ApiController]
+[Route("api/maintenance")]
+[Authorize(Policy = "AdminOnly")]
+public class MaintenanceApiController(
+ ILogStore logStore,
+ IAccountStore accountStore,
+ IMessageIndexStore indexStore,
+ IFolderSettingsStore folderSettingsStore,
+ IMailCacheStore mailCacheStore,
+ IBackgroundIndexingStatus indexingStatus) : ControllerBase
+{
+ [HttpGet("logs")]
+ public IActionResult Logs(
+ LogLevel? level = null,
+ string? category = null,
+ int? accountId = null,
+ DateTime? from = null,
+ DateTime? to = null,
+ string? search = null,
+ int page = 1,
+ int pageSize = 50)
+ {
+ var result = logStore.Query(new LogQuery
+ {
+ Level = level,
+ Category = category,
+ AccountId = accountId,
+ From = from,
+ To = to,
+ Search = search,
+ Page = page,
+ PageSize = pageSize
+ });
+
+ return Ok(new { items = result.Items, totalCount = result.TotalCount, page, pageSize });
+ }
+
+ [HttpGet("status")]
+ public IActionResult Status()
+ {
+ // Per-account/folder index summary (accounts, indexed folders, total messages) - the same
+ // underlying data as the Metrics tab, but rolled up to totals here rather than shown
+ // per-folder, since this panel is about "is the background work healthy", not the folder
+ // browser the Metrics tab already is.
+ var accounts = accountStore.GetAll();
+ var indexSummaries = accounts.Select(account =>
+ {
+ var folders = indexStore.GetIndexedFolders(account.Id);
+ return new
+ {
+ accountId = account.Id,
+ emailAddress = account.EmailAddress,
+ indexedFolderCount = folders.Count,
+ indexedMessageCount = folders.Sum(f => indexStore.GetMessageCount(account.Id, f)),
+ indexedSizeBytes = folders.Sum(f => indexStore.GetTotalSize(account.Id, f))
+ };
+ }).ToList();
+
+ var cache = mailCacheStore.GetStats();
+
+ return Ok(new
+ {
+ indexing = new
+ {
+ maxConcurrentIndexing = indexingStatus.MaxConcurrentIndexing,
+ currentlyAvailableSlots = indexingStatus.CurrentlyAvailableSlots,
+ inProgress = indexingStatus.InProgressKeys
+ },
+ index = new
+ {
+ accounts = indexSummaries,
+ totalIndexedFolders = indexSummaries.Sum(a => a.indexedFolderCount),
+ totalIndexedMessages = indexSummaries.Sum(a => a.indexedMessageCount),
+ totalIndexedSizeBytes = indexSummaries.Sum(a => a.indexedSizeBytes)
+ },
+ cache = new
+ {
+ messageListCacheEntries = cache.MessageListCacheEntries,
+ contactMailSearchEntries = cache.ContactMailSearchEntries,
+ senderImagePreferences = cache.SenderImagePreferences,
+ imageUrlMappings = cache.ImageUrlMappings,
+ messageIdMappings = cache.MessageIdMappings,
+ cachedMessages = new { fileCount = cache.CachedMessages.FileCount, totalBytes = cache.CachedMessages.TotalBytes },
+ cachedAttachments = new { fileCount = cache.CachedAttachments.FileCount, totalBytes = cache.CachedAttachments.TotalBytes },
+ cachedImages = new { fileCount = cache.CachedImages.FileCount, totalBytes = cache.CachedImages.TotalBytes }
+ }
+ });
+ }
+
+ [HttpGet("metrics")]
+ public IActionResult Metrics(int? accountId = null)
+ {
+ var accounts = accountStore.GetAll()
+ .Where(a => accountId == null || a.Id == accountId.Value)
+ .Select(account =>
+ {
+ // Same display order the user set up in Settings > Mappen beheren (see
+ // IImapService.GetFoldersAsync/ApplyDisplayOrder) - folders the user dragged to the
+ // top there should show in that order here too, not in whatever order LiteDB happens
+ // to return them.
+ var savedOrder = folderSettingsStore.GetOrder(account.Id);
+ var position = savedOrder.Select((name, i) => (name, i)).ToDictionary(x => x.name, x => x.i, StringComparer.Ordinal);
+
+ var folders = indexStore.GetIndexedFolders(account.Id)
+ .Select((folder, naturalIndex) => (folder, naturalIndex))
+ .OrderBy(x => position.TryGetValue(x.folder, out var idx) ? idx : int.MaxValue)
+ .ThenBy(x => x.naturalIndex)
+ .Select(x => new
+ {
+ folder = x.folder,
+ messageCount = indexStore.GetMessageCount(account.Id, x.folder),
+ sizeBytes = indexStore.GetTotalSize(account.Id, x.folder)
+ }).ToList();
+
+ return new
+ {
+ accountId = account.Id,
+ emailAddress = account.EmailAddress,
+ folders,
+ totalMessages = folders.Sum(f => f.messageCount),
+ totalSizeBytes = folders.Sum(f => f.sizeBytes)
+ };
+ }).ToList();
+
+ return Ok(new
+ {
+ accounts,
+ totalMessages = accounts.Sum(a => a.totalMessages),
+ totalSizeBytes = accounts.Sum(a => a.totalSizeBytes)
+ });
+ }
+}
diff --git a/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs b/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
new file mode 100644
index 0000000..3ce8353
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
@@ -0,0 +1,164 @@
+using MailSharp.MailClient.Models;
+using MailSharp.MailClient.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace MailSharp.MailClient.Controllers.Api;
+
+[ApiController]
+[Route("api/settings")]
+public class SettingsApiController(
+ IAccountStore accountStore,
+ SessionAccountManager sessionManager,
+ IImapService imapService,
+ IFolderSettingsStore folderSettingsStore,
+ IOptions<MailSettings> mailSettings,
+ LocalizationService localizer,
+ ILogger<SettingsApiController> logger) : ControllerBase
+{
+ private readonly MailSettings _settings = mailSettings.Value;
+
+ private (Account account, string password)? GetActive()
+ {
+ var id = sessionManager.GetActiveAccountId();
+ if (id == null) return null;
+ var account = accountStore.Get(id.Value);
+ var password = sessionManager.GetPassword(id.Value);
+ if (account == null || password == null) return null;
+ return (account, password);
+ }
+
+ [HttpGet]
+ public IActionResult Get()
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+
+ var settings = AccountSettingsDto.From(active.Value.account);
+ // Accounts created before these fields existed (or otherwise still at their zero default)
+ // show the app-wide default instead of a blank input - see also AuthApiController.Login,
+ // which sets these explicitly on brand-new accounts so this fallback rarely kicks in there.
+ if (settings.MessagesPerPage <= 0) settings.MessagesPerPage = _settings.MessageListPageSize;
+ if (settings.ContactsPerPage <= 0) settings.ContactsPerPage = _settings.ContactMailsPageSize;
+
+ return Ok(new SettingsStateDto
+ {
+ Settings = settings,
+ TimeZones = [.. TimeZoneInfo.GetSystemTimeZones().Select(ToIanaId).Distinct().OrderBy(x => x, StringComparer.OrdinalIgnoreCase)],
+ Languages = [.. localizer.AvailableLanguages().Select(l => new LanguageOptionDto { Code = l.Code, Name = l.Name })]
+ });
+ }
+
+ [HttpPost]
+ public IActionResult Save(AccountSettingsDto request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var account = active.Value.account;
+
+ account.DisplayName = string.IsNullOrWhiteSpace(request.FriendlyName) ? account.EmailAddress : request.FriendlyName;
+ account.Signature = request.Signature;
+ account.MessagesPerPage = Math.Max(0, request.MessagesPerPage);
+ account.ContactsPerPage = Math.Max(0, request.ContactsPerPage);
+ account.AutoCheckIntervalMinutes = Math.Max(0, request.AutoCheckIntervalMinutes);
+ account.TimeZoneId = request.TimeZoneId;
+ account.Language = request.Language;
+ accountStore.Update(account);
+
+ if (localizer.LanguageExists(request.Language))
+ {
+ Response.Cookies.Append("lang", request.Language, new CookieOptions
+ {
+ Expires = DateTimeOffset.UtcNow.AddYears(1),
+ IsEssential = true,
+ SameSite = SameSiteMode.Lax,
+ HttpOnly = true
+ });
+ }
+
+ return Ok(AccountSettingsDto.From(account));
+ }
+
+ // The client (browser Intl API) only understands IANA ids; TimeZoneInfo.GetSystemTimeZones()
+ // returns Windows ids on Windows, so this is the single conversion point for the list exposed
+ // to the UI (see also Account.ResolveDefaultTimeZoneId, which does the same for the default).
+ private static string ToIanaId(TimeZoneInfo z) =>
+ OperatingSystem.IsWindows() && TimeZoneInfo.TryConvertWindowsIdToIanaId(z.Id, out var iana) ? iana : z.Id;
+
+ [HttpGet("folders")]
+ public async Task<IActionResult> Folders(bool refresh = false, CancellationToken ct = default)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+ return Ok(await imapService.GetFoldersAsync(account, password, includeSizes: true, forceRefreshSizes: refresh, ct: ct));
+ }
+
+ // The array's own sequence doubles as the new display order (see IFolderSettingsStore.
+ // SaveOrder) - the client always submits every row it currently shows, in its current
+ // (possibly drag-reordered) on-screen order, so there's no separate "save order" step.
+ [HttpPost("folders")]
+ public IActionResult SaveFolders(SaveFolderSettingsRequest request)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var accountId = active.Value.account.Id;
+
+ foreach (var f in request.Folders)
+ {
+ if (string.IsNullOrWhiteSpace(f.FullName)) continue;
+ folderSettingsStore.Save(accountId, f.FullName, f.SyncMode, f.Subscribed);
+ }
+
+ folderSettingsStore.SaveOrder(accountId, [.. request.Folders.Select(f => f.FullName).Where(n => !string.IsNullOrWhiteSpace(n))]);
+
+ return Ok();
+ }
+
+ [HttpPost("folders/add")]
+ public async Task<IActionResult> AddFolder(AddFolderRequest request, CancellationToken ct)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest();
+
+ var (account, password) = active.Value;
+ await imapService.CreateFolderAsync(account, password, request.Name, ct);
+ return Ok();
+ }
+
+ [HttpPost("folders/delete")]
+ public async Task<IActionResult> DeleteFolders(DeleteFoldersRequest request, CancellationToken ct)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var (account, password) = active.Value;
+
+ var failed = await imapService.DeleteFoldersAsync(account, password, request.FolderNames, ct);
+ foreach (var name in request.FolderNames.Except(failed))
+ folderSettingsStore.Remove(account.Id, name);
+
+ return Ok(new { failed });
+ }
+
+ // One-time bootstrap escape hatch for installs that already have accounts but no admin yet
+ // (e.g. upgraded from before IsAdmin existed). Only works while zero admins exist system-wide -
+ // once any account is an admin, this always 403s and further promotion is an admin-only action.
+ [HttpPost("claim-admin")]
+ public async Task<IActionResult> ClaimAdmin()
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ var account = active.Value.account;
+
+ if (accountStore.GetAll().Any(a => a.IsAdmin)) return Forbid();
+
+ account.IsAdmin = true;
+ accountStore.Update(account);
+ logger.LogInformation("Account {AccountId} ({EmailAddress}) claimed admin via bootstrap (no prior admin existed)", account.Id, account.EmailAddress);
+
+ // Re-issue the auth cookie so the admin claim takes effect immediately, without a full logout.
+ await sessionManager.SetActiveAccountIdAsync(account.Id);
+ return Ok(AccountDto.From(account));
+ }
+}
diff --git a/MailSharp.MailClient/Controllers/ContactsController.cs b/MailSharp.MailClient/Controllers/ContactsController.cs
new file mode 100644
index 0000000..ddb1c5a
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/ContactsController.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.MailClient.Controllers;
+
+[AllowAnonymous]
+public class ContactsController : Controller
+{
+ [HttpGet]
+ public IActionResult Index() => View();
+}
diff --git a/MailSharp.MailClient/Controllers/MailController.cs b/MailSharp.MailClient/Controllers/MailController.cs
new file mode 100644
index 0000000..3a3f190
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/MailController.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.MailClient.Controllers;
+
+// Serves only the page shells - all data (folders, messages, translations, sending mail) is
+// fetched client-side from Controllers/Api via mail.js / compose.js. Must stay reachable while
+// logged out (mail.js itself redirects to /Account/Login on a 401 from the API), and since
+// MapControllers() and MapControllerRoute() share the same endpoint data source,
+// RequireAuthorization() on the API routes would otherwise leak onto this controller too.
+[AllowAnonymous]
+public class MailController : Controller
+{
+ [HttpGet]
+ public IActionResult Index() => View();
+
+ [HttpGet]
+ public IActionResult Compose() => View();
+}
diff --git a/MailSharp.MailClient/Controllers/MaintenanceController.cs b/MailSharp.MailClient/Controllers/MaintenanceController.cs
new file mode 100644
index 0000000..f1bdfb5
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/MaintenanceController.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.MailClient.Controllers;
+
+[Authorize(Policy = "AdminOnly")]
+public class MaintenanceController : Controller
+{
+ [HttpGet]
+ public IActionResult Index() => View();
+}
diff --git a/MailSharp.MailClient/Controllers/SettingsController.cs b/MailSharp.MailClient/Controllers/SettingsController.cs
new file mode 100644
index 0000000..7bf9607
--- /dev/null
+++ b/MailSharp.MailClient/Controllers/SettingsController.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.MailClient.Controllers;
+
+[AllowAnonymous]
+public class SettingsController : Controller
+{
+ [HttpGet]
+ public IActionResult Index() => View();
+}
diff --git a/MailSharp.MailClient/Extensions/ServiceCollectionExtensions.cs b/MailSharp.MailClient/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..9c7118e
--- /dev/null
+++ b/MailSharp.MailClient/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,49 @@
+using MailSharp.MailClient.Models;
+using MailSharp.MailClient.Services;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.DataProtection;
+
+namespace MailSharp.MailClient.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ // Data Protection and cookie authentication are configured together because one exists to
+ // support the other: the cookie's payload is protected using Data Protection keys, and
+ // persisting those keys to disk (instead of the ephemeral default) is what lets a previously
+ // issued auth cookie stay valid across an app/server restart.
+ public static IServiceCollection AddMailSharpAuthentication(this IServiceCollection services, MailSettings settings)
+ {
+ Directory.CreateDirectory(settings.DataProtectionKeysPath);
+ services.AddDataProtection()
+ .PersistKeysToFileSystem(new DirectoryInfo(settings.DataProtectionKeysPath))
+ .SetApplicationName("MailSharp");
+
+ services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddCookie(options =>
+ {
+ options.Cookie.Name = "MailSharpAuth";
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SameSite = SameSiteMode.Lax;
+ options.ExpireTimeSpan = TimeSpan.FromHours(settings.SessionIdleTimeoutHours);
+ options.SlidingExpiration = true;
+ options.LoginPath = "/Account/Login";
+ options.Events.OnRedirectToLogin = context =>
+ {
+ if (context.Request.Path.StartsWithSegments("/api"))
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return Task.CompletedTask;
+ }
+ context.Response.Redirect(context.RedirectUri);
+ return Task.CompletedTask;
+ };
+ });
+
+ services.AddAuthorization(options =>
+ {
+ options.AddPolicy("AdminOnly", policy => policy.RequireClaim(SessionAccountManager.AdminClaimType, "true"));
+ });
+
+ return services;
+ }
+}
diff --git a/MailSharp.MailClient/Localization/en.json b/MailSharp.MailClient/Localization/en.json
new file mode 100644
index 0000000..4744c57
--- /dev/null
+++ b/MailSharp.MailClient/Localization/en.json
@@ -0,0 +1,173 @@
+{
+ "__name__": "English",
+ "__order__": "2",
+
+ "common_menu": "Menu",
+ "common_back": "Back",
+ "common_cancel_request": "Cancel",
+ "common_request_cancelled": "Request cancelled.",
+
+ "topbar_add_account": "+ Account",
+ "topbar_logout": "Log out",
+ "topbar_language": "Language",
+
+ "sidebar_new_message": "✎ New message",
+ "topbar_contacts": "Contacts",
+ "topbar_settings": "Settings",
+
+ "login_page_title": "Log in",
+ "login_subtitle": "Log in with your email account",
+ "login_email_address": "Email address",
+ "login_username": "Username (optional, defaults to email address)",
+ "login_password": "Password",
+ "login_advanced_toggle": "Advanced login (configure server) »",
+ "login_manual_server": "Configure server manually",
+ "login_imap_incoming": "IMAP (incoming)",
+ "login_server": "Server",
+ "login_port": "Port",
+ "login_security": "Security",
+ "login_security_ssl_tls": "SSL/TLS",
+ "login_security_start_tls": "STARTTLS",
+ "login_security_none": "None",
+ "login_smtp_outgoing": "SMTP (outgoing)",
+ "login_submit": "Log in",
+ "login_error_email_password_required": "Email address and password are required.",
+ "login_error_servers_required": "IMAP and SMTP server are required for advanced login.",
+ "login_error_connect_failed": "Connection failed: {0}",
+
+ "compose_page_title": "New message",
+ "compose_to": "To",
+ "compose_to_placeholder": "recipient@example.com",
+ "compose_cc": "Cc",
+ "compose_bcc": "Bcc",
+ "compose_subject": "Subject",
+ "compose_body": "Message",
+ "compose_font_name": "Font",
+ "compose_font_size": "Size",
+ "compose_size_small": "Small",
+ "compose_size_normal": "Normal",
+ "compose_size_large": "Large",
+ "compose_size_huge": "Huge",
+ "compose_text": "Text",
+ "compose_background": "Background",
+ "compose_link": "Link",
+ "compose_image": "Image",
+ "compose_list": "• List",
+ "compose_clear_format": "Clear format",
+ "compose_priority": "Priority",
+ "compose_priority_low": "Low",
+ "compose_priority_normal": "Normal",
+ "compose_priority_high": "High",
+ "compose_sensitivity": "Sensitivity",
+ "compose_sensitivity_nothing": "Nothing",
+ "compose_sensitivity_confidential": "Confidential",
+ "compose_sensitivity_private": "Private",
+ "compose_sensitivity_personal": "Personal",
+ "compose_request_read_receipt": "Request read receipt",
+ "compose_attachments": "Attachments",
+ "compose_send": "Send",
+ "compose_save_draft": "Save as draft",
+ "compose_upload_image": "Upload image",
+ "detail_priority_high": "High priority",
+ "detail_sensitivity_confidential": "Please treat this message as confidential.",
+ "detail_sensitivity_private": "Please treat this as private.",
+ "detail_sensitivity_personal": "Please treat this as personal.",
+ "compose_prompt_link_url": "Enter the URL:",
+ "compose_prompt_image_url": "Enter the image URL:",
+ "compose_sent": "Message sent.",
+ "compose_draft_saved": "Draft saved.",
+
+ "mail_inbox": "Inbox",
+ "mail_sort": "Sort:",
+ "mail_sort_date": "Date",
+ "mail_sort_subject": "Subject",
+ "mail_sort_from": "From",
+ "mail_sort_size": "Size",
+ "mail_refresh": "Refresh",
+ "mail_empty": "No messages in this folder.",
+ "mail_previous": "« Previous",
+ "mail_next": "Next »",
+ "mail_pager_info": "Page {0} of {1} ({2} messages)",
+
+ "mail_actions": "Actions...",
+ "action_mark_read": "Mark as read",
+ "action_mark_unread": "Mark as unread",
+ "action_flag": "Flag",
+ "action_unflag": "Unflag",
+ "action_mark_all_read": "Mark all as read",
+ "action_mark_all_unread": "Mark all as unread",
+ "mail_move_to": "Move to...",
+ "mail_delete_menu": "Delete...",
+ "action_delete": "Delete",
+ "action_empty_trash": "Empty trash",
+ "action_empty_spam": "Empty spam",
+ "mail_spam": "Spam",
+ "detail_reply_menu": "Reply...",
+ "action_reply": "Reply",
+ "action_reply_all": "Reply all",
+ "detail_forward": "Forward",
+
+ "detail_empty": "Select a message to read.",
+ "detail_back_to_list": "← Back to list",
+ "detail_from": "From:",
+ "detail_to": "To:",
+ "detail_cc": "Cc:",
+ "detail_date": "Date:",
+ "detail_images_blocked": "External images are blocked for this sender.",
+ "detail_always_show_for": "Always show for {0}",
+ "detail_view": "View",
+ "detail_download": "Download",
+
+ "contacts_search": "Search",
+ "contacts_empty": "No contacts found.",
+ "contacts_select": "Select a contact to view.",
+ "contacts_email": "Email:",
+ "contacts_mail": "Mail contact",
+ "contacts_edit": "Edit contact information",
+ "contacts_save": "Save",
+ "contacts_cancel": "Cancel",
+ "contacts_view_mails": "View all mails from this contact",
+ "contacts_no_mails": "No messages found.",
+
+ "settings_page_title": "Settings",
+ "settings_tab_general": "General Settings",
+ "settings_tab_folders": "Manage Folders",
+ "settings_general": "General settings",
+ "settings_email": "Email settings",
+ "settings_language": "Language",
+ "settings_account": "Account",
+ "settings_email_address": "Email address",
+ "settings_imap_server": "IMAP server",
+ "settings_smtp_server": "SMTP server",
+
+ "settings_friendly_name": "Friendly name",
+ "settings_signature": "Signature",
+ "settings_messages_per_page": "Messages per page",
+ "settings_contacts_per_page": "Contacts per page",
+ "settings_autocheck_interval": "Autocheck interval (minutes, 0 = off)",
+ "settings_timezone": "Default timezone",
+ "settings_default_language": "Default language",
+ "settings_save": "Save",
+ "settings_saved": "Settings saved.",
+
+ "settings_folders_title": "Manage folders",
+ "settings_folder_column": "Folder",
+ "settings_messages_column": "Messages",
+ "settings_size_column": "Size",
+ "settings_sync_column": "Synchronize",
+ "settings_subscribed_column": "Subscribed",
+ "settings_total": "Total",
+ "settings_add_folder": "Add new folder",
+ "settings_delete_selected": "Delete selected",
+ "settings_reload_folders": "Reload folders",
+ "settings_delete_non_empty_warning": "Non-empty folders cannot be deleted. Please empty them first.",
+ "settings_new_folder_prompt": "Name of the new folder:",
+ "settings_folder_order_hint": "Drag a folder (or use the arrows) to change its order in the main menu. This only changes the display, not the actual folder structure.",
+
+ "sync_not_synchronized": "Do not synchronize",
+ "sync_new_headers": "New headers",
+ "sync_all_headers": "All headers",
+ "sync_new_messages": "New messages",
+ "sync_all_messages": "All messages",
+ "sync_direct": "Direct"
+}
diff --git a/MailSharp.MailClient/Localization/nl.json b/MailSharp.MailClient/Localization/nl.json
new file mode 100644
index 0000000..5551de5
--- /dev/null
+++ b/MailSharp.MailClient/Localization/nl.json
@@ -0,0 +1,173 @@
+{
+ "__name__": "Nederlands",
+ "__order__": "1",
+
+ "common_menu": "Menu",
+ "common_back": "Terug",
+ "common_cancel_request": "Annuleren",
+ "common_request_cancelled": "Verzoek geannuleerd.",
+
+ "topbar_add_account": "+ Account",
+ "topbar_logout": "Uitloggen",
+ "topbar_language": "Taal",
+
+ "sidebar_new_message": "✎ Nieuw bericht",
+ "topbar_contacts": "Contacten",
+ "topbar_settings": "Instellingen",
+
+ "login_page_title": "Inloggen",
+ "login_subtitle": "Log in met je e-mailaccount",
+ "login_email_address": "E-mailadres",
+ "login_username": "Gebruikersnaam (optioneel, standaard e-mailadres)",
+ "login_password": "Wachtwoord",
+ "login_advanced_toggle": "Geavanceerde login (server instellen) »",
+ "login_manual_server": "Server handmatig instellen",
+ "login_imap_incoming": "IMAP (inkomend)",
+ "login_server": "Server",
+ "login_port": "Poort",
+ "login_security": "Beveiliging",
+ "login_security_ssl_tls": "SSL/TLS",
+ "login_security_start_tls": "STARTTLS",
+ "login_security_none": "Geen",
+ "login_smtp_outgoing": "SMTP (uitgaand)",
+ "login_submit": "Inloggen",
+ "login_error_email_password_required": "E-mailadres en wachtwoord zijn verplicht.",
+ "login_error_servers_required": "IMAP- en SMTP-server zijn verplicht bij geavanceerd inloggen.",
+ "login_error_connect_failed": "Verbinden mislukt: {0}",
+
+ "compose_page_title": "Nieuw bericht",
+ "compose_to": "Aan",
+ "compose_to_placeholder": "ontvanger@voorbeeld.nl",
+ "compose_cc": "Cc",
+ "compose_bcc": "Bcc",
+ "compose_subject": "Onderwerp",
+ "compose_body": "Bericht",
+ "compose_font_name": "Lettertype",
+ "compose_font_size": "Grootte",
+ "compose_size_small": "Klein",
+ "compose_size_normal": "Normaal",
+ "compose_size_large": "Groot",
+ "compose_size_huge": "Zeer groot",
+ "compose_text": "Tekst",
+ "compose_background": "Achtergrond",
+ "compose_link": "Link",
+ "compose_image": "Afbeelding",
+ "compose_list": "• Lijst",
+ "compose_clear_format": "Wis opmaak",
+ "compose_priority": "Prioriteit",
+ "compose_priority_low": "Laag",
+ "compose_priority_normal": "Normaal",
+ "compose_priority_high": "Hoog",
+ "compose_sensitivity": "Gevoeligheid",
+ "compose_sensitivity_nothing": "Niets",
+ "compose_sensitivity_confidential": "Vertrouwelijk",
+ "compose_sensitivity_private": "Prive",
+ "compose_sensitivity_personal": "Persoonlijk",
+ "compose_request_read_receipt": "Leesbevestiging vragen",
+ "compose_attachments": "Bijlagen",
+ "compose_send": "Verzenden",
+ "compose_save_draft": "Opslaan als concept",
+ "compose_upload_image": "Afbeelding uploaden",
+ "detail_priority_high": "Hoge prioriteit",
+ "detail_sensitivity_confidential": "Behandel dit bericht als vertrouwelijk.",
+ "detail_sensitivity_private": "Behandel dit bericht als prive.",
+ "detail_sensitivity_personal": "Behandel dit bericht als persoonlijk.",
+ "compose_prompt_link_url": "Voer de URL in:",
+ "compose_prompt_image_url": "Voer de afbeeldings-URL in:",
+ "compose_sent": "Bericht is verzonden.",
+ "compose_draft_saved": "Concept opgeslagen.",
+
+ "mail_inbox": "Postvak",
+ "mail_sort": "Sorteer:",
+ "mail_sort_date": "Datum",
+ "mail_sort_subject": "Onderwerp",
+ "mail_sort_from": "Afzender",
+ "mail_sort_size": "Grootte",
+ "mail_refresh": "Vernieuwen",
+ "mail_empty": "Geen berichten in deze map.",
+ "mail_previous": "« Vorige",
+ "mail_next": "Volgende »",
+ "mail_pager_info": "Pagina {0} van {1} ({2} berichten)",
+
+ "mail_actions": "Acties...",
+ "action_mark_read": "Als gelezen markeren",
+ "action_mark_unread": "Als ongelezen markeren",
+ "action_flag": "Vlaggen",
+ "action_unflag": "Uitvlaggen",
+ "action_mark_all_read": "Allemaal als gelezen markeren",
+ "action_mark_all_unread": "Allemaal als ongelezen markeren",
+ "mail_move_to": "Verplaatsen naar...",
+ "mail_delete_menu": "Verwijderen...",
+ "action_delete": "Verwijderen",
+ "action_empty_trash": "Prullenbak leegmaken",
+ "action_empty_spam": "Spam leegmaken",
+ "mail_spam": "Spam",
+ "detail_reply_menu": "Antwoorden...",
+ "action_reply": "Antwoorden",
+ "action_reply_all": "Allen antwoorden",
+ "detail_forward": "Doorsturen",
+
+ "detail_empty": "Selecteer een bericht om te lezen.",
+ "detail_back_to_list": "← Terug naar lijst",
+ "detail_from": "Van:",
+ "detail_to": "Aan:",
+ "detail_cc": "Cc:",
+ "detail_date": "Datum:",
+ "detail_images_blocked": "Externe afbeeldingen zijn geblokkeerd voor deze afzender.",
+ "detail_always_show_for": "Altijd tonen voor {0}",
+ "detail_view": "Bekijken",
+ "detail_download": "Downloaden",
+
+ "contacts_search": "Zoeken",
+ "contacts_empty": "Geen contacten gevonden.",
+ "contacts_select": "Selecteer een contact om te bekijken.",
+ "contacts_email": "Email:",
+ "contacts_mail": "Mail contactpersoon",
+ "contacts_edit": "Wijzig contact informatie",
+ "contacts_save": "Opslaan",
+ "contacts_cancel": "Annuleren",
+ "contacts_view_mails": "Alle mails van deze contactpersoon bekijken",
+ "contacts_no_mails": "Geen berichten gevonden.",
+
+ "settings_page_title": "Instellingen",
+ "settings_tab_general": "Algemene Instellingen",
+ "settings_tab_folders": "Mappen beheren",
+ "settings_general": "Algemene instellingen",
+ "settings_email": "Email instellingen",
+ "settings_language": "Taal",
+ "settings_account": "Account",
+ "settings_email_address": "E-mailadres",
+ "settings_imap_server": "IMAP-server",
+ "settings_smtp_server": "SMTP-server",
+
+ "settings_friendly_name": "Friendly naam",
+ "settings_signature": "Onderschrift",
+ "settings_messages_per_page": "Berichten per pagina",
+ "settings_contacts_per_page": "Contacten per pagina",
+ "settings_autocheck_interval": "Automatisch controleren (minuten, 0 = uit)",
+ "settings_timezone": "Standaard tijdzone",
+ "settings_default_language": "Standaard taal",
+ "settings_save": "Opslaan",
+ "settings_saved": "Instellingen opgeslagen.",
+
+ "settings_folders_title": "Mappen beheren",
+ "settings_folder_column": "Map",
+ "settings_messages_column": "Berichten",
+ "settings_size_column": "Grootte",
+ "settings_sync_column": "Synchroniseren",
+ "settings_subscribed_column": "Geabonneerd",
+ "settings_total": "Totaal",
+ "settings_add_folder": "Nieuwe map toevoegen",
+ "settings_delete_selected": "Verwijder geselecteerde",
+ "settings_reload_folders": "Mappen opnieuw laden",
+ "settings_delete_non_empty_warning": "Niet-lege mappen verwijderen is niet mogelijk. Gelieve eerst de inhoud van deze mappen te verwijderen.",
+ "settings_new_folder_prompt": "Naam van de nieuwe map:",
+ "settings_folder_order_hint": "Versleep een map (of gebruik de pijltjes) om de volgorde in het hoofdmenu te wijzigen. Dit past alleen de weergave aan, niet de echte mappenstructuur.",
+
+ "sync_not_synchronized": "Niet synchroniseren",
+ "sync_new_headers": "Nieuwe headers",
+ "sync_all_headers": "Alle headers",
+ "sync_new_messages": "Nieuwe berichten",
+ "sync_all_messages": "Alle berichten",
+ "sync_direct": "Direct"
+}
diff --git a/MailSharp.MailClient/MailSharp.MailClient.csproj b/MailSharp.MailClient/MailSharp.MailClient.csproj
new file mode 100644
index 0000000..230fa0d
--- /dev/null
+++ b/MailSharp.MailClient/MailSharp.MailClient.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Folder Include="App_Data\" />
+ <Folder Include="Extensions\" />
+ <Folder Include="wwwroot\" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="MailKit" Version="4.17.0" />
+ <PackageReference Include="LiteDB" Version="5.0.21" />
+ </ItemGroup>
+
+</Project>
diff --git a/MailSharp.MailClient/Models/Account.cs b/MailSharp.MailClient/Models/Account.cs
new file mode 100644
index 0000000..7d3c1dc
--- /dev/null
+++ b/MailSharp.MailClient/Models/Account.cs
@@ -0,0 +1,47 @@
+namespace MailSharp.MailClient.Models;
+
+public class Account
+{
+ public int Id { get; set; }
+ public string DisplayName { get; set; } = "";
+ public string EmailAddress { get; set; } = "";
+ public string Username { get; set; } = "";
+
+ // Encrypted at rest via IPasswordProtector
+ public string ProtectedPassword { get; set; } = "";
+
+ public string ImapHost { get; set; } = "";
+ public int ImapPort { get; set; } = 993;
+ public SecurityMode ImapSecurity { get; set; } = SecurityMode.SslTls;
+
+ public string SmtpHost { get; set; } = "";
+ public int SmtpPort { get; set; } = 587;
+ public SecurityMode SmtpSecurity { get; set; } = SecurityMode.StartTls;
+
+ // User-configurable preferences (Settings page). MessagesPerPage/ContactsPerPage of 0 means
+ // "use the app-wide default" (see MailSettings) rather than a hardcoded per-account fallback.
+ public string Signature { get; set; } = "";
+ public int MessagesPerPage { get; set; }
+ public int ContactsPerPage { get; set; }
+ public int AutoCheckIntervalMinutes { get; set; } = 5;
+
+ // Stored as an IANA id (e.g. "Europe/Amsterdam") even on Windows, where TimeZoneInfo itself
+ // natively uses Windows ids ("W. Europe Standard Time") - the browser's Intl API (used for all
+ // client-side date formatting) only understands IANA ids, so converting once here means every
+ // other call site can just pass this straight through instead of re-converting.
+ public string TimeZoneId { get; set; } = ResolveDefaultTimeZoneId();
+ public string Language { get; set; } = "nl";
+
+ // Grants access to the Maintenance page (logs, background process status, message metrics).
+ // The first account ever created on a fresh install becomes admin automatically (see
+ // AuthApiController.Login); later accounts default to false and can only be promoted via the
+ // claim-admin bootstrap endpoint while no admin exists yet, or by an existing admin afterwards.
+ public bool IsAdmin { get; set; }
+
+ private static string ResolveDefaultTimeZoneId()
+ {
+ var id = TimeZoneInfo.Local.Id;
+ if (OperatingSystem.IsWindows() && TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var iana)) return iana;
+ return id;
+ }
+}
diff --git a/MailSharp.MailClient/Models/CacheModels.cs b/MailSharp.MailClient/Models/CacheModels.cs
new file mode 100644
index 0000000..85b480b
--- /dev/null
+++ b/MailSharp.MailClient/Models/CacheModels.cs
@@ -0,0 +1,58 @@
+namespace MailSharp.MailClient.Models;
+
+public class CachedMessageList
+{
+ public string Id { get; set; } = "";
+ public DateTimeOffset CachedAt { get; set; }
+ public List<MessageListItem> Messages { get; set; } = [];
+}
+
+public class SenderImagePreference
+{
+ public string Id { get; set; } = "";
+ public bool AllowImages { get; set; }
+}
+
+// Maps an image's URL hash to its original source URL, so the /cache/images/{hash} handler can
+// download it on the first request even though the hash itself can't be reversed back to a URL.
+public class ImageUrlMapping
+{
+ public string Id { get; set; } = "";
+ public string Url { get; set; } = "";
+}
+
+// Maps a (account, folder, uid) triple - the only thing we know before contacting IMAP - to the
+// message's own globally-unique Message-ID, which is the actual cache key for the .eml file
+// (uids are only unique within a single folder, not account-wide, and change when a message is
+// moved between folders; Message-ID doesn't).
+public class MessageIdMapping
+{
+ public string Id { get; set; } = "";
+ public string MessageId { get; set; } = "";
+}
+
+public class ContactOverride
+{
+ public string Id { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+}
+
+// One row per account, holding the full contact address list scraped from Sent - fetching it
+// requires walking the whole Sent folder over IMAP, so it's done once and then kept up to date
+// incrementally (see IContactStore.AddContacts) instead of being re-fetched on every page load.
+public class ContactCache
+{
+ public int Id { get; set; }
+ public List<ContactAddress> Contacts { get; set; } = [];
+}
+
+// Caches the result of IImapService.SearchByAddressAsync (all mails to/from one contact), keyed
+// by (account, email) - that search walks every folder over IMAP, so repeatedly opening the same
+// contact shouldn't repeat it every time; a short TTL (like the message list cache) still lets it
+// pick up newly arrived/sent mail without needing an explicit invalidation.
+public class ContactMailSearchCache
+{
+ public string Id { get; set; } = "";
+ public DateTimeOffset CachedAt { get; set; }
+ public List<MessageSearchResult> Results { get; set; } = [];
+}
diff --git a/MailSharp.MailClient/Models/Enums.cs b/MailSharp.MailClient/Models/Enums.cs
new file mode 100644
index 0000000..1a9eeb1
--- /dev/null
+++ b/MailSharp.MailClient/Models/Enums.cs
@@ -0,0 +1,23 @@
+namespace MailSharp.MailClient.Models;
+
+public enum SecurityMode
+{
+ None,
+ StartTls,
+ SslTls
+}
+
+public enum MessagePriority
+{
+ Low,
+ Normal,
+ High
+}
+
+public enum MessageSensitivity
+{
+ Nothing,
+ Confidential,
+ Private,
+ Personal
+}
diff --git a/MailSharp.MailClient/Models/LogEntry.cs b/MailSharp.MailClient/Models/LogEntry.cs
new file mode 100644
index 0000000..a5c844a
--- /dev/null
+++ b/MailSharp.MailClient/Models/LogEntry.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.Logging;
+
+namespace MailSharp.MailClient.Models;
+
+public class LogEntry
+{
+ public int Id { get; set; }
+ public DateTime Timestamp { get; set; }
+ public LogLevel Level { get; set; }
+ public string Category { get; set; } = "";
+ public string Message { get; set; } = "";
+ public string? Exception { get; set; }
+ public int? EventId { get; set; }
+
+ // Pulled out of ScopeData (when present) into its own indexed field so filtering by account in
+ // the Maintenance log viewer doesn't need to scan/deserialize every ScopeData dictionary.
+ public int? AccountId { get; set; }
+
+ // Flattened BeginScope() values (accountId, folder, durationMs, ...) - keys/values are always
+ // stringified since scope state can be any shape (anonymous objects, dictionaries, tuples) and
+ // LiteDB needs a stable, queryable document shape rather than arbitrary nested state.
+ public Dictionary<string, string> ScopeData { get; set; } = [];
+}
diff --git a/MailSharp.MailClient/Models/MailModels.cs b/MailSharp.MailClient/Models/MailModels.cs
new file mode 100644
index 0000000..724129d
--- /dev/null
+++ b/MailSharp.MailClient/Models/MailModels.cs
@@ -0,0 +1,124 @@
+namespace MailSharp.MailClient.Models;
+
+public enum FolderSyncMode
+{
+ NotSynchronized,
+ NewHeaders,
+ AllHeaders,
+ NewMessages,
+ AllMessages,
+ Direct
+}
+
+// Per-account override of a folder's sync mode/subscription, keyed by (account, folder full
+// name) - see IFolderSettingsStore. Not stored on MailFolder itself since MailFolder is rebuilt
+// fresh from IMAP on every fetch.
+public class FolderSetting
+{
+ public string Id { get; set; } = "";
+ public FolderSyncMode SyncMode { get; set; } = FolderSyncMode.Direct;
+ public bool Subscribed { get; set; } = true;
+}
+
+// Display-only ordering of an account's folders in the main menu (Settings > Mappen beheren) -
+// see IFolderSettingsStore.GetOrder/SaveOrder. Never affects the real IMAP folder structure.
+public class FolderOrder
+{
+ public int Id { get; set; }
+ public List<string> Names { get; set; } = [];
+}
+
+public class MailFolder
+{
+ public string FullName { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+ public int UnreadCount { get; set; }
+ public bool IsSelectable { get; set; } = true;
+
+ // Folder-management-page fields (see Settings > Mappen beheren). MessageCount comes cheaply
+ // from the IMAP STATUS/SELECT response already fetched for UnreadCount. SizeBytes is a
+ // best-effort estimate, not an exact total - see IImapService.GetFoldersAsync for how it's
+ // derived (real IMAP STATUS SIZE where the server supports it, otherwise summed from
+ // whatever of the folder's messages happen to already be in the local message-list cache).
+ // IsSizeEstimated tells the UI which case applied, so it can show e.g. "~" for the estimate.
+ public int MessageCount { get; set; }
+ public long SizeBytes { get; set; }
+ public bool IsSizeEstimated { get; set; }
+ public int Depth { get; set; }
+ public FolderSyncMode SyncMode { get; set; } = FolderSyncMode.Direct;
+ public bool Subscribed { get; set; } = true;
+
+ // INBOX, and the folders serving IMAP's other special roles (Sent/Trash/Drafts/Junk) - these
+ // can't be deleted (see IImapService.DeleteFoldersAsync) since core app features (send,
+ // delete-to-trash, save-draft, contacts' "sent" scan) assume they exist.
+ public bool IsProtected { get; set; }
+}
+
+public class MessageListItem
+{
+ public uint Uid { get; set; }
+ public string Subject { get; set; } = "";
+ public string From { get; set; } = "";
+ public DateTimeOffset Date { get; set; }
+ public long SizeBytes { get; set; }
+ public bool IsRead { get; set; }
+ public bool IsFlagged { get; set; }
+ public bool HasAttachments { get; set; }
+ public MessagePriority Priority { get; set; } = MessagePriority.Normal;
+
+ public double SizeKb => Math.Round(SizeBytes / 1024.0, 1);
+}
+
+public class MessageDetail
+{
+ public uint Uid { get; set; }
+ public string Subject { get; set; } = "";
+ public string From { get; set; } = "";
+ public string SenderEmail { get; set; } = "";
+ public string To { get; set; } = "";
+ public string Cc { get; set; } = "";
+ public DateTimeOffset Date { get; set; }
+ public string HtmlBody { get; set; } = "";
+ public string TextBody { get; set; } = "";
+ public List<AttachmentInfo> Attachments { get; set; } = [];
+ public bool HasExternalImages { get; set; }
+ public bool ImagesAllowed { get; set; }
+ public MessagePriority Priority { get; set; } = MessagePriority.Normal;
+ public MessageSensitivity Sensitivity { get; set; } = MessageSensitivity.Nothing;
+}
+
+public class AttachmentInfo
+{
+ public string FileName { get; set; } = "";
+ public string ContentType { get; set; } = "application/octet-stream";
+ public long SizeBytes { get; set; }
+ public int PartIndex { get; set; }
+}
+
+public class ContactAddress
+{
+ public string Email { get; set; } = "";
+ public string Name { get; set; } = "";
+}
+
+public class MessageSearchResult
+{
+ public string Folder { get; set; } = "";
+ public uint Uid { get; set; }
+ public string Subject { get; set; } = "";
+ public string From { get; set; } = "";
+ public DateTimeOffset Date { get; set; }
+}
+
+public class ComposeModel
+{
+ public int AccountId { get; set; }
+ public string To { get; set; } = "";
+ public string Cc { get; set; } = "";
+ public string Bcc { get; set; } = "";
+ public string Subject { get; set; } = "";
+ public string HtmlBody { get; set; } = "";
+ public MessagePriority Priority { get; set; } = MessagePriority.Normal;
+ public MessageSensitivity Sensitivity { get; set; } = MessageSensitivity.Nothing;
+ public bool RequestReadReceipt { get; set; }
+}
diff --git a/MailSharp.MailClient/Models/MailSettings.cs b/MailSharp.MailClient/Models/MailSettings.cs
new file mode 100644
index 0000000..66693f6
--- /dev/null
+++ b/MailSharp.MailClient/Models/MailSettings.cs
@@ -0,0 +1,19 @@
+namespace MailSharp.MailClient.Models;
+
+public class MailSettings
+{
+ public int MessageListFetchLimit { get; set; } = 200;
+ public int MessageListCacheTtlMinutes { get; set; } = 5;
+ public int ContactMailSearchCacheTtlMinutes { get; set; } = 5;
+ public int MessageListPageSize { get; set; } = 20;
+ public int ContactMailsPageSize { get; set; } = 20;
+ public int SessionIdleTimeoutHours { get; set; } = 24;
+ public string DataProtectionKeysPath { get; set; } = @"C:\Data\DataProtection\Keys";
+ public string DefaultImapServer { get; set; } = "127.0.0.1";
+ public int DefaultImapPort { get; set; } = 993;
+ public SecurityMode DefaultImapSecurity { get; set; } = SecurityMode.SslTls;
+ public string DefaultSmtpServer { get; set; } = "127.0.0.1";
+ public int DefaultSmtpPort { get; set; } = 465;
+ public SecurityMode DefaultSmtpSecurity { get; set; } = SecurityMode.SslTls;
+ public int LogRetentionDays { get; set; } = 30;
+}
diff --git a/MailSharp.MailClient/Models/MessageIndexModels.cs b/MailSharp.MailClient/Models/MessageIndexModels.cs
new file mode 100644
index 0000000..8e57d0e
--- /dev/null
+++ b/MailSharp.MailClient/Models/MessageIndexModels.cs
@@ -0,0 +1,37 @@
+namespace MailSharp.MailClient.Models;
+
+// One row per message, kept up to date by IImapService.EnsureFolderIndexedAsync (full rebuild on
+// UIDVALIDITY change, incremental fetch of new UIDs, UID-diff reconciliation when the message
+// count doesn't match) - this is what GetMessagesAsync/GetFoldersAsync (sizes)/
+// SearchByAddressAsync/GetSentContactsAsync read from instead of re-scanning IMAP live, for any
+// folder whose FolderSyncMode isn't NotSynchronized.
+public class IndexedMessage
+{
+ public string Id { get; set; } = "";
+ public string Key { get; set; } = "";
+ public int AccountId { get; set; }
+ public string Folder { get; set; } = "";
+ public uint Uid { get; set; }
+ public string Subject { get; set; } = "";
+ public string From { get; set; } = "";
+ public string To { get; set; } = "";
+ public string Cc { get; set; } = "";
+ public DateTimeOffset Date { get; set; }
+ public long SizeBytes { get; set; }
+ public bool IsRead { get; set; }
+ public bool IsFlagged { get; set; }
+ public bool HasAttachments { get; set; }
+ public MessagePriority Priority { get; set; } = MessagePriority.Normal;
+}
+
+// Per-(account, folder) sync checkpoint - see IImapService.EnsureFolderIndexedAsync. UidValidity
+// changing means the server considers the whole mailbox rebuilt (rare); UidNext advancing means
+// new mail arrived; MessageCount not matching the index's own row count is the cheap signal that
+// something was removed (deleted or moved elsewhere) since the last check.
+public class FolderSyncState
+{
+ public string Id { get; set; } = "";
+ public uint UidValidity { get; set; }
+ public uint UidNext { get; set; }
+ public int MessageCount { get; set; }
+}
diff --git a/MailSharp.MailClient/Program.cs b/MailSharp.MailClient/Program.cs
new file mode 100644
index 0000000..4d97598
--- /dev/null
+++ b/MailSharp.MailClient/Program.cs
@@ -0,0 +1,94 @@
+using MailSharp.MailClient.Extensions;
+using MailSharp.MailClient.Models;
+using MailSharp.MailClient.Services;
+using MailSharp.MailClient.Services.Logging;
+using Microsoft.Extensions.Logging;
+using System.Text.Json.Serialization;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings"));
+var mailSettings = builder.Configuration.GetSection("MailSettings").Get<MailSettings>() ?? new MailSettings();
+
+builder.Services.AddControllersWithViews(options =>
+{
+ // Cc/Bcc and similar optional string fields on request DTOs (e.g. ComposeModel) are non-nullable
+ // reference types with a "" default, not [Required] - without this, MVC's implicit-required
+ // validation for non-nullable reference types rejects them whenever the form posts an empty value.
+ options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
+}).AddJsonOptions(options =>
+{
+ options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+});
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddMailSharpAuthentication(mailSettings);
+
+builder.Services.AddHttpClient("ImageDownload", client =>
+{
+ client.Timeout = TimeSpan.FromSeconds(10);
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("MailSharp/1.0");
+});
+
+builder.Services.AddSingleton<IPasswordProtector, AesPasswordProtector>();
+builder.Services.AddSingleton<IAccountStore, LiteDbAccountStore>();
+builder.Services.AddSingleton<IMailCacheStore, LiteDbMailCacheStore>();
+builder.Services.AddSingleton<IContactStore, LiteDbContactStore>();
+builder.Services.AddSingleton<IFolderSettingsStore, LiteDbFolderSettingsStore>();
+builder.Services.AddSingleton<IMessageIndexStore, LiteDbMessageIndexStore>();
+builder.Services.AddSingleton<ILogStore, LiteDbLogStore>();
+builder.Services.AddSingleton<IBackgroundIndexingStatus, BackgroundIndexingStatus>();
+builder.Services.AddScoped<IImapService, ImapService>();
+builder.Services.AddScoped<ISmtpService, SmtpService>();
+builder.Services.AddScoped<SessionAccountManager>();
+builder.Services.AddScoped<LocalizationService>();
+builder.Services.AddScoped<ImageSanitizer>();
+builder.Services.AddHostedService<LogPruningService>();
+
+// Persists every log entry to LiteDB (see LiteDbLogStore) alongside the default console provider,
+// so logs survive an app restart / running as a Windows Service and are visible on the Maintenance
+// page - registered on the logging builder's own service collection (shares the same DI container
+// as builder.Services) so the provider can resolve ILogStore via normal constructor injection.
+builder.Logging.Services.AddSingleton<ILoggerProvider, LiteDbLoggerProvider>();
+
+var app = builder.Build();
+
+// One-time upgrade path for installs that predate the IsAdmin field: if there's exactly one
+// account and nobody is an admin yet, that account is the obvious sole candidate - promote it
+// automatically so there's no separate manual step. Installs with multiple accounts and no admin
+// still need the explicit claim-admin bootstrap (see SettingsApiController.ClaimAdmin), since it's
+// not obvious which of several accounts should get the promotion.
+using (var scope = app.Services.CreateScope())
+{
+ var accountStore = scope.ServiceProvider.GetRequiredService<IAccountStore>();
+ var accounts = accountStore.GetAll();
+ if (accounts.Count == 1 && !accounts[0].IsAdmin)
+ {
+ accounts[0].IsAdmin = true;
+ accountStore.Update(accounts[0]);
+ }
+}
+
+if (!app.Environment.IsDevelopment())
+{
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+app.UseStaticFiles();
+app.UseRouting();
+
+// No separate CSRF header check needed: the auth cookie is SameSite=Lax, so it is never sent
+// on a cross-site POST (the classic CSRF vector) - a forged request from another site simply
+// arrives unauthenticated and gets rejected by RequireAuthorization() below.
+app.UseAuthentication();
+app.UseAuthorization();
+
+// All API controllers require a logged-in account by default; the few endpoints that must stay
+// reachable while logged out (auth/state, login, login-defaults, and the whole language
+// controller) are marked [AllowAnonymous] explicitly.
+app.MapControllers().RequireAuthorization();
+app.MapControllerRoute(
+ name: "default",
+ pattern: "{controller=Mail}/{action=Index}/{id?}");
+
+await app.RunAsync();
diff --git a/MailSharp.MailClient/Properties/launchSettings.json b/MailSharp.MailClient/Properties/launchSettings.json
new file mode 100644
index 0000000..d888257
--- /dev/null
+++ b/MailSharp.MailClient/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5195",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7210;http://localhost:5194",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/MailSharp.MailClient/Services/AccountStore.cs b/MailSharp.MailClient/Services/AccountStore.cs
new file mode 100644
index 0000000..692cf9c
--- /dev/null
+++ b/MailSharp.MailClient/Services/AccountStore.cs
@@ -0,0 +1,52 @@
+using LiteDB;
+using MailSharp.MailClient.Models;
+
+namespace MailSharp.MailClient.Services;
+
+public interface IAccountStore
+{
+ Account Add(Account account, string plainPassword);
+ Account? Get(int id);
+ List<Account> GetAll();
+ void Remove(int id);
+ void Update(Account account);
+ string GetPlainPassword(Account account);
+}
+
+// Holds a single long-lived LiteDatabase connection for the app's lifetime (registered as a
+// singleton). LiteDatabase is thread-safe for concurrent operations; opening/closing a new
+// connection per call instead would race on the OS file lock under concurrent requests.
+public class LiteDbAccountStore : IAccountStore, IDisposable
+{
+ private readonly LiteDatabase _db;
+ private readonly IPasswordProtector _protector;
+
+ public LiteDbAccountStore(IWebHostEnvironment env, IPasswordProtector protector)
+ {
+ _protector = protector;
+ var dir = Path.Combine(env.ContentRootPath, "App_Data");
+ Directory.CreateDirectory(dir);
+ _db = new LiteDatabase(Path.Combine(dir, "accounts.db"));
+ }
+
+ private ILiteCollection<Account> Accounts => _db.GetCollection<Account>("accounts");
+
+ public Account Add(Account account, string plainPassword)
+ {
+ account.ProtectedPassword = _protector.Protect(plainPassword);
+ Accounts.Insert(account);
+ return account;
+ }
+
+ public Account? Get(int id) => Accounts.FindById(id);
+
+ public List<Account> GetAll() => [.. Accounts.FindAll()];
+
+ public void Remove(int id) => Accounts.Delete(id);
+
+ public void Update(Account account) => Accounts.Update(account);
+
+ public string GetPlainPassword(Account account) => _protector.Unprotect(account.ProtectedPassword);
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/MailSharp.MailClient/Services/ContactStore.cs b/MailSharp.MailClient/Services/ContactStore.cs
new file mode 100644
index 0000000..2262777
--- /dev/null
+++ b/MailSharp.MailClient/Services/ContactStore.cs
@@ -0,0 +1,74 @@
+using LiteDB;
+using MailSharp.MailClient.Models;
+
+namespace MailSharp.MailClient.Services;
+
+public interface IContactStore
+{
+ string? GetDisplayName(int accountId, string email);
+ void SetDisplayName(int accountId, string email, string displayName);
+
+ // Cached contact address list, keyed by account. Null means "never populated yet" - the
+ // caller should fetch from IMAP once and call SaveContacts.
+ List<ContactAddress>? GetCachedContacts(int accountId);
+ void SaveContacts(int accountId, List<ContactAddress> contacts);
+
+ // Merges newly-seen recipients (from a send/reply/forward) into the existing cache without
+ // re-fetching the whole Sent folder. No-op if the cache hasn't been populated yet - the next
+ // full list request will pick these up anyway.
+ void AddContacts(int accountId, IEnumerable<ContactAddress> contacts);
+}
+
+// Stores the user's own edits to a contact's display name, plus the cached contact address list
+// itself, keyed by account. The address list is expensive to build (a full walk of the Sent
+// folder over IMAP - see IImapService.GetSentContactsAsync), so it's fetched once and then kept
+// up to date incrementally as mail is sent, rather than being re-derived on every page load.
+public class LiteDbContactStore : IContactStore, IDisposable
+{
+ private readonly LiteDatabase _db;
+
+ public LiteDbContactStore(IWebHostEnvironment env)
+ {
+ var appDataDir = Path.Combine(env.ContentRootPath, "App_Data");
+ Directory.CreateDirectory(appDataDir);
+ _db = new LiteDatabase(Path.Combine(appDataDir, "contacts.db"));
+ }
+
+ private ILiteCollection<ContactOverride> Overrides => _db.GetCollection<ContactOverride>("contactOverrides");
+ private ILiteCollection<ContactCache> Caches => _db.GetCollection<ContactCache>("contactCaches");
+
+ private static string Key(int accountId, string email) => $"{accountId}:{email.Trim().ToLowerInvariant()}";
+
+ public string? GetDisplayName(int accountId, string email) =>
+ Overrides.FindById(Key(accountId, email))?.DisplayName;
+
+ public void SetDisplayName(int accountId, string email, string displayName) =>
+ Overrides.Upsert(new ContactOverride { Id = Key(accountId, email), DisplayName = displayName });
+
+ public List<ContactAddress>? GetCachedContacts(int accountId) =>
+ Caches.FindById(accountId)?.Contacts;
+
+ public void SaveContacts(int accountId, List<ContactAddress> contacts) =>
+ Caches.Upsert(new ContactCache { Id = accountId, Contacts = contacts });
+
+ public void AddContacts(int accountId, IEnumerable<ContactAddress> contacts)
+ {
+ var cache = Caches.FindById(accountId);
+ if (cache == null) return;
+
+ var byEmail = cache.Contacts.ToDictionary(c => c.Email, StringComparer.OrdinalIgnoreCase);
+ var changed = false;
+ foreach (var c in contacts)
+ {
+ if (string.IsNullOrWhiteSpace(c.Email) || byEmail.ContainsKey(c.Email)) continue;
+ byEmail[c.Email] = c;
+ changed = true;
+ }
+
+ if (!changed) return;
+ cache.Contacts = [.. byEmail.Values.OrderBy(c => c.Email)];
+ Caches.Update(cache);
+ }
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/MailSharp.MailClient/Services/FolderSettingsStore.cs b/MailSharp.MailClient/Services/FolderSettingsStore.cs
new file mode 100644
index 0000000..ee43ff1
--- /dev/null
+++ b/MailSharp.MailClient/Services/FolderSettingsStore.cs
@@ -0,0 +1,59 @@
+using LiteDB;
+using MailSharp.MailClient.Models;
+
+namespace MailSharp.MailClient.Services;
+
+public interface IFolderSettingsStore
+{
+ Dictionary<string, FolderSetting> GetAll(int accountId);
+ void Save(int accountId, string folderFullName, FolderSyncMode syncMode, bool subscribed);
+ void Remove(int accountId, string folderFullName);
+
+ // Display-only ordering of folders in the main menu (Settings > Mappen beheren) - purely
+ // cosmetic, never touches the actual IMAP folder structure. Folders not mentioned here (new
+ // ones, or ones never reordered) are appended after the ordered ones in their natural IMAP
+ // discovery order - see IImapService.GetFoldersAsync.
+ List<string> GetOrder(int accountId);
+ void SaveOrder(int accountId, List<string> folderFullNamesInOrder);
+}
+
+// Stores the user's per-folder sync mode/subscription choices (Settings > Mappen beheren), keyed
+// by (account, folder full name) - separate from the folder list itself, which is always
+// re-fetched live from IMAP (see IImapService.GetFoldersAsync) and has no durable identity of its
+// own beyond its full name.
+public class LiteDbFolderSettingsStore : IFolderSettingsStore, IDisposable
+{
+ private readonly LiteDatabase _db;
+
+ public LiteDbFolderSettingsStore(IWebHostEnvironment env)
+ {
+ var dir = Path.Combine(env.ContentRootPath, "App_Data");
+ Directory.CreateDirectory(dir);
+ _db = new LiteDatabase(Path.Combine(dir, "foldersettings.db"));
+ }
+
+ private ILiteCollection<FolderSetting> Settings => _db.GetCollection<FolderSetting>("folderSettings");
+ private ILiteCollection<FolderOrder> Orders => _db.GetCollection<FolderOrder>("folderOrders");
+
+ private static string Key(int accountId, string folderFullName) => $"{accountId}:{folderFullName}";
+
+ public Dictionary<string, FolderSetting> GetAll(int accountId)
+ {
+ var prefix = accountId + ":";
+ return Settings.Find(x => x.Id.StartsWith(prefix))
+ .ToDictionary(x => x.Id[prefix.Length..], x => x);
+ }
+
+ public void Save(int accountId, string folderFullName, FolderSyncMode syncMode, bool subscribed) =>
+ Settings.Upsert(new FolderSetting { Id = Key(accountId, folderFullName), SyncMode = syncMode, Subscribed = subscribed });
+
+ public void Remove(int accountId, string folderFullName) =>
+ Settings.Delete(Key(accountId, folderFullName));
+
+ public List<string> GetOrder(int accountId) => Orders.FindById(accountId)?.Names ?? [];
+
+ public void SaveOrder(int accountId, List<string> folderFullNamesInOrder) =>
+ Orders.Upsert(new FolderOrder { Id = accountId, Names = folderFullNamesInOrder });
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/MailSharp.MailClient/Services/IPasswordProtector.cs b/MailSharp.MailClient/Services/IPasswordProtector.cs
new file mode 100644
index 0000000..5eaf534
--- /dev/null
+++ b/MailSharp.MailClient/Services/IPasswordProtector.cs
@@ -0,0 +1,62 @@
+using System.Security.Cryptography;
+
+namespace MailSharp.MailClient.Services;
+
+public interface IPasswordProtector
+{
+ string Protect(string plainText);
+ string Unprotect(string protectedText);
+}
+
+// AES-GCM encryption using a key material file kept outside wwwroot.
+// Not a substitute for a real secrets manager, but avoids storing plaintext passwords in LiteDB.
+public class AesPasswordProtector : IPasswordProtector
+{
+ private readonly byte[] _key;
+
+ public AesPasswordProtector(IWebHostEnvironment env)
+ {
+ var keyPath = Path.Combine(env.ContentRootPath, "App_Data", "protect.key");
+ Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
+ if (File.Exists(keyPath))
+ {
+ _key = Convert.FromBase64String(File.ReadAllText(keyPath));
+ }
+ else
+ {
+ _key = RandomNumberGenerator.GetBytes(32);
+ File.WriteAllText(keyPath, Convert.ToBase64String(_key));
+ }
+ }
+
+ public string Protect(string plainText)
+ {
+ var nonce = RandomNumberGenerator.GetBytes(12);
+ var plainBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
+ var cipher = new byte[plainBytes.Length];
+ var tag = new byte[16];
+ using (var aes = new AesGcm(_key, 16))
+ {
+ aes.Encrypt(nonce, plainBytes, cipher, tag);
+ }
+ var result = new byte[nonce.Length + tag.Length + cipher.Length];
+ Buffer.BlockCopy(nonce, 0, result, 0, nonce.Length);
+ Buffer.BlockCopy(tag, 0, result, nonce.Length, tag.Length);
+ Buffer.BlockCopy(cipher, 0, result, nonce.Length + tag.Length, cipher.Length);
+ return Convert.ToBase64String(result);
+ }
+
+ public string Unprotect(string protectedText)
+ {
+ var data = Convert.FromBase64String(protectedText);
+ var nonce = data[..12];
+ var tag = data[12..28];
+ var cipher = data[28..];
+ var plain = new byte[cipher.Length];
+ using (var aes = new AesGcm(_key, 16))
+ {
+ aes.Decrypt(nonce, cipher, tag, plain);
+ }
+ return System.Text.Encoding.UTF8.GetString(plain);
+ }
+}
diff --git a/MailSharp.MailClient/Services/ImageSanitizer.cs b/MailSharp.MailClient/Services/ImageSanitizer.cs
new file mode 100644
index 0000000..72b2fb7
--- /dev/null
+++ b/MailSharp.MailClient/Services/ImageSanitizer.cs
@@ -0,0 +1,59 @@
+using System.Text.RegularExpressions;
+
+namespace MailSharp.MailClient.Services;
+
+// When external images are blocked for a sender, <img> src attributes are replaced with a
+// harmless placeholder. When allowed, the src is rewritten to a local
+// /cache/images/{emailAddress}/{messageId}/{01, 02, ...}.ext path - numbered in the order the
+// images appear in the message, so it's obvious which cached files belong to which message - and
+// the original URL is kept both in a data-url attribute and in a mapping so the image handler can
+// download and cache it the first time it's actually requested.
+public partial class ImageSanitizer(IMailCacheStore cacheStore)
+{
+ [GeneratedRegex(@"(<img\b[^>]*?\bsrc\s*=\s*)(['""])(https?://[^'""]+)\2", RegexOptions.IgnoreCase)]
+ private static partial Regex ImgSrcRegex();
+
+ public (string Html, bool HasExternalImages) ApplyPolicy(string html, bool allowExternalImages, string emailAddress, string messageId)
+ {
+ if (string.IsNullOrEmpty(html)) return (html, false);
+
+ var hasExternal = false;
+ var index = 0;
+ var result = ImgSrcRegex().Replace(html, m =>
+ {
+ hasExternal = true;
+ var prefix = m.Groups[1].Value;
+ var quote = m.Groups[2].Value;
+ var url = m.Groups[3].Value;
+
+ if (!allowExternalImages)
+ {
+ return $"{prefix}{quote}about:blank{quote} data-blocked-src={quote}{url}{quote}";
+ }
+
+ index++;
+ var extension = ExtractExtension(url);
+ var fileName = $"{index:D2}{extension}";
+ cacheStore.SaveImageSourceUrl(emailAddress, messageId, fileName, url);
+
+ var src = $"/cache/images/{Uri.EscapeDataString(emailAddress)}/{Uri.EscapeDataString(messageId)}/{fileName}";
+ return $"{prefix}{quote}{src}{quote} data-url={quote}{url}{quote}";
+ });
+ return (result, hasExternal);
+ }
+
+ private static string ExtractExtension(string url)
+ {
+ var qIndex = url.IndexOf('?');
+ var basePart = qIndex >= 0 ? url[..qIndex] : url;
+
+ var lastSlash = basePart.LastIndexOf('/');
+ var lastSegment = lastSlash >= 0 ? basePart[(lastSlash + 1)..] : basePart;
+
+ var dotIndex = lastSegment.LastIndexOf('.');
+ if (dotIndex <= 0) return "";
+
+ var sanitized = new string([.. lastSegment[(dotIndex + 1)..].Where(char.IsLetterOrDigit)]);
+ return sanitized.Length > 0 ? "." + sanitized : "";
+ }
+}
diff --git a/MailSharp.MailClient/Services/ImapService.cs b/MailSharp.MailClient/Services/ImapService.cs
new file mode 100644
index 0000000..b48780d
--- /dev/null
+++ b/MailSharp.MailClient/Services/ImapService.cs
@@ -0,0 +1,1108 @@
+using MailKit;
+using MailKit.Net.Imap;
+using MailKit.Search;
+using MailSharp.MailClient.Models;
+using Microsoft.Extensions.Options;
+using MimeKit;
+using System.Collections.Concurrent;
+using MailFolder = MailSharp.MailClient.Models.MailFolder;
+using ModelPriority = MailSharp.MailClient.Models.MessagePriority;
+
+namespace MailSharp.MailClient.Services;
+
+public interface IImapService
+{
+ // includeSizes is false by default on purpose: computing folder sizes can mean a full
+ // per-message header scan (see ResolveFolderSizeAsync) when the server doesn't support IMAP's
+ // STATUS SIZE extension, so only the Settings > Mappen beheren page (the only place sizes are
+ // ever shown) should ask for it - the Mail app's folder sidebar and the login connectivity
+ // check never display sizes and shouldn't pay for computing them.
+ Task<List<MailFolder>> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, CancellationToken ct = default);
+ Task CreateFolderAsync(Account account, string password, string folderName, CancellationToken ct = default);
+
+ // Refuses to delete non-empty folders (checked via IMAP message count, not the cached
+ // UnreadCount) rather than silently expunging their contents - returns the subset of the
+ // requested names that were skipped for that reason, so the caller can report it.
+ Task<List<string>> DeleteFoldersAsync(Account account, string password, IEnumerable<string> folderFullNames, CancellationToken ct = default);
+ Task<List<MessageListItem>> GetMessagesAsync(Account account, string password, string folderFullName, bool forceRefresh = false, CancellationToken ct = default);
+ Task<MessageDetail> GetMessageAsync(Account account, string password, string folderFullName, uint uid, CancellationToken ct = default);
+ Task<Stream> GetAttachmentAsync(Account account, string password, string folderFullName, uint uid, int partIndex, CancellationToken ct = default);
+ Task SaveDraftAsync(Account account, string password, ComposeModel compose, IEnumerable<(string FileName, Stream Content)> attachments, IEnumerable<(string Cid, string FileName, Stream Content)> inlineImages, CancellationToken ct = default);
+ Task AppendSentAsync(Account account, string password, MimeMessage message, CancellationToken ct = default);
+ Task SetSeenAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, bool seen, CancellationToken ct = default);
+ Task SetFlaggedAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, bool flagged, CancellationToken ct = default);
+ Task MarkAllAsync(Account account, string password, string folderFullName, bool seen, CancellationToken ct = default);
+ Task MoveAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, string targetFolderFullName, CancellationToken ct = default);
+ Task DeleteAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, CancellationToken ct = default);
+ Task EmptyFolderAsync(Account account, string password, string folderFullName, CancellationToken ct = default);
+ Task<List<ContactAddress>> GetSentContactsAsync(Account account, string password, CancellationToken ct = default);
+ Task<List<MessageSearchResult>> SearchByAddressAsync(Account account, string password, string address, bool forceRefresh = false, CancellationToken ct = default);
+}
+
+public class ImapService(
+ IMailCacheStore cacheStore,
+ IFolderSettingsStore folderSettingsStore,
+ IMessageIndexStore indexStore,
+ IOptions<MailSettings> mailSettings,
+ ImageSanitizer imageSanitizer,
+ ILogger<ImapService> logger) : IImapService
+{
+ private readonly MailSettings _settings = mailSettings.Value;
+
+ // De-dup guard so a folder never gets indexed by two overlapping background builds at once
+ // (e.g. two browser tabs, or a page reload while the first build is still running). Process-
+ // wide (static) rather than per-instance since ImapService is scoped per request. Internal (not
+ // private) so BackgroundIndexingStatus can read it for the Maintenance page without needing to
+ // route indexing calls through a new service.
+ internal static readonly ConcurrentDictionary<string, byte> IndexingInProgress = new();
+
+ // Caps how many background index builds run their IMAP connection at once, process-wide. Most
+ // IMAP servers cap concurrent connections per account - without this, clicking through several
+ // never-indexed folders in a row would fire off that many simultaneous background connections,
+ // competing with (and slowing down) whatever the user is actually waiting on in the foreground.
+ internal const int MaxConcurrentBackgroundIndexing = 2;
+ internal static readonly SemaphoreSlim BackgroundIndexingThrottle = new(MaxConcurrentBackgroundIndexing, MaxConcurrentBackgroundIndexing);
+
+ // Building a folder's index for the first time means a full IMAP scan (see
+ // EnsureFolderIndexedAsync) - too slow to do inline within the request that triggered it (the
+ // user would stare at a blank screen). Instead this runs it on a background Task using its own
+ // fresh IMAP connection, while the caller falls back to the old capped-live-fetch path for this
+ // one request; once the background build finishes, the next request finds the index ready and
+ // takes the fast path. Deliberately uses no request-scoped state (only the static ConnectAsync
+ // and the singleton-backed index/settings stores captured on `this`), so it's safe to keep
+ // running after the HTTP request that started it has already completed.
+ private void StartBackgroundIndexBuild(Account account, string password, string folderFullName)
+ {
+ var key = $"{account.Id}:{folderFullName}";
+ if (!IndexingInProgress.TryAdd(key, 0))
+ {
+ logger.LogInformation("Background index build for account {AccountId} folder {Folder} already in progress, skipping duplicate trigger", account.Id, folderFullName);
+ return;
+ }
+
+ logger.LogInformation("Background index build queued for account {AccountId} folder {Folder}", account.Id, folderFullName);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ // Let the page load that triggered this finish its own IMAP connections first
+ // (folder list, live message fallback) instead of immediately competing for the same
+ // account's connection slots.
+ await Task.Delay(TimeSpan.FromSeconds(3));
+ await BackgroundIndexingThrottle.WaitAsync();
+ try
+ {
+ await EnsureFolderIndexedAsync(account, password, folderFullName, CancellationToken.None);
+ logger.LogInformation("Background index build completed for account {AccountId} folder {Folder} in {DurationMs}ms, {Count} messages",
+ account.Id, folderFullName, sw.ElapsedMilliseconds, indexStore.GetMessageCount(account.Id, folderFullName));
+ }
+ finally
+ {
+ BackgroundIndexingThrottle.Release();
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Background index build failed for account {AccountId} folder {Folder} after {DurationMs}ms", account.Id, folderFullName, sw.ElapsedMilliseconds);
+ }
+ finally
+ {
+ IndexingInProgress.TryRemove(key, out _);
+ }
+ });
+ }
+
+ // SpecialFolder.* lookups rely on the server advertising IMAP SPECIAL-USE/XLIST - servers that
+ // don't (like this one) make MailKit throw NotSupportedException rather than returning null.
+ // Falls back to matching common folder names (English/Dutch) among the account's actual
+ // folders, then to a couple of hardcoded full-name guesses; returns null (rather than throwing)
+ // if nothing matches, since some callers (e.g. "delete" falling back to expunge when there's no
+ // Trash) treat "no such folder" as a valid, handleable outcome.
+ private static async Task<IMailFolder?> ResolveFolderOrNullAsync(ImapClient client, SpecialFolder special, string[] nameHints, string[] nameGuesses, CancellationToken ct)
+ {
+ IMailFolder? folder = null;
+ try { folder = client.GetFolder(special); }
+ catch (NotSupportedException) { /* server doesn't advertise SPECIAL-USE/XLIST - fall through */ }
+ if (folder != null) return folder;
+
+ var personal = client.GetFolder(client.PersonalNamespaces[0]);
+ var subfolders = await personal.GetSubfoldersAsync(true, ct);
+ var byName = subfolders.FirstOrDefault(f => nameHints.Any(h => f.Name.Contains(h, StringComparison.OrdinalIgnoreCase)));
+ if (byName != null) return byName;
+
+ foreach (var guess in nameGuesses)
+ {
+ try { return await client.GetFolderAsync(guess, ct); }
+ catch { /* try next guess */ }
+ }
+
+ return null;
+ }
+
+ private static readonly string[] SentNameHints = ["sent", "verzonden"];
+ private static readonly string[] SentNameGuesses = ["Sent", "Sent Items", "Sent Messages", "INBOX.Sent", "INBOX/Sent"];
+ private static readonly string[] DraftsNameHints = ["draft", "concept"];
+ private static readonly string[] DraftsNameGuesses = ["Drafts", "INBOX.Drafts", "INBOX/Drafts"];
+ private static readonly string[] TrashNameHints = ["trash", "deleted", "prullenbak"];
+ private static readonly string[] TrashNameGuesses = ["Trash", "Deleted Items", "INBOX.Trash", "INBOX/Trash"];
+ private static readonly string[] JunkNameHints = ["spam", "junk", "unwanted", "ongewenst"];
+
+ // INBOX (by definition) and whatever folder is filling Sent/Trash/Drafts/Junk's role can't be
+ // deleted (see DeleteFoldersAsync) since core features assume they exist - detected the same
+ // way as the Resolve*FolderAsync helpers above: SPECIAL-USE attributes first, common name
+ // hints as a fallback for servers that don't advertise SPECIAL-USE.
+ private static bool IsProtectedFolder(string fullName, string name, FolderAttributes attributes)
+ {
+ if (string.Equals(fullName, "INBOX", StringComparison.OrdinalIgnoreCase)) return true;
+ if ((attributes & (FolderAttributes.Inbox | FolderAttributes.Sent | FolderAttributes.Trash | FolderAttributes.Drafts | FolderAttributes.Junk)) != 0) return true;
+
+ return SentNameHints.Concat(TrashNameHints).Concat(DraftsNameHints).Concat(JunkNameHints)
+ .Any(h => name.Contains(h, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static async Task<IMailFolder> ResolveSentFolderAsync(ImapClient client, CancellationToken ct) =>
+ await ResolveFolderOrNullAsync(client, SpecialFolder.Sent, SentNameHints, SentNameGuesses, ct)
+ ?? throw new InvalidOperationException("Could not locate a Sent folder on this account.");
+
+ private static async Task<IMailFolder> ResolveDraftsFolderAsync(ImapClient client, CancellationToken ct) =>
+ await ResolveFolderOrNullAsync(client, SpecialFolder.Drafts, DraftsNameHints, DraftsNameGuesses, ct)
+ ?? throw new InvalidOperationException("Could not locate a Drafts folder on this account.");
+
+ private static Task<IMailFolder?> ResolveTrashFolderAsync(ImapClient client, CancellationToken ct) =>
+ ResolveFolderOrNullAsync(client, SpecialFolder.Trash, TrashNameHints, TrashNameGuesses, ct);
+
+ private async Task<ImapClient> ConnectAsync(Account account, string password, CancellationToken ct)
+ {
+ var client = new ImapClient();
+ var secureSocket = account.ImapSecurity switch
+ {
+ SecurityMode.SslTls => MailKit.Security.SecureSocketOptions.SslOnConnect,
+ SecurityMode.StartTls => MailKit.Security.SecureSocketOptions.StartTls,
+ _ => MailKit.Security.SecureSocketOptions.None
+ };
+ try
+ {
+ await client.ConnectAsync(account.ImapHost, account.ImapPort, secureSocket, ct);
+ await client.AuthenticateAsync(account.Username, password, ct);
+ return client;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "IMAP connect/authenticate failed for account {AccountId} against {Host}:{Port}", account.Id, account.ImapHost, account.ImapPort);
+ throw;
+ }
+ }
+
+ public async Task<List<MailFolder>> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id });
+ using var client = await ConnectAsync(account, password, ct);
+ var result = new List<MailFolder>();
+ var personal = client.GetFolder(client.PersonalNamespaces[0]);
+ var folders = await personal.GetSubfoldersAsync(true, ct);
+ var settings = folderSettingsStore.GetAll(account.Id);
+
+ foreach (var f in new[] { client.Inbox }.Concat(folders))
+ {
+ if (result.Any(x => x.FullName == f.FullName)) continue;
+
+ var setting = settings.GetValueOrDefault(f.FullName);
+ var depth = FolderDepth(f, personal);
+ var isProtected = IsProtectedFolder(f.FullName, f.Name, f.Attributes);
+
+ try
+ {
+ // STATUS rather than SELECT/Open - cheaper (no mailbox actually gets opened). Only
+ // asks for SIZE (RFC 8438) when the caller actually wants sizes - not every server
+ // supports that extension, so retry without it rather than losing Count/Unread too.
+ if (includeSizes)
+ {
+ try { await f.StatusAsync(StatusItems.Count | StatusItems.Unread | StatusItems.Size, ct); }
+ catch { await f.StatusAsync(StatusItems.Count | StatusItems.Unread, ct); }
+ }
+ else
+ {
+ await f.StatusAsync(StatusItems.Count | StatusItems.Unread, ct);
+ }
+
+ var (sizeBytes, isSizeEstimated) = includeSizes
+ ? await ResolveFolderSizeAsync(account, password, f, setting?.SyncMode ?? FolderSyncMode.Direct, forceRefreshSizes, ct)
+ : (0, false);
+
+ result.Add(new MailFolder
+ {
+ FullName = f.FullName,
+ DisplayName = f.Name,
+ UnreadCount = f.Unread,
+ MessageCount = f.Count,
+ IsSelectable = true,
+ Depth = depth,
+ SyncMode = setting?.SyncMode ?? FolderSyncMode.Direct,
+ Subscribed = setting?.Subscribed ?? true,
+ SizeBytes = sizeBytes,
+ IsSizeEstimated = isSizeEstimated,
+ IsProtected = isProtected
+ });
+ }
+ catch
+ {
+ result.Add(new MailFolder
+ {
+ FullName = f.FullName,
+ DisplayName = f.Name,
+ IsSelectable = false,
+ Depth = depth,
+ SyncMode = setting?.SyncMode ?? FolderSyncMode.Direct,
+ Subscribed = setting?.Subscribed ?? true,
+ IsProtected = isProtected
+ });
+ }
+ }
+ await client.DisconnectAsync(true, ct);
+ return ApplyDisplayOrder(result, folderSettingsStore.GetOrder(account.Id));
+ }
+
+ // Real total size: IMAP STATUS SIZE (RFC 8438) where the server supports it - already checked
+ // by the caller via f.Size. Otherwise this is the local message index's own total (see
+ // EnsureFolderIndexedAsync) - exact, not an estimate, and free once the folder is indexed.
+ // NotSynchronized folders are deliberately never indexed, so their size is simply unknown (0)
+ // rather than triggering the very full-folder scan that setting exists to avoid. A folder
+ // that's never been indexed yet also shows 0 for now - its first build runs in the background
+ // (see StartBackgroundIndexBuild) rather than blocking this page load; a later reload picks up
+ // the real total once that finishes.
+ private async Task<(long SizeBytes, bool IsEstimated)> ResolveFolderSizeAsync(Account account, string password, IMailFolder folder, FolderSyncMode syncMode, bool forceRebuild, CancellationToken ct)
+ {
+ if (folder.Size.HasValue) return ((long)folder.Size.Value, false);
+ if (syncMode == FolderSyncMode.NotSynchronized) return (0, false);
+
+ var state = indexStore.GetSyncState(account.Id, folder.FullName);
+ if (forceRebuild && state != null)
+ {
+ indexStore.RemoveFolder(account.Id, folder.FullName);
+ state = null;
+ }
+
+ if (state == null)
+ {
+ StartBackgroundIndexBuild(account, password, folder.FullName);
+ return (0, false);
+ }
+
+ try
+ {
+ await EnsureFolderIndexedAsync(account, password, folder.FullName, ct);
+ return (indexStore.GetTotalSize(account.Id, folder.FullName), false);
+ }
+ catch
+ {
+ return (0, false);
+ }
+ }
+
+ // Keeps a local header index (Envelope/Flags/Size per message) up to date without re-scanning
+ // everything on every access - the same headers GetMessagesAsync/GetFoldersAsync/
+ // SearchByAddressAsync/GetSentContactsAsync already needed, just fetched once and kept in sync
+ // instead of re-fetched (fully or partially) every time. No CONDSTORE required: UIDVALIDITY
+ // changing means the server considers the mailbox rebuilt (full reindex); UIDNEXT advancing
+ // means new mail arrived (cheap incremental fetch of just the new UID range); and the message
+ // count not matching the index's own row count is the cheap signal - already free from the
+ // STATUS call below - that something was removed (by this app or another IMAP client), which is
+ // the only case expensive enough (a UID-only SEARCH ALL) to want to avoid doing blindly.
+ //
+ // Deliberately doesn't fetch BODYSTRUCTURE: some servers send a slightly non-conformant one for
+ // certain (often malformed/spam) messages, and MailKit doesn't just skip that one message - a
+ // parse error mid-response desyncs the whole stream, disconnecting the client outright. The only
+ // thing BODYSTRUCTURE would have bought here is the message list's HasAttachments paperclip icon,
+ // not worth risking the entire index build over.
+ private async Task EnsureFolderIndexedAsync(Account account, string password, string folderFullName, CancellationToken ct)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderFullName });
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+
+ await folder.StatusAsync(StatusItems.Count | StatusItems.UidValidity | StatusItems.UidNext | StatusItems.Unread, ct);
+ var uidNext = folder.UidNext?.Id ?? 0;
+ var state = indexStore.GetSyncState(account.Id, folderFullName);
+
+ if (state == null || state.UidValidity != folder.UidValidity)
+ {
+ logger.LogInformation("Full reindex of folder {Folder} for account {AccountId} ({Reason})",
+ folderFullName, account.Id, state == null ? "first index" : "UIDVALIDITY changed");
+ await folder.OpenAsync(FolderAccess.ReadOnly, ct);
+ var allUids = await folder.SearchAsync(SearchQuery.All, ct);
+ var messages = allUids.Count > 0 ? await FetchIndexedMessagesAsync(folder, allUids, ct) : [];
+ await folder.CloseAsync(false, ct);
+
+ indexStore.ReplaceFolder(account.Id, folderFullName, messages);
+ indexStore.SaveSyncState(account.Id, folderFullName, folder.UidValidity, uidNext, folder.Count);
+ await client.DisconnectAsync(true, ct);
+ return;
+ }
+
+ if (uidNext > state.UidNext)
+ {
+ await folder.OpenAsync(FolderAccess.ReadOnly, ct);
+ var range = new UniqueIdRange(new UniqueId(state.UidNext), UniqueId.MaxValue);
+ var newMessages = await FetchIndexedMessagesAsync(folder, range, ct);
+ await folder.CloseAsync(false, ct);
+
+ if (newMessages.Count > 0) indexStore.UpsertMessages(account.Id, folderFullName, newMessages);
+ }
+
+ if (indexStore.GetMessageCount(account.Id, folderFullName) != folder.Count)
+ {
+ await folder.OpenAsync(FolderAccess.ReadOnly, ct);
+ var currentUids = (await folder.SearchAsync(SearchQuery.All, ct)).Select(u => u.Id).ToHashSet();
+ await folder.CloseAsync(false, ct);
+
+ var missing = indexStore.GetMessages(account.Id, folderFullName)
+ .Select(m => m.Uid)
+ .Where(uid => !currentUids.Contains(uid))
+ .ToList();
+ if (missing.Count > 0) indexStore.RemoveMessages(account.Id, folderFullName, missing);
+ }
+
+ // None of the checks above catch flag changes made to already-indexed messages by another
+ // IMAP client (or the server itself) - UIDVALIDITY/UIDNEXT/count are all unaffected by a
+ // Seen flag flipping on an existing UID, so a message marked unread elsewhere would
+ // otherwise stay "read" in our index forever. The live Unread count from the STATUS call
+ // above is free to compare against the index's own count; a mismatch means flags drifted,
+ // so re-fetch Envelope+Flags for every current UID and upsert (corrects IsRead/IsFlagged/etc
+ // for the whole folder) rather than trying to figure out which specific UIDs changed.
+ if (indexStore.GetUnreadCount(account.Id, folderFullName) != folder.Unread)
+ {
+ logger.LogInformation("Flag drift detected in folder {Folder} for account {AccountId}: index unread={IndexUnread}, server unread={ServerUnread} - refreshing flags",
+ folderFullName, account.Id, indexStore.GetUnreadCount(account.Id, folderFullName), folder.Unread);
+
+ await folder.OpenAsync(FolderAccess.ReadOnly, ct);
+ var allUids = await folder.SearchAsync(SearchQuery.All, ct);
+ var refreshed = allUids.Count > 0 ? await FetchIndexedMessagesAsync(folder, allUids, ct) : [];
+ await folder.CloseAsync(false, ct);
+
+ if (refreshed.Count > 0) indexStore.UpsertMessages(account.Id, folderFullName, refreshed);
+ }
+
+ indexStore.SaveSyncState(account.Id, folderFullName, folder.UidValidity, uidNext, folder.Count);
+ await client.DisconnectAsync(true, ct);
+ }
+
+ // Priority isn't covered by MessageSummaryItems' normal flags (Envelope/Flags/Size) - MailKit
+ // only exposes it by explicitly requesting the two headers senders actually use for it
+ // (Importance and the older X-Priority), fetched here rather than the whole header block to
+ // keep this as cheap as the rest of the index build.
+ private static readonly HeaderId[] PriorityHeaderFields = [HeaderId.Importance, HeaderId.XPriority];
+
+ private static async Task<List<IndexedMessage>> FetchIndexedMessagesAsync(IMailFolder folder, IList<UniqueId> uids, CancellationToken ct)
+ {
+ var summaries = await folder.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.Flags | MessageSummaryItems.Size, PriorityHeaderFields, ct);
+ return [.. summaries.Select(s => new IndexedMessage
+ {
+ Uid = s.UniqueId.Id,
+ Subject = s.Envelope?.Subject ?? "(no subject)",
+ From = s.Envelope?.From?.ToString() ?? "",
+ To = s.Envelope?.To?.ToString() ?? "",
+ Cc = s.Envelope?.Cc?.ToString() ?? "",
+ Date = s.Envelope?.Date ?? DateTimeOffset.MinValue,
+ SizeBytes = s.Size ?? 0,
+ IsRead = s.Flags?.HasFlag(MessageFlags.Seen) ?? false,
+ IsFlagged = s.Flags?.HasFlag(MessageFlags.Flagged) ?? false,
+ HasAttachments = false,
+ Priority = ParsePriorityHeaders(s.Headers)
+ })];
+ }
+
+ private static ModelPriority ParsePriorityHeaders(HeaderList? headers)
+ {
+ var importance = headers?[HeaderId.Importance]?.Trim();
+ if (string.Equals(importance, "high", StringComparison.OrdinalIgnoreCase)) return ModelPriority.High;
+ if (string.Equals(importance, "low", StringComparison.OrdinalIgnoreCase)) return ModelPriority.Low;
+
+ // X-Priority: 1-2 = High, 3 = Normal, 4-5 = Low (the de-facto convention since Outlook Express).
+ var xPriority = headers?[HeaderId.XPriority]?.Trim();
+ if (xPriority != null && xPriority.Length > 0 && char.IsDigit(xPriority[0]))
+ {
+ return xPriority[0] switch { '1' or '2' => ModelPriority.High, '4' or '5' => ModelPriority.Low, _ => ModelPriority.Normal };
+ }
+
+ return ModelPriority.Normal;
+ }
+
+ // Presentation-only reordering (Settings > Mappen beheren) - never touches the real IMAP
+ // folder structure. Folders mentioned in savedOrder come first, in that sequence; anything
+ // else (new folders discovered since the order was last saved) is appended afterwards in its
+ // original IMAP discovery order.
+ private static List<MailFolder> ApplyDisplayOrder(List<MailFolder> folders, List<string> savedOrder)
+ {
+ if (savedOrder.Count == 0) return folders;
+
+ var position = savedOrder.Select((name, i) => (name, i)).ToDictionary(x => x.name, x => x.i, StringComparer.Ordinal);
+ return [.. folders
+ .Select((f, naturalIndex) => (f, naturalIndex))
+ .OrderBy(x => position.TryGetValue(x.f.FullName, out var idx) ? idx : int.MaxValue)
+ .ThenBy(x => x.naturalIndex)
+ .Select(x => x.f)];
+ }
+
+ private static int FolderDepth(IMailFolder folder, IMailFolder root)
+ {
+ var depth = 0;
+ var parent = folder.ParentFolder;
+ while (parent != null && parent != root)
+ {
+ depth++;
+ parent = parent.ParentFolder;
+ }
+ return depth;
+ }
+
+ public async Task CreateFolderAsync(Account account, string password, string folderName, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderName });
+ using var client = await ConnectAsync(account, password, ct);
+ var personal = client.GetFolder(client.PersonalNamespaces[0]);
+ await personal.CreateAsync(folderName, true, ct);
+ await client.DisconnectAsync(true, ct);
+ logger.LogInformation("Created folder {Folder} for account {AccountId}", folderName, account.Id);
+ }
+
+ public async Task<List<string>> DeleteFoldersAsync(Account account, string password, IEnumerable<string> folderFullNames, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id });
+ using var client = await ConnectAsync(account, password, ct);
+ var failed = new List<string>();
+
+ foreach (var fullName in folderFullNames)
+ {
+ try
+ {
+ var folder = await client.GetFolderAsync(fullName, ct);
+ if (IsProtectedFolder(folder.FullName, folder.Name, folder.Attributes))
+ {
+ logger.LogWarning("Refused to delete protected folder {Folder} for account {AccountId}", fullName, account.Id);
+ failed.Add(fullName);
+ continue;
+ }
+
+ await folder.OpenAsync(FolderAccess.ReadOnly, ct);
+ var count = folder.Count;
+ await folder.CloseAsync(false, ct);
+
+ if (count > 0)
+ {
+ logger.LogWarning("Refused to delete non-empty folder {Folder} for account {AccountId} ({Count} messages)", fullName, account.Id, count);
+ failed.Add(fullName);
+ continue;
+ }
+ await folder.DeleteAsync(ct);
+ indexStore.RemoveFolder(account.Id, fullName);
+ logger.LogInformation("Deleted folder {Folder} for account {AccountId}", fullName, account.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to delete folder {Folder} for account {AccountId}", fullName, account.Id);
+ failed.Add(fullName);
+ }
+ }
+
+ await client.DisconnectAsync(true, ct);
+ return failed;
+ }
+
+ public async Task<List<MessageListItem>> GetMessagesAsync(Account account, string password, string folderFullName, bool forceRefresh = false, CancellationToken ct = default)
+ {
+ var syncMode = folderSettingsStore.GetAll(account.Id).GetValueOrDefault(folderFullName)?.SyncMode ?? FolderSyncMode.Direct;
+
+ // Indexed path: covers every folder except NotSynchronized - the local header index (see
+ // EnsureFolderIndexedAsync) holds every message, not just the last MessageListFetchLimit, and
+ // is kept in sync cheaply instead of being re-fetched (fully or partially) on every request.
+ if (syncMode != FolderSyncMode.NotSynchronized)
+ {
+ var state = indexStore.GetSyncState(account.Id, folderFullName);
+
+ // forceRefresh is deliberately a no-op here (unlike the NotSynchronized fallback below,
+ // which uses it to bypass a short TTL cache): EnsureFolderIndexedAsync already re-verifies
+ // against the live server on every call - cheaply, via STATUS - and mark-read/flag/move/
+ // delete already update the index immediately for their own uids. There's nothing a
+ // "refresh" needs to force here; treating it as "wipe and rebuild the whole folder" would
+ // only throw away an accurate index (and re-trigger the full-folder header scan) on every
+ // single mark-read/flag/move/delete, since the UI always requests a refresh after those.
+ if (state == null)
+ {
+ // First time this folder is seen (or just reset above): a full rebuild means scanning
+ // every message, far too slow to do inline - the user would stare at a blank screen.
+ // Kick it off in the background and fall through to the fast capped-live-fetch path
+ // below for this one request; once the background build lands, later requests take
+ // the fast indexed path above instead.
+ StartBackgroundIndexBuild(account, password, folderFullName);
+ }
+ else
+ {
+ await EnsureFolderIndexedAsync(account, password, folderFullName, ct);
+
+ return [.. indexStore.GetMessages(account.Id, folderFullName)
+ .OrderByDescending(m => m.Date)
+ .Select(m => new MessageListItem
+ {
+ Uid = m.Uid,
+ Subject = m.Subject,
+ From = m.From,
+ Date = m.Date,
+ SizeBytes = m.SizeBytes,
+ IsRead = m.IsRead,
+ IsFlagged = m.IsFlagged,
+ HasAttachments = m.HasAttachments,
+ Priority = m.Priority
+ })];
+ }
+ }
+
+ // Fallback path: used for NotSynchronized folders (unchanged, permanent behaviour for that
+ // mode) and for indexed folders whose very first index build is still running in the
+ // background above - capped live fetch (last MessageListFetchLimit messages) behind a short
+ // TTL cache, same as before the index existed.
+ if (!forceRefresh)
+ {
+ var cached = cacheStore.GetMessageList(account.Id, folderFullName, TimeSpan.FromMinutes(_settings.MessageListCacheTtlMinutes));
+ if (cached != null) return cached;
+ }
+
+ var indexed = await FetchLiveMessagesAsync(account, password, folderFullName, ct);
+
+ var items = indexed.Select(m => new MessageListItem
+ {
+ Uid = m.Uid,
+ Subject = m.Subject,
+ From = m.From,
+ Date = m.Date,
+ SizeBytes = m.SizeBytes,
+ IsRead = m.IsRead,
+ IsFlagged = m.IsFlagged,
+ HasAttachments = m.HasAttachments,
+ Priority = m.Priority
+ });
+
+ var result = new List<MessageListItem>([.. items.OrderByDescending(x => x.Date)]);
+ cacheStore.SaveMessageList(account.Id, folderFullName, result);
+ return result;
+ }
+
+ // Used for the NotSynchronized capped-live-fetch path.
+ private async Task<List<IndexedMessage>> FetchLiveMessagesAsync(Account account, string password, string folderFullName, CancellationToken ct)
+ {
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadOnly, ct);
+
+ var uids = await folder.SearchAsync(SearchQuery.All, ct);
+ var take = uids.Skip(Math.Max(0, uids.Count - _settings.MessageListFetchLimit)).ToList();
+ var messages = take.Count > 0 ? await FetchIndexedMessagesAsync(folder, take, ct) : [];
+ await client.DisconnectAsync(true, ct);
+ return messages;
+ }
+
+ public async Task<MessageDetail> GetMessageAsync(Account account, string password, string folderFullName, uint uid, CancellationToken ct = default)
+ {
+ var message = await LoadMimeMessageAsync(account, password, folderFullName, uid, ct);
+
+ // LoadMimeMessageAsync only sets the Seen flag on IMAP (and only on the very first open,
+ // before the .eml is cached - a cache hit skips even that) and marks the short-TTL live
+ // message-list cache - it never touches the persistent index (see IMessageIndexStore),
+ // which is what indexed folders actually read their read/unread state from (see
+ // GetMessagesAsync). Without this, an opened message's "read" state only ever showed up as
+ // a client-side-only flag that reverted to unread on the next list reload. No-ops harmlessly
+ // for folders that aren't indexed (message not present in the index) or already marked read.
+ indexStore.MarkRead(account.Id, folderFullName, [uid], true);
+
+ var html = message.HtmlBody ?? "";
+ if (!string.IsNullOrEmpty(html))
+ {
+ foreach (var part in message.BodyParts.OfType<MimePart>())
+ {
+ if (string.IsNullOrEmpty(part.ContentId) || part.Content == null) continue;
+ if (!html.Contains($"cid:{part.ContentId}", StringComparison.OrdinalIgnoreCase)) continue;
+
+ using var ms = new MemoryStream();
+ part.Content.DecodeTo(ms, ct);
+ var dataUri = $"data:{part.ContentType.MimeType};base64,{Convert.ToBase64String(ms.ToArray())}";
+ html = html.Replace($"cid:{part.ContentId}", dataUri, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ var senderEmail = message.From.Mailboxes.FirstOrDefault()?.Address ?? "";
+ var allowImages = cacheStore.GetAllowExternalImages(account.Id, senderEmail);
+ var messageId = ResolveMessageId(message, folderFullName, uid);
+ var (transformedHtml, hasExternal) = imageSanitizer.ApplyPolicy(html, allowImages, account.EmailAddress, messageId);
+
+ var detail = new MessageDetail
+ {
+ Uid = uid,
+ Subject = message.Subject ?? "(no subject)",
+ From = message.From.ToString(),
+ SenderEmail = senderEmail,
+ To = message.To.ToString(),
+ Cc = message.Cc.ToString(),
+ Date = message.Date,
+ HtmlBody = transformedHtml,
+ TextBody = message.TextBody ?? "",
+ HasExternalImages = hasExternal,
+ ImagesAllowed = allowImages,
+ Priority = message.Priority switch
+ {
+ MimeKit.MessagePriority.NonUrgent => ModelPriority.Low,
+ MimeKit.MessagePriority.Urgent => ModelPriority.High,
+ _ => ModelPriority.Normal
+ },
+ Sensitivity = ParseSensitivity(message.Headers["Sensitivity"])
+ };
+
+ int idx = 0;
+ foreach (var att in message.Attachments)
+ {
+ var fileName = att.ContentDisposition?.FileName ?? att.ContentType.Name ?? $"attachment{idx}";
+ long size = 0;
+ if (att is MimePart part && part.Content != null)
+ {
+ using var ms = new MemoryStream();
+ part.Content.DecodeTo(ms, ct);
+ size = ms.Length;
+ }
+ detail.Attachments.Add(new AttachmentInfo
+ {
+ FileName = fileName,
+ ContentType = att.ContentType.MimeType,
+ SizeBytes = size,
+ PartIndex = idx
+ });
+ idx++;
+ }
+
+ return detail;
+ }
+
+ // Reads the cached .eml if we already have one; otherwise fetches the message's exact raw
+ // bytes from IMAP (not a MimeMessage re-serialized via WriteToAsync, which would only be a
+ // logical reproduction - original header order/whitespace could differ, which matters e.g.
+ // for DKIM verification), marks it Seen, and writes those raw bytes to disk as-is - keyed by
+ // the message's own Message-ID rather than its (folder, uid), since a uid is only unique
+ // within one folder and changes when a message is moved. (folder, uid) is all we know before
+ // contacting IMAP though, so a small mapping to the Message-ID is kept for that lookup; a
+ // fresh IMAP round trip is still needed the first time a given (folder, uid) pair is seen
+ // (e.g. right after a move), but every later read - here and in GetAttachmentAsync - is then
+ // a local file read instead.
+ private async Task<MimeMessage> LoadMimeMessageAsync(Account account, string password, string folderFullName, uint uid, CancellationToken ct)
+ {
+ var messageId = cacheStore.GetMessageIdForUid(account.Id, folderFullName, uid);
+ if (messageId != null)
+ {
+ var cachedEml = cacheStore.GetMessageEml(account.EmailAddress, messageId);
+ if (cachedEml != null)
+ {
+ using var cachedStream = new MemoryStream(cachedEml);
+ return await MimeMessage.LoadAsync(cachedStream, ct);
+ }
+ }
+
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadWrite, ct);
+
+ byte[] rawBytes;
+ using (var rawStream = await folder.GetStreamAsync(new UniqueId(uid), ct))
+ using (var buffer = new MemoryStream())
+ {
+ await rawStream.CopyToAsync(buffer, ct);
+ rawBytes = buffer.ToArray();
+ }
+ await folder.AddFlagsAsync(new UniqueId(uid), MessageFlags.Seen, true, ct);
+ await client.DisconnectAsync(true, ct);
+
+ MimeMessage message;
+ using (var parseStream = new MemoryStream(rawBytes))
+ {
+ message = await MimeMessage.LoadAsync(parseStream, ct);
+ }
+
+ var resolvedMessageId = ResolveMessageId(message, folderFullName, uid);
+ cacheStore.SaveMessageEml(account.EmailAddress, resolvedMessageId, rawBytes);
+ cacheStore.SaveMessageIdForUid(account.Id, folderFullName, uid, resolvedMessageId);
+
+ cacheStore.MarkMessageRead(account.Id, folderFullName, uid);
+ return message;
+ }
+
+ // Shared by the .eml cache key and the image cache directory name, so both always agree on
+ // the same identity for a given message even when its Message-ID header is missing.
+ private static string ResolveMessageId(MimeMessage message, string folderFullName, uint uid) =>
+ string.IsNullOrWhiteSpace(message.MessageId) ? $"no-message-id-{folderFullName}-{uid}" : message.MessageId;
+
+ // "Sensitivity" isn't a MimeKit first-class property (unlike Priority/X-Priority) - it's a
+ // plain header this app itself writes on send (see MimeMessageFactory.Build) using the same
+ // values Outlook/OWA use, so parsing it back here just has to match that same vocabulary.
+ private static MessageSensitivity ParseSensitivity(string? headerValue) => headerValue?.Trim() switch
+ {
+ "Personal" => MessageSensitivity.Personal,
+ "Private" => MessageSensitivity.Private,
+ "Company-Confidential" => MessageSensitivity.Confidential,
+ _ => MessageSensitivity.Nothing
+ };
+
+ public async Task<Stream> GetAttachmentAsync(Account account, string password, string folderFullName, uint uid, int partIndex, CancellationToken ct = default)
+ {
+ // The uid -> Message-ID mapping alone (cheap LiteDB lookup) is enough to check the disk
+ // cache, so a cached attachment never requires loading/parsing the .eml at all.
+ var knownMessageId = cacheStore.GetMessageIdForUid(account.Id, folderFullName, uid);
+ if (knownMessageId != null)
+ {
+ var cached = cacheStore.GetAttachmentContent(account.EmailAddress, knownMessageId, partIndex);
+ if (cached != null) return new MemoryStream(cached);
+ }
+
+ var message = await LoadMimeMessageAsync(account, password, folderFullName, uid, ct);
+ var messageId = ResolveMessageId(message, folderFullName, uid);
+ var att = message.Attachments.ElementAt(partIndex);
+ var result = new MemoryStream();
+ if (att is MimePart part && part.Content != null)
+ {
+ part.Content.DecodeTo(result, ct);
+ result.Position = 0;
+ var fileName = att.ContentDisposition?.FileName ?? att.ContentType.Name ?? $"attachment{partIndex}";
+ cacheStore.SaveAttachmentContent(account.EmailAddress, messageId, partIndex, fileName, result.ToArray());
+ result.Position = 0;
+ }
+ return result;
+ }
+
+ public async Task SaveDraftAsync(Account account, string password, ComposeModel compose, IEnumerable<(string FileName, Stream Content)> attachments, IEnumerable<(string Cid, string FileName, Stream Content)> inlineImages, CancellationToken ct = default)
+ {
+ var message = MimeMessageFactory.Build(account, compose, attachments, inlineImages);
+ using var client = await ConnectAsync(account, password, ct);
+
+ var drafts = await ResolveDraftsFolderAsync(client, ct);
+ await drafts.AppendAsync(message, MessageFlags.Draft | MessageFlags.Seen, ct);
+ await client.DisconnectAsync(true, ct);
+ }
+
+ // Most SMTP servers never store a copy of what they relay - the client is expected to save its
+ // own copy to the Sent folder over IMAP, the same way any other webmail/desktop mail client does.
+ public async Task AppendSentAsync(Account account, string password, MimeMessage message, CancellationToken ct = default)
+ {
+ using var client = await ConnectAsync(account, password, ct);
+ try
+ {
+ var sent = await ResolveSentFolderAsync(client, ct);
+ await sent.AppendAsync(message, MessageFlags.Seen, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to save sent copy to the Sent folder for account {AccountId}", account.Id);
+ throw;
+ }
+ finally
+ {
+ await client.DisconnectAsync(true, ct);
+ }
+ }
+
+ public async Task SetSeenAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, bool seen, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderFullName });
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadWrite, ct);
+ var uidList = uids.Select(u => new UniqueId(u)).ToList();
+
+ var applied = await ApplySeenFlagWithVerificationAsync(folder, uidList, seen, ct);
+ await client.DisconnectAsync(true, ct);
+
+ indexStore.MarkRead(account.Id, folderFullName, applied, seen);
+ }
+
+ public async Task SetFlaggedAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, bool flagged, CancellationToken ct = default)
+ {
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadWrite, ct);
+ var uidList = uids.Select(u => new UniqueId(u)).ToList();
+ await folder.AddFlagsAsync(uidList, MessageFlags.Flagged, flagged, ct);
+ if (!flagged) await folder.RemoveFlagsAsync(uidList, MessageFlags.Flagged, true, ct);
+ await client.DisconnectAsync(true, ct);
+
+ indexStore.MarkFlagged(account.Id, folderFullName, uidList.Select(u => u.Id), flagged);
+ }
+
+ // One giant STORE command covering every UID in the folder has been observed (see the Vmtux
+ // investigation) to silently not apply to a handful of UIDs on at least one real server, even
+ // though the same command against a single UID at a time works fine for those exact messages -
+ // no exception, no server error, just a subset quietly not taking effect. Root cause is outside
+ // this app (server-side UID-set/command-length handling), so instead of trusting one big batch
+ // this chunks the request and then verifies + individually retries whatever didn't stick,
+ // mirroring the code path that's confirmed to work. Only UIDs verified to actually have the
+ // target flag afterward get written into the local index - never assumed from the request.
+ private const int FlagUpdateBatchSize = 50;
+
+ public async Task MarkAllAsync(Account account, string password, string folderFullName, bool seen, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderFullName });
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadWrite, ct);
+ var uids = await folder.SearchAsync(SearchQuery.All, ct);
+
+ var applied = await ApplySeenFlagWithVerificationAsync(folder, uids, seen, ct);
+ await client.DisconnectAsync(true, ct);
+
+ logger.LogInformation("Marked {Applied}/{Requested} messages as {SeenState} in folder {Folder} for account {AccountId}",
+ applied.Count, uids.Count, seen ? "read" : "unread", folderFullName, account.Id);
+
+ indexStore.MarkRead(account.Id, folderFullName, applied, seen);
+ }
+
+ // Applies the Seen flag in batches, then verifies against a fresh SEARCH and individually
+ // retries any UID that didn't stick - see MarkAllAsync's remarks. Returns the UIDs confirmed to
+ // actually be in the target state afterward (a strict subset of the input on a flaky server).
+ private async Task<List<uint>> ApplySeenFlagWithVerificationAsync(IMailFolder folder, IList<UniqueId> uids, bool seen, CancellationToken ct)
+ {
+ if (uids.Count == 0) return [];
+
+ for (var offset = 0; offset < uids.Count; offset += FlagUpdateBatchSize)
+ {
+ var batch = uids.Skip(offset).Take(FlagUpdateBatchSize).ToList();
+ try
+ {
+ if (seen) await folder.AddFlagsAsync(batch, MessageFlags.Seen, true, ct);
+ else await folder.RemoveFlagsAsync(batch, MessageFlags.Seen, true, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Batch flag update failed for {Count} messages in folder {Folder}, will retry individually", batch.Count, folder.FullName);
+ }
+ }
+
+ var mismatchQuery = seen ? SearchQuery.NotSeen : SearchQuery.Seen;
+ var stillMismatched = (await folder.SearchAsync(mismatchQuery, ct)).Where(uids.Contains).ToList();
+
+ var confirmed = uids.Except(stillMismatched).Select(u => u.Id).ToList();
+ if (stillMismatched.Count == 0) return confirmed;
+
+ logger.LogWarning("{Count} messages did not pick up the bulk flag update in folder {Folder}, retrying individually: {Uids}",
+ stillMismatched.Count, folder.FullName, string.Join(",", stillMismatched.Select(u => u.Id)));
+
+ foreach (var uid in stillMismatched)
+ {
+ try
+ {
+ if (seen) await folder.AddFlagsAsync(uid, MessageFlags.Seen, true, ct);
+ else await folder.RemoveFlagsAsync(uid, MessageFlags.Seen, true, ct);
+ confirmed.Add(uid.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Individual flag update also failed for uid {Uid} in folder {Folder} - leaving as-is", uid.Id, folder.FullName);
+ }
+ }
+
+ return confirmed;
+ }
+
+ public async Task MoveAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, string targetFolderFullName, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderFullName });
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadWrite, ct);
+ var target = await client.GetFolderAsync(targetFolderFullName, ct);
+ var uidList = uids.Select(u => new UniqueId(u)).ToList();
+ await folder.MoveToAsync(uidList, target, ct);
+ await client.DisconnectAsync(true, ct);
+ logger.LogInformation("Moved {Count} messages from {Folder} to {TargetFolder} for account {AccountId}", uidList.Count, folderFullName, targetFolderFullName, account.Id);
+
+ // The moved messages get new UIDs in targetFolderFullName - rather than guess at those, just
+ // drop them from the source folder's index now; the target folder picks them up as "new"
+ // UIDs (with flags intact, since IMAP MOVE preserves them) next time it's indexed.
+ indexStore.RemoveMessages(account.Id, folderFullName, uidList.Select(u => u.Id));
+ }
+
+ public async Task DeleteAsync(Account account, string password, string folderFullName, IEnumerable<uint> uids, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderFullName });
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadWrite, ct);
+ var uidList = uids.Select(u => new UniqueId(u)).ToList();
+
+ var trash = await ResolveTrashFolderAsync(client, ct);
+ if (trash != null && folder.FullName != trash.FullName)
+ {
+ await folder.MoveToAsync(uidList, trash, ct);
+ logger.LogInformation("Moved {Count} messages from {Folder} to Trash for account {AccountId}", uidList.Count, folderFullName, account.Id);
+ }
+ else
+ {
+ await folder.AddFlagsAsync(uidList, MessageFlags.Deleted, true, ct);
+ await folder.ExpungeAsync(ct);
+ logger.LogInformation("Expunged {Count} messages from {Folder} for account {AccountId}", uidList.Count, folderFullName, account.Id);
+ }
+ await client.DisconnectAsync(true, ct);
+
+ // Same reasoning as MoveAsync above: moved to Trash (new UIDs there, picked up on next
+ // index) or actually expunged (gone for good either way) - either way, gone from here now.
+ indexStore.RemoveMessages(account.Id, folderFullName, uidList.Select(u => u.Id));
+ }
+
+ public async Task EmptyFolderAsync(Account account, string password, string folderFullName, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderFullName });
+ using var client = await ConnectAsync(account, password, ct);
+ var folder = await client.GetFolderAsync(folderFullName, ct);
+ await folder.OpenAsync(FolderAccess.ReadWrite, ct);
+ var uids = await folder.SearchAsync(SearchQuery.All, ct);
+ if (uids.Count > 0)
+ {
+ await folder.AddFlagsAsync(uids, MessageFlags.Deleted, true, ct);
+ await folder.ExpungeAsync(ct);
+ }
+ await client.DisconnectAsync(true, ct);
+
+ indexStore.RemoveFolder(account.Id, folderFullName);
+ logger.LogInformation("Emptied folder {Folder} for account {AccountId} ({Count} messages)", folderFullName, account.Id, uids.Count);
+ }
+
+ public async Task<List<ContactAddress>> GetSentContactsAsync(Account account, string password, CancellationToken ct = default)
+ {
+ string sentFullName;
+ using (var client = await ConnectAsync(account, password, ct))
+ {
+ var sent = await ResolveSentFolderAsync(client, ct);
+ sentFullName = sent.FullName;
+ await client.DisconnectAsync(true, ct);
+ }
+
+ var syncMode = folderSettingsStore.GetAll(account.Id).GetValueOrDefault(sentFullName)?.SyncMode ?? FolderSyncMode.Direct;
+ var state = syncMode != FolderSyncMode.NotSynchronized ? indexStore.GetSyncState(account.Id, sentFullName) : null;
+
+ var result = new Dictionary<string, ContactAddress>(StringComparer.OrdinalIgnoreCase);
+
+ if (state != null)
+ {
+ // Already indexed: no IMAP fetch needed at all beyond the cheap sync check - To/Cc are
+ // stored as the same InternetAddressList.ToString() form MimeKit itself produces, so they
+ // parse straight back into MailboxAddress for extraction, same as the live path below.
+ await EnsureFolderIndexedAsync(account, password, sentFullName, ct);
+
+ foreach (var m in indexStore.GetMessages(account.Id, sentFullName))
+ AddContactRecipients(result, ParseMailboxes(m.To).Concat(ParseMailboxes(m.Cc)));
+
+ return [.. result.Values.OrderBy(c => c.Email)];
+ }
+
+ // NotSynchronized, or simply never indexed yet: do the (correct, if slower) live scan this
+ // once, same as before the index existed - and if it's the latter case, also kick off a
+ // background build so the next call takes the fast indexed path above instead.
+ if (syncMode != FolderSyncMode.NotSynchronized) StartBackgroundIndexBuild(account, password, sentFullName);
+
+ using var liveClient = await ConnectAsync(account, password, ct);
+ var liveSent = await liveClient.GetFolderAsync(sentFullName, ct);
+ await liveSent.OpenAsync(FolderAccess.ReadOnly, ct);
+ var uids = await liveSent.SearchAsync(SearchQuery.All, ct);
+ var summaries = await liveSent.FetchAsync(uids, MessageSummaryItems.Envelope, ct);
+ await liveClient.DisconnectAsync(true, ct);
+
+ foreach (var s in summaries)
+ AddContactRecipients(result, (s.Envelope?.To ?? []).Concat(s.Envelope?.Cc ?? []).OfType<MailboxAddress>());
+
+ return [.. result.Values.OrderBy(c => c.Email)];
+ }
+
+ private static void AddContactRecipients(Dictionary<string, ContactAddress> result, IEnumerable<MailboxAddress> recipients)
+ {
+ foreach (var mb in recipients)
+ {
+ if (string.IsNullOrWhiteSpace(mb.Address)) continue;
+ if (!result.ContainsKey(mb.Address))
+ {
+ result[mb.Address] = new ContactAddress { Email = mb.Address, Name = mb.Name ?? "" };
+ }
+ }
+ }
+
+ private static IEnumerable<MailboxAddress> ParseMailboxes(string addressListString)
+ {
+ if (string.IsNullOrWhiteSpace(addressListString)) return [];
+ try { return InternetAddressList.Parse(addressListString).OfType<MailboxAddress>(); }
+ catch { return []; }
+ }
+
+ public async Task<List<MessageSearchResult>> SearchByAddressAsync(Account account, string password, string address, bool forceRefresh = false, CancellationToken ct = default)
+ {
+ if (!forceRefresh)
+ {
+ var cached = cacheStore.GetContactMailSearch(account.Id, address, TimeSpan.FromMinutes(_settings.ContactMailSearchCacheTtlMinutes));
+ if (cached != null) return cached;
+ }
+
+ using var client = await ConnectAsync(account, password, ct);
+ var personal = client.GetFolder(client.PersonalNamespaces[0]);
+ var folders = await personal.GetSubfoldersAsync(true, ct);
+ var settings = folderSettingsStore.GetAll(account.Id);
+ var result = new List<MessageSearchResult>();
+ var query = SearchQuery.FromContains(address).Or(SearchQuery.ToContains(address));
+
+ foreach (var f in new[] { client.Inbox }.Concat(folders).DistinctBy(f => f.FullName))
+ {
+ var syncMode = settings.GetValueOrDefault(f.FullName)?.SyncMode ?? FolderSyncMode.Direct;
+ var alreadyIndexed = syncMode != FolderSyncMode.NotSynchronized && indexStore.GetSyncState(account.Id, f.FullName) != null;
+
+ // Already indexed: just keep it current and let the bulk SearchByAddress query below
+ // pick up this folder's matches - no per-folder IMAP search/fetch needed at all.
+ if (alreadyIndexed)
+ {
+ try { await EnsureFolderIndexedAsync(account, password, f.FullName, ct); }
+ catch { /* couldn't refresh right now - fall through using whatever's already indexed */ }
+ continue;
+ }
+
+ // Never indexed yet (or NotSynchronized): do the live per-folder scan this once, same as
+ // before the index existed, so this search isn't missing results while the background
+ // build (kicked off below, for the non-NotSynchronized case) is still running.
+ if (syncMode != FolderSyncMode.NotSynchronized) StartBackgroundIndexBuild(account, password, f.FullName);
+
+ try
+ {
+ await f.OpenAsync(FolderAccess.ReadOnly, ct);
+ var uids = await f.SearchAsync(query, ct);
+ if (uids.Count == 0) { await f.CloseAsync(false, ct); continue; }
+
+ var summaries = await f.FetchAsync(uids, MessageSummaryItems.Envelope, ct);
+ foreach (var s in summaries)
+ {
+ result.Add(new MessageSearchResult
+ {
+ Folder = f.FullName,
+ Uid = s.UniqueId.Id,
+ Subject = s.Envelope?.Subject ?? "(no subject)",
+ From = s.Envelope?.From?.ToString() ?? "",
+ Date = s.Envelope?.Date ?? DateTimeOffset.MinValue
+ });
+ }
+ await f.CloseAsync(false, ct);
+ }
+ catch { /* non-selectable folder */ }
+ }
+ await client.DisconnectAsync(true, ct);
+
+ result.AddRange(indexStore.SearchByAddress(account.Id, address).Select(m => new MessageSearchResult
+ {
+ Folder = m.Folder,
+ Uid = m.Uid,
+ Subject = m.Subject,
+ From = m.From,
+ Date = m.Date
+ }));
+
+ var ordered = new List<MessageSearchResult>([.. result.OrderByDescending(r => r.Date)]);
+ cacheStore.SaveContactMailSearch(account.Id, address, ordered);
+ return ordered;
+ }
+}
diff --git a/MailSharp.MailClient/Services/LocalizationService.cs b/MailSharp.MailClient/Services/LocalizationService.cs
new file mode 100644
index 0000000..f8471d0
--- /dev/null
+++ b/MailSharp.MailClient/Services/LocalizationService.cs
@@ -0,0 +1,112 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Text.Json;
+
+namespace MailSharp.MailClient.Services;
+
+public class LocalizationService(IHttpContextAccessor httpContextAccessor, IWebHostEnvironment env)
+{
+ private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor;
+ private readonly string _localizationPath = Path.Combine(env.ContentRootPath, "Localization");
+
+ private static readonly ConcurrentDictionary<string, Dictionary<string, string>> _cache = new();
+
+ public string CurrentLanguage
+ {
+ get
+ {
+ var ctx = _httpContextAccessor.HttpContext;
+ if (ctx?.Request.Cookies.TryGetValue("lang", out var lang) == true
+ && !string.IsNullOrEmpty(lang)
+ && File.Exists(Path.Combine(_localizationPath, lang + ".json")))
+ {
+ return lang;
+ }
+ return "nl";
+ }
+ }
+
+ public CultureInfo Culture
+ {
+ get
+ {
+ try { return CultureInfo.GetCultureInfo(CurrentLanguage); }
+ catch (CultureNotFoundException) { return CultureInfo.InvariantCulture; }
+ }
+ }
+
+ public IEnumerable<(string Code, string Name)> AvailableLanguages()
+ {
+ if (!Directory.Exists(_localizationPath))
+ yield break;
+
+ var langs = Directory.GetFiles(_localizationPath, "*.json")
+ .Select(file =>
+ {
+ var code = Path.GetFileNameWithoutExtension(file);
+ var dict = GetDictionary(code);
+ var name = dict.TryGetValue("__name__", out var n) ? n : code;
+ var order = dict.TryGetValue("__order__", out var o) && int.TryParse(o, out var oi) ? oi : 999;
+ return (code, name, order);
+ })
+ .OrderBy(x => x.order)
+ .ThenBy(x => x.name);
+
+ foreach (var (code, name, _) in langs)
+ yield return (code, name);
+ }
+
+ public string this[string key]
+ {
+ get
+ {
+ var lang = CurrentLanguage;
+ var dict = GetDictionary(lang);
+ if (dict.TryGetValue(key, out var val)) return val;
+
+ if (lang != "nl")
+ {
+ var fallback = GetDictionary("nl");
+ if (fallback.TryGetValue(key, out var fallbackVal)) return fallbackVal;
+ }
+
+ return key;
+ }
+ }
+
+ public string Format(string key, params object[] args)
+ {
+ try { return string.Format(this[key], args); }
+ catch { return this[key]; }
+ }
+
+ public bool LanguageExists(string? lang) =>
+ !string.IsNullOrEmpty(lang) && lang.Length <= 10 && lang.All(c => char.IsLetterOrDigit(c) || c == '-') &&
+ File.Exists(Path.Combine(_localizationPath, lang + ".json"));
+
+ public Dictionary<string, string> GetAllStrings(string? lang = null)
+ {
+ var code = LanguageExists(lang) ? lang! : CurrentLanguage;
+ return GetDictionary(code)
+ .Where(kv => !kv.Key.StartsWith("__"))
+ .ToDictionary(kv => kv.Key, kv => kv.Value);
+ }
+
+ private Dictionary<string, string> GetDictionary(string lang)
+ {
+ return _cache.GetOrAdd(lang, code =>
+ {
+ var file = Path.Combine(_localizationPath, code + ".json");
+ if (!File.Exists(file)) return [];
+ try
+ {
+ var json = File.ReadAllText(file);
+ return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? [];
+ }
+ catch
+ {
+ return [];
+ }
+ });
+ }
+}
diff --git a/MailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.cs b/MailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.cs
new file mode 100644
index 0000000..4719f20
--- /dev/null
+++ b/MailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.cs
@@ -0,0 +1,19 @@
+namespace MailSharp.MailClient.Services.Logging;
+
+public interface IBackgroundIndexingStatus
+{
+ int MaxConcurrentIndexing { get; }
+ int CurrentlyAvailableSlots { get; }
+ IReadOnlyCollection<string> InProgressKeys { get; }
+}
+
+// Thin read-only view over ImapService's process-wide static indexing state - that state is
+// already shared across every request (see ImapService.IndexingInProgress/
+// BackgroundIndexingThrottle), so this just exposes it for the Maintenance page instead of
+// routing background indexing through a new service.
+public class BackgroundIndexingStatus : IBackgroundIndexingStatus
+{
+ public int MaxConcurrentIndexing => ImapService.MaxConcurrentBackgroundIndexing;
+ public int CurrentlyAvailableSlots => ImapService.BackgroundIndexingThrottle.CurrentCount;
+ public IReadOnlyCollection<string> InProgressKeys => [.. ImapService.IndexingInProgress.Keys];
+}
diff --git a/MailSharp.MailClient/Services/Logging/LiteDbLoggerProvider.cs b/MailSharp.MailClient/Services/Logging/LiteDbLoggerProvider.cs
new file mode 100644
index 0000000..d985038
--- /dev/null
+++ b/MailSharp.MailClient/Services/Logging/LiteDbLoggerProvider.cs
@@ -0,0 +1,72 @@
+using MailSharp.MailClient.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MailSharp.MailClient.Services.Logging;
+
+// Custom ILogger sink that persists every log entry to LiteDB (see LogStore) so logs survive an
+// app restart and are visible/filterable on the Maintenance page - unlike the console provider,
+// which is lost the moment the process isn't attached to an interactive terminal (e.g. running as
+// a Windows Service). Registered alongside the console provider, not instead of it; both receive
+// the same events and both respect the standard Logging:LogLevel configuration, since this provider
+// does no filtering of its own beyond what ILoggerFactory already applies.
+public class LiteDbLoggerProvider(ILogStore logStore) : ILoggerProvider
+{
+ // LoggerExternalScopeProvider is the framework's own IExternalScopeProvider implementation
+ // (AsyncLocal-backed) - owning one here rather than sharing the host's means every
+ // logger.BeginScope() call made anywhere in the app is captured for this sink specifically,
+ // without depending on ISupportExternalScopeProvider wiring from the logging builder.
+ private readonly IExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider();
+
+ public ILogger CreateLogger(string categoryName) => new LiteDbLogger(categoryName, logStore, _scopeProvider);
+
+ public void Dispose() { }
+}
+
+public class LiteDbLogger(string categoryName, ILogStore logStore, IExternalScopeProvider scopeProvider) : ILogger
+{
+ public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
+ scopeProvider.Push(state);
+
+ public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
+
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+ {
+ if (!IsEnabled(logLevel)) return;
+
+ var scopeData = new Dictionary<string, string>();
+ scopeProvider.ForEachScope((scope, dict) => Flatten(scope, dict), scopeData);
+
+ int? accountId = scopeData.TryGetValue("accountId", out var accountIdText) && int.TryParse(accountIdText, out var id) ? id : null;
+
+ logStore.Add(new LogEntry
+ {
+ Timestamp = DateTime.UtcNow,
+ Level = logLevel,
+ Category = categoryName,
+ Message = formatter(state, exception),
+ Exception = exception?.ToString(),
+ EventId = eventId.Id == 0 ? null : eventId.Id,
+ AccountId = accountId,
+ ScopeData = scopeData
+ });
+ }
+
+ // BeginScope() is most commonly called with an IEnumerable<KeyValuePair<string,object>> (an
+ // anonymous object or Dictionary, as passed throughout ImapService/SmtpService/the API
+ // controllers) - flatten that shape; anything else just contributes its ToString() as a single
+ // unnamed entry so scopes built some other way still show up rather than being silently dropped.
+ private static void Flatten(object? scope, Dictionary<string, string> into)
+ {
+ if (scope is IEnumerable<KeyValuePair<string, object?>> pairs)
+ {
+ foreach (var kvp in pairs)
+ {
+ if (kvp.Value != null) into[kvp.Key] = kvp.Value.ToString() ?? "";
+ }
+ return;
+ }
+
+ if (scope != null) into[$"scope{into.Count}"] = scope.ToString() ?? "";
+ }
+}
diff --git a/MailSharp.MailClient/Services/Logging/LogPruningService.cs b/MailSharp.MailClient/Services/Logging/LogPruningService.cs
new file mode 100644
index 0000000..92873dc
--- /dev/null
+++ b/MailSharp.MailClient/Services/Logging/LogPruningService.cs
@@ -0,0 +1,37 @@
+using MailSharp.MailClient.Models;
+using Microsoft.Extensions.Options;
+
+namespace MailSharp.MailClient.Services.Logging;
+
+// Keeps App_Data/logs.db from growing unbounded - prunes on startup and then periodically, rather
+// than capping the collection size, so retention is time-based (predictable for someone reading
+// the Maintenance log viewer) instead of silently dropping whichever entries happen to be oldest
+// once some row-count limit is hit.
+public class LogPruningService(ILogStore logStore, IOptions<MailSettings> mailSettings, ILogger<LogPruningService> logger) : BackgroundService
+{
+ private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(6);
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ Prune();
+
+ using var timer = new PeriodicTimer(PruneInterval);
+ while (await timer.WaitForNextTickAsync(stoppingToken))
+ {
+ Prune();
+ }
+ }
+
+ private void Prune()
+ {
+ try
+ {
+ var retentionDays = Math.Max(1, mailSettings.Value.LogRetentionDays);
+ logStore.PruneOlderThan(DateTime.UtcNow.AddDays(-retentionDays));
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Log pruning failed");
+ }
+ }
+}
diff --git a/MailSharp.MailClient/Services/Logging/LogStore.cs b/MailSharp.MailClient/Services/Logging/LogStore.cs
new file mode 100644
index 0000000..336e8c8
--- /dev/null
+++ b/MailSharp.MailClient/Services/Logging/LogStore.cs
@@ -0,0 +1,83 @@
+using LiteDB;
+using MailSharp.MailClient.Models;
+using Microsoft.Extensions.Logging;
+
+namespace MailSharp.MailClient.Services.Logging;
+
+public class LogQuery
+{
+ // Exact match, not a minimum threshold - the Maintenance log viewer's dropdown is meant to
+ // isolate one level at a time (e.g. "just show me the errors"), not "this level and worse".
+ public LogLevel? Level { get; set; }
+ public string? Category { get; set; }
+ public int? AccountId { get; set; }
+ public DateTime? From { get; set; }
+ public DateTime? To { get; set; }
+ public string? Search { get; set; }
+ public int Page { get; set; } = 1;
+ public int PageSize { get; set; } = 50;
+}
+
+public class LogQueryResult
+{
+ public List<LogEntry> Items { get; set; } = [];
+ public int TotalCount { get; set; }
+}
+
+public interface ILogStore
+{
+ void Add(LogEntry entry);
+ LogQueryResult Query(LogQuery query);
+ void PruneOlderThan(DateTime cutoffUtc);
+}
+
+// Holds a single long-lived LiteDatabase connection for the app's lifetime (registered as a
+// singleton), same pattern as the other *Store classes - a fresh connection per call would race on
+// the OS file lock under concurrent requests.
+public class LiteDbLogStore : ILogStore, IDisposable
+{
+ private readonly LiteDatabase _db;
+
+ public LiteDbLogStore(IWebHostEnvironment env)
+ {
+ var dir = Path.Combine(env.ContentRootPath, "App_Data");
+ Directory.CreateDirectory(dir);
+ _db = new LiteDatabase(Path.Combine(dir, "logs.db"));
+
+ Logs.EnsureIndex(x => x.Timestamp);
+ Logs.EnsureIndex(x => x.Level);
+ Logs.EnsureIndex(x => x.Category);
+ Logs.EnsureIndex(x => x.AccountId);
+ }
+
+ private ILiteCollection<LogEntry> Logs => _db.GetCollection<LogEntry>("logs");
+
+ public void Add(LogEntry entry) => Logs.Insert(entry);
+
+ public LogQueryResult Query(LogQuery query)
+ {
+ var q = Logs.Query();
+
+ if (query.Level != null) q = q.Where(x => x.Level == query.Level.Value);
+ if (!string.IsNullOrWhiteSpace(query.Category)) q = q.Where(x => x.Category.Contains(query.Category));
+ if (query.AccountId != null) q = q.Where(x => x.AccountId == query.AccountId.Value);
+ if (query.From != null) q = q.Where(x => x.Timestamp >= query.From.Value);
+ if (query.To != null) q = q.Where(x => x.Timestamp <= query.To.Value);
+ if (!string.IsNullOrWhiteSpace(query.Search)) q = q.Where(x => x.Message.Contains(query.Search));
+
+ var total = q.Count();
+ var page = Math.Max(1, query.Page);
+ var pageSize = Math.Clamp(query.PageSize, 1, 500);
+
+ var items = q.OrderByDescending(x => x.Timestamp)
+ .Offset((page - 1) * pageSize)
+ .Limit(pageSize)
+ .ToList();
+
+ return new LogQueryResult { Items = items, TotalCount = total };
+ }
+
+ public void PruneOlderThan(DateTime cutoffUtc) => Logs.DeleteMany(x => x.Timestamp < cutoffUtc);
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/MailSharp.MailClient/Services/MailCacheStore.cs b/MailSharp.MailClient/Services/MailCacheStore.cs
new file mode 100644
index 0000000..b5e65a3
--- /dev/null
+++ b/MailSharp.MailClient/Services/MailCacheStore.cs
@@ -0,0 +1,293 @@
+using LiteDB;
+using MailSharp.MailClient.Models;
+
+namespace MailSharp.MailClient.Services;
+
+public interface IMailCacheStore
+{
+ List<MessageListItem>? GetMessageList(int accountId, string folder, TimeSpan maxAge);
+ void SaveMessageList(int accountId, string folder, List<MessageListItem> messages);
+ void MarkMessageRead(int accountId, string folder, uint uid);
+
+ // Messages are cached as raw .eml files on disk (see App_Data/cache/messages), organized by
+ // the logged-in email address and named after the message's own Message-ID - not the IMAP
+ // uid, which is only unique within a single folder and changes when a message is moved. Not
+ // as LiteDB blobs, and not as a pre-processed snapshot: the .eml is the untouched source, so a
+ // schema change to MessageDetail never requires invalidating the cache.
+ byte[]? GetMessageEml(string emailAddress, string messageId);
+ void SaveMessageEml(string emailAddress, string messageId, byte[] emlBytes);
+
+ // (account, folder, uid) is the only thing known before contacting IMAP, so this maps it to
+ // the Message-ID used above once we've fetched the message at least once.
+ string? GetMessageIdForUid(int accountId, string folder, uint uid);
+ void SaveMessageIdForUid(int accountId, string folder, uint uid, string messageId);
+
+ bool GetAllowExternalImages(int accountId, string senderEmail);
+ void SetAllowExternalImages(int accountId, string senderEmail, bool allow);
+
+ // Caches the (expensive - walks every folder over IMAP) result of searching all mail to/from
+ // a contact, keyed by (account, email). Same maxAge/forceRefresh pattern as GetMessageList.
+ List<MessageSearchResult>? GetContactMailSearch(int accountId, string email, TimeSpan maxAge);
+ void SaveContactMailSearch(int accountId, string email, List<MessageSearchResult> results);
+
+ // Attachments are cached as loose files on disk under
+ // App_Data/cache/attachments/{emailAddress}/{messageId}/{partIndex}_{fileName} - not LiteDB
+ // blobs - saved the first time they're viewed or downloaded, keyed like the .eml/image
+ // caches so a moved message doesn't invalidate anything.
+ byte[]? GetAttachmentContent(string emailAddress, string messageId, int partIndex);
+ void SaveAttachmentContent(string emailAddress, string messageId, int partIndex, string fileName, byte[] content);
+
+ // Image disk cache: bytes are stored as loose files under
+ // App_Data/cache/images/{emailAddress}/{messageId}/{fileName} (fileName is "01.jpg" etc., see
+ // ImageSanitizer) so it's obvious which images belong to which message - not inside LiteDB;
+ // only the (email, messageId, fileName) -> source URL mapping (needed so the
+ // /cache/images/... handler can download on first request) lives in LiteDB.
+ string? GetImageSourceUrl(string emailAddress, string messageId, string fileName);
+ void SaveImageSourceUrl(string emailAddress, string messageId, string fileName, string url);
+ string? GetCachedImagePath(string emailAddress, string messageId, string fileName);
+ string SaveCachedImageFile(string emailAddress, string messageId, string fileName, byte[] content);
+
+ // Aggregate view of every cache layer this store manages, for the Maintenance page's background
+ // process panel - both the small structured LiteDB entries (message list/contact search TTL
+ // caches, image/uid mappings) and the loose files on disk (.eml/attachment/image caches), since
+ // those are the two very different things "how much is cached" could mean here.
+ CacheStats GetStats();
+}
+
+// Holds a single long-lived LiteDatabase connection for the app's lifetime (registered as a
+// singleton) for the small structured metadata. LiteDatabase is thread-safe for concurrent
+// operations; opening/closing a new connection per call instead would race on the OS file lock
+// under concurrent requests (e.g. clicking through several messages fires overlapping fetches).
+public class LiteDbMailCacheStore : IMailCacheStore, IDisposable
+{
+ private readonly LiteDatabase _db;
+ private readonly string _messagesDir;
+ private readonly string _imagesDir;
+ private readonly string _attachmentsDir;
+
+ public LiteDbMailCacheStore(IWebHostEnvironment env)
+ {
+ var appDataDir = Path.Combine(env.ContentRootPath, "App_Data");
+ Directory.CreateDirectory(appDataDir);
+ _db = new LiteDatabase(Path.Combine(appDataDir, "mailcache.db"));
+
+ var cacheRoot = Path.Combine(appDataDir, "cache");
+ _messagesDir = Path.Combine(cacheRoot, "messages");
+ _imagesDir = Path.Combine(cacheRoot, "images");
+ _attachmentsDir = Path.Combine(cacheRoot, "attachments");
+ Directory.CreateDirectory(_messagesDir);
+ Directory.CreateDirectory(_imagesDir);
+ Directory.CreateDirectory(_attachmentsDir);
+ }
+
+ private ILiteCollection<CachedMessageList> MessageLists => _db.GetCollection<CachedMessageList>("messageLists");
+ private ILiteCollection<SenderImagePreference> SenderImagePrefs => _db.GetCollection<SenderImagePreference>("senderImagePrefs");
+ private ILiteCollection<ImageUrlMapping> ImageUrlMappings => _db.GetCollection<ImageUrlMapping>("imageUrlMappings");
+ private ILiteCollection<MessageIdMapping> MessageIdMappings => _db.GetCollection<MessageIdMapping>("messageIdMappings");
+ private ILiteCollection<ContactMailSearchCache> ContactMailSearches => _db.GetCollection<ContactMailSearchCache>("contactMailSearches");
+
+ private static string ListKey(int accountId, string folder) => $"{accountId}:{folder}";
+ private static string SenderKey(int accountId, string senderEmail) => $"{accountId}:{senderEmail.Trim().ToLowerInvariant()}";
+ private static string UidMappingKey(int accountId, string folder, uint uid) => $"{accountId}:{folder}:{uid}";
+ private static string ContactMailSearchKey(int accountId, string email) => $"{accountId}:{email.Trim().ToLowerInvariant()}";
+
+ public List<MessageListItem>? GetMessageList(int accountId, string folder, TimeSpan maxAge)
+ {
+ var entry = MessageLists.FindById(ListKey(accountId, folder));
+ if (entry == null) return null;
+ if (DateTimeOffset.UtcNow - entry.CachedAt > maxAge) return null;
+ return entry.Messages;
+ }
+
+ public void SaveMessageList(int accountId, string folder, List<MessageListItem> messages)
+ {
+ MessageLists.Upsert(new CachedMessageList
+ {
+ Id = ListKey(accountId, folder),
+ CachedAt = DateTimeOffset.UtcNow,
+ Messages = messages
+ });
+ }
+
+ public void MarkMessageRead(int accountId, string folder, uint uid)
+ {
+ var col = MessageLists;
+ var entry = col.FindById(ListKey(accountId, folder));
+ if (entry == null) return;
+ var item = entry.Messages.FirstOrDefault(m => m.Uid == uid);
+ if (item == null || item.IsRead) return;
+ item.IsRead = true;
+ col.Update(entry);
+ }
+
+ private string MessageEmlPath(string emailAddress, string messageId)
+ {
+ var dir = Path.Combine(_messagesDir, SanitizeForPath(emailAddress));
+ Directory.CreateDirectory(dir);
+ return Path.Combine(dir, $"{SanitizeForPath(messageId)}.eml");
+ }
+
+ public byte[]? GetMessageEml(string emailAddress, string messageId)
+ {
+ var path = MessageEmlPath(emailAddress, messageId);
+ return File.Exists(path) ? File.ReadAllBytes(path) : null;
+ }
+
+ public void SaveMessageEml(string emailAddress, string messageId, byte[] emlBytes) =>
+ File.WriteAllBytes(MessageEmlPath(emailAddress, messageId), emlBytes);
+
+ public string? GetMessageIdForUid(int accountId, string folder, uint uid) =>
+ MessageIdMappings.FindById(UidMappingKey(accountId, folder, uid))?.MessageId;
+
+ public void SaveMessageIdForUid(int accountId, string folder, uint uid, string messageId) =>
+ MessageIdMappings.Upsert(new MessageIdMapping { Id = UidMappingKey(accountId, folder, uid), MessageId = messageId });
+
+ public bool GetAllowExternalImages(int accountId, string senderEmail)
+ {
+ if (string.IsNullOrWhiteSpace(senderEmail)) return false;
+ var entry = SenderImagePrefs.FindById(SenderKey(accountId, senderEmail));
+ return entry?.AllowImages ?? false;
+ }
+
+ public void SetAllowExternalImages(int accountId, string senderEmail, bool allow)
+ {
+ if (string.IsNullOrWhiteSpace(senderEmail)) return;
+ SenderImagePrefs.Upsert(new SenderImagePreference
+ {
+ Id = SenderKey(accountId, senderEmail),
+ AllowImages = allow
+ });
+ }
+
+ public List<MessageSearchResult>? GetContactMailSearch(int accountId, string email, TimeSpan maxAge)
+ {
+ var entry = ContactMailSearches.FindById(ContactMailSearchKey(accountId, email));
+ if (entry == null) return null;
+ if (DateTimeOffset.UtcNow - entry.CachedAt > maxAge) return null;
+ return entry.Results;
+ }
+
+ public void SaveContactMailSearch(int accountId, string email, List<MessageSearchResult> results) =>
+ ContactMailSearches.Upsert(new ContactMailSearchCache
+ {
+ Id = ContactMailSearchKey(accountId, email),
+ CachedAt = DateTimeOffset.UtcNow,
+ Results = results
+ });
+
+ private string AttachmentDir(string emailAddress, string messageId)
+ {
+ var dir = Path.Combine(_attachmentsDir, SanitizeForPath(emailAddress), SanitizeForPath(messageId));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ public byte[]? GetAttachmentContent(string emailAddress, string messageId, int partIndex)
+ {
+ var dir = AttachmentDir(emailAddress, messageId);
+ var prefix = $"{partIndex:D2}_";
+ var match = Directory.EnumerateFiles(dir).FirstOrDefault(f => Path.GetFileName(f).StartsWith(prefix, StringComparison.Ordinal));
+ return match != null ? File.ReadAllBytes(match) : null;
+ }
+
+ public void SaveAttachmentContent(string emailAddress, string messageId, int partIndex, string fileName, byte[] content)
+ {
+ var path = Path.Combine(AttachmentDir(emailAddress, messageId), $"{partIndex:D2}_{SanitizeForPath(fileName)}");
+ File.WriteAllBytes(path, content);
+ }
+
+ private static string ImageMappingKey(string emailAddress, string messageId, string fileName) =>
+ $"{emailAddress.Trim().ToLowerInvariant()}:{messageId}:{fileName}";
+
+ private string ImageDir(string emailAddress, string messageId)
+ {
+ var dir = Path.Combine(_imagesDir, SanitizeForPath(emailAddress), SanitizeForPath(messageId));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ public string? GetImageSourceUrl(string emailAddress, string messageId, string fileName) =>
+ ImageUrlMappings.FindById(ImageMappingKey(emailAddress, messageId, fileName))?.Url;
+
+ public void SaveImageSourceUrl(string emailAddress, string messageId, string fileName, string url)
+ {
+ var key = ImageMappingKey(emailAddress, messageId, fileName);
+ if (ImageUrlMappings.FindById(key) != null) return;
+ ImageUrlMappings.Insert(new ImageUrlMapping { Id = key, Url = url });
+ }
+
+ public string? GetCachedImagePath(string emailAddress, string messageId, string fileName)
+ {
+ var dir = ImageDir(emailAddress, messageId);
+ var exactPath = Path.Combine(dir, fileName);
+ if (File.Exists(exactPath)) return exactPath;
+
+ // The source URL had no extension, so the file may have been saved with one guessed from
+ // the download's Content-Type (see ImageCacheController) - look for "fileName.*" too.
+ if (!fileName.Contains('.'))
+ {
+ return Directory.EnumerateFiles(dir, fileName + ".*").FirstOrDefault();
+ }
+
+ return null;
+ }
+
+ public string SaveCachedImageFile(string emailAddress, string messageId, string fileName, byte[] content)
+ {
+ var path = Path.Combine(ImageDir(emailAddress, messageId), fileName);
+ File.WriteAllBytes(path, content);
+ return path;
+ }
+
+ private static string SanitizeForPath(string s) =>
+ new([.. s.Select(c => Path.GetInvalidFileNameChars().Contains(c) || c is '/' or '\\' ? '_' : c)]);
+
+ public CacheStats GetStats() => new()
+ {
+ MessageListCacheEntries = MessageLists.Count(),
+ ContactMailSearchEntries = ContactMailSearches.Count(),
+ SenderImagePreferences = SenderImagePrefs.Count(),
+ ImageUrlMappings = ImageUrlMappings.Count(),
+ MessageIdMappings = MessageIdMappings.Count(),
+ CachedMessages = DirStats(_messagesDir),
+ CachedAttachments = DirStats(_attachmentsDir),
+ CachedImages = DirStats(_imagesDir)
+ };
+
+ // Walks the cache directory tree to count files/bytes - these are plain files (not tracked in
+ // LiteDB, see the class remarks above), so there's no cheaper source of truth for "how much is
+ // on disk" than actually looking. Fine at admin-page request frequency; not called per-request
+ // during normal mail usage.
+ private static DirCacheStats DirStats(string dir)
+ {
+ if (!Directory.Exists(dir)) return new DirCacheStats();
+ long bytes = 0;
+ var count = 0;
+ foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
+ {
+ bytes += new FileInfo(file).Length;
+ count++;
+ }
+ return new DirCacheStats { FileCount = count, TotalBytes = bytes };
+ }
+
+ public void Dispose() => _db.Dispose();
+}
+
+public class DirCacheStats
+{
+ public int FileCount { get; set; }
+ public long TotalBytes { get; set; }
+}
+
+public class CacheStats
+{
+ public int MessageListCacheEntries { get; set; }
+ public int ContactMailSearchEntries { get; set; }
+ public int SenderImagePreferences { get; set; }
+ public int ImageUrlMappings { get; set; }
+ public int MessageIdMappings { get; set; }
+ public DirCacheStats CachedMessages { get; set; } = new();
+ public DirCacheStats CachedAttachments { get; set; } = new();
+ public DirCacheStats CachedImages { get; set; } = new();
+}
diff --git a/MailSharp.MailClient/Services/MessageIndexStore.cs b/MailSharp.MailClient/Services/MessageIndexStore.cs
new file mode 100644
index 0000000..13ab604
--- /dev/null
+++ b/MailSharp.MailClient/Services/MessageIndexStore.cs
@@ -0,0 +1,171 @@
+using LiteDB;
+using MailSharp.MailClient.Models;
+
+namespace MailSharp.MailClient.Services;
+
+public interface IMessageIndexStore
+{
+ FolderSyncState? GetSyncState(int accountId, string folder);
+ void SaveSyncState(int accountId, string folder, uint uidValidity, uint uidNext, int messageCount);
+
+ List<IndexedMessage> GetMessages(int accountId, string folder);
+ int GetMessageCount(int accountId, string folder);
+
+ // Full rebuild: replaces every row for this folder in one go (used on UIDVALIDITY change, i.e.
+ // the server considers the mailbox rebuilt, or the first time a folder is indexed).
+ void ReplaceFolder(int accountId, string folder, List<IndexedMessage> messages);
+
+ // Incremental add (new UIDs since the last check) - doesn't touch existing rows.
+ void UpsertMessages(int accountId, string folder, List<IndexedMessage> messages);
+
+ // Reconciliation deletes (messages gone from IMAP - removed or moved elsewhere, by this app or
+ // another IMAP client) and folder-deletion cleanup (Settings > Mappen beheren).
+ void RemoveMessages(int accountId, string folder, IEnumerable<uint> uids);
+ void RemoveFolder(int accountId, string folder);
+
+ // Keeps the index in sync with this app's own actions immediately, rather than waiting for the
+ // next EnsureFolderIndexedAsync check to notice.
+ void MarkRead(int accountId, string folder, IEnumerable<uint> uids, bool read);
+ void MarkFlagged(int accountId, string folder, IEnumerable<uint> uids, bool flagged);
+
+ long GetTotalSize(int accountId, string folder);
+ int GetUnreadCount(int accountId, string folder);
+
+ // Every folder this account currently has any indexed messages/sync state for - used by the
+ // Maintenance page's message-count metrics to aggregate across folders without needing the
+ // caller to already know the account's folder names.
+ List<string> GetIndexedFolders(int accountId);
+
+ // Filters in-memory across every indexed folder for this account - LiteDB isn't a full-text
+ // search engine, but at the scale of one mailbox's message count this is fast enough (same
+ // approach the old live-IMAP scan used after fetching, just without the IMAP round trip).
+ List<IndexedMessage> SearchByAddress(int accountId, string address);
+}
+
+// Holds a single long-lived LiteDatabase connection for the app's lifetime (registered as a
+// singleton), same pattern as the other *Store classes - a fresh connection per call would race on
+// the OS file lock under concurrent requests.
+public class LiteDbMessageIndexStore : IMessageIndexStore, IDisposable
+{
+ private readonly LiteDatabase _db;
+
+ public LiteDbMessageIndexStore(IWebHostEnvironment env)
+ {
+ var dir = Path.Combine(env.ContentRootPath, "App_Data");
+ Directory.CreateDirectory(dir);
+ _db = new LiteDatabase(Path.Combine(dir, "messageindex.db"));
+
+ Messages.EnsureIndex(x => x.Key);
+ Messages.EnsureIndex(x => x.AccountId);
+ }
+
+ private ILiteCollection<IndexedMessage> Messages => _db.GetCollection<IndexedMessage>("messages");
+ private ILiteCollection<FolderSyncState> SyncStates => _db.GetCollection<FolderSyncState>("folderSyncStates");
+
+ private static string Key(int accountId, string folder) => $"{accountId}:{folder}";
+ private static string MessageId(int accountId, string folder, uint uid) => $"{accountId}:{folder}:{uid}";
+
+ public FolderSyncState? GetSyncState(int accountId, string folder) => SyncStates.FindById(Key(accountId, folder));
+
+ public void SaveSyncState(int accountId, string folder, uint uidValidity, uint uidNext, int messageCount) =>
+ SyncStates.Upsert(new FolderSyncState
+ {
+ Id = Key(accountId, folder),
+ UidValidity = uidValidity,
+ UidNext = uidNext,
+ MessageCount = messageCount
+ });
+
+ public List<IndexedMessage> GetMessages(int accountId, string folder) =>
+ [.. Messages.Find(x => x.Key == Key(accountId, folder))];
+
+ public int GetMessageCount(int accountId, string folder) =>
+ Messages.Count(x => x.Key == Key(accountId, folder));
+
+ public void ReplaceFolder(int accountId, string folder, List<IndexedMessage> messages)
+ {
+ var key = Key(accountId, folder);
+ Messages.DeleteMany(x => x.Key == key);
+ Stamp(accountId, folder, messages);
+ if (messages.Count > 0) Messages.InsertBulk(messages);
+ }
+
+ public void UpsertMessages(int accountId, string folder, List<IndexedMessage> messages)
+ {
+ Stamp(accountId, folder, messages);
+ foreach (var m in messages) Messages.Upsert(m);
+ }
+
+ private static void Stamp(int accountId, string folder, List<IndexedMessage> messages)
+ {
+ var key = Key(accountId, folder);
+ foreach (var m in messages)
+ {
+ m.Id = MessageId(accountId, folder, m.Uid);
+ m.Key = key;
+ m.AccountId = accountId;
+ m.Folder = folder;
+ }
+ }
+
+ public void RemoveMessages(int accountId, string folder, IEnumerable<uint> uids)
+ {
+ foreach (var uid in uids) Messages.Delete(MessageId(accountId, folder, uid));
+ }
+
+ public void RemoveFolder(int accountId, string folder)
+ {
+ var key = Key(accountId, folder);
+ Messages.DeleteMany(x => x.Key == key);
+ SyncStates.Delete(Key(accountId, folder));
+ }
+
+ public void MarkRead(int accountId, string folder, IEnumerable<uint> uids, bool read)
+ {
+ foreach (var uid in uids)
+ {
+ var m = Messages.FindById(MessageId(accountId, folder, uid));
+ if (m == null || m.IsRead == read) continue;
+ m.IsRead = read;
+ Messages.Update(m);
+ }
+ }
+
+ public void MarkFlagged(int accountId, string folder, IEnumerable<uint> uids, bool flagged)
+ {
+ foreach (var uid in uids)
+ {
+ var m = Messages.FindById(MessageId(accountId, folder, uid));
+ if (m == null || m.IsFlagged == flagged) continue;
+ m.IsFlagged = flagged;
+ Messages.Update(m);
+ }
+ }
+
+ public long GetTotalSize(int accountId, string folder)
+ {
+ var key = Key(accountId, folder);
+ return Messages.Find(x => x.Key == key).Sum(x => x.SizeBytes);
+ }
+
+ public int GetUnreadCount(int accountId, string folder) =>
+ Messages.Count(x => x.Key == Key(accountId, folder) && !x.IsRead);
+
+ public List<string> GetIndexedFolders(int accountId) =>
+ [.. SyncStates.Find(x => x.Id.StartsWith(accountId + ":"))
+ .Select(x => x.Id[(accountId.ToString().Length + 1)..])];
+
+ public List<IndexedMessage> SearchByAddress(int accountId, string address)
+ {
+ // From/To/Cc are always written as "" rather than null (see FetchIndexedMessagesAsync), but
+ // tolerate null here anyway defensively - a document from a different code path or a partial
+ // write shouldn't crash every future search.
+ return [.. Messages.Find(x => x.AccountId == accountId)
+ .Where(m =>
+ (m.From ?? "").Contains(address, StringComparison.OrdinalIgnoreCase) ||
+ (m.To ?? "").Contains(address, StringComparison.OrdinalIgnoreCase) ||
+ (m.Cc ?? "").Contains(address, StringComparison.OrdinalIgnoreCase))];
+ }
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/MailSharp.MailClient/Services/MimeMessageFactory.cs b/MailSharp.MailClient/Services/MimeMessageFactory.cs
new file mode 100644
index 0000000..33a120e
--- /dev/null
+++ b/MailSharp.MailClient/Services/MimeMessageFactory.cs
@@ -0,0 +1,74 @@
+using MailSharp.MailClient.Models;
+using MimeKit;
+using ModelPriority = MailSharp.MailClient.Models.MessagePriority;
+
+namespace MailSharp.MailClient.Services;
+
+public static class MimeMessageFactory
+{
+ public static MimeMessage Build(Account account, ComposeModel compose, IEnumerable<(string FileName, Stream Content)> attachments) =>
+ Build(account, compose, attachments, []);
+
+ // inlineImages are pictures the user inserted into the editor via file upload rather than a
+ // URL (see compose.js's upload-image button) - the composer already rewrote each <img> tag's
+ // src to "cid:<Cid>" before posting HtmlBody, so all that's needed here is attaching the actual
+ // bytes as an inline MimePart whose ContentId matches, so mail clients resolve the reference
+ // instead of showing a broken image.
+ public static MimeMessage Build(Account account, ComposeModel compose, IEnumerable<(string FileName, Stream Content)> attachments, IEnumerable<(string Cid, string FileName, Stream Content)> inlineImages)
+ {
+ var message = new MimeMessage();
+ message.From.Add(new MailboxAddress(account.DisplayName, account.EmailAddress));
+ AddAddresses(message.To, compose.To);
+ AddAddresses(message.Cc, compose.Cc);
+ AddAddresses(message.Bcc, compose.Bcc);
+ message.Subject = compose.Subject;
+
+ message.Priority = compose.Priority switch
+ {
+ ModelPriority.Low => MimeKit.MessagePriority.NonUrgent,
+ ModelPriority.High => MimeKit.MessagePriority.Urgent,
+ _ => MimeKit.MessagePriority.Normal
+ };
+
+ if (compose.Sensitivity != MessageSensitivity.Nothing)
+ {
+ message.Headers.Add("Sensitivity", compose.Sensitivity switch
+ {
+ MessageSensitivity.Personal => "Personal",
+ MessageSensitivity.Private => "Private",
+ MessageSensitivity.Confidential => "Company-Confidential",
+ _ => "Normal"
+ });
+ }
+
+ if (compose.RequestReadReceipt)
+ {
+ message.Headers.Add("Disposition-Notification-To", account.EmailAddress);
+ message.Headers.Add("Return-Receipt-To", account.EmailAddress);
+ }
+
+ var builder = new BodyBuilder { HtmlBody = compose.HtmlBody };
+ foreach (var (fileName, content) in attachments)
+ {
+ builder.Attachments.Add(fileName, content);
+ }
+ foreach (var (cid, fileName, content) in inlineImages)
+ {
+ var part = builder.LinkedResources.Add(fileName, content);
+ part.ContentId = cid;
+ part.ContentDisposition = new ContentDisposition(ContentDisposition.Inline);
+ }
+ message.Body = builder.ToMessageBody();
+ return message;
+ }
+
+ private static void AddAddresses(InternetAddressList list, string raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) return;
+ foreach (var part in raw.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (MailboxAddress.TryParse(part, out var addr))
+ list.Add(addr);
+ }
+ }
+}
diff --git a/MailSharp.MailClient/Services/SessionAccountManager.cs b/MailSharp.MailClient/Services/SessionAccountManager.cs
new file mode 100644
index 0000000..25d6cd5
--- /dev/null
+++ b/MailSharp.MailClient/Services/SessionAccountManager.cs
@@ -0,0 +1,92 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using System.Security.Claims;
+
+namespace MailSharp.MailClient.Services;
+
+// Tracks which accounts are "logged in" and which one is active via claims baked into the
+// persistent auth cookie (not server-side session state), so login survives an app/server
+// restart as long as the Data Protection keys used to protect that cookie are also persisted
+// to disk. IMAP passwords are never stored here or in the cookie - they're decrypted on demand
+// from the LiteDB-backed account store (see IAccountStore.GetPlainPassword).
+public class SessionAccountManager(IHttpContextAccessor accessor, IAccountStore accountStore)
+{
+ private const string LoggedInClaimType = "mailsharp:logged_in_account";
+ private const string ActiveClaimType = "mailsharp:active_account";
+ public const string AdminClaimType = "mailsharp:is_admin";
+
+ private HttpContext Context => accessor.HttpContext!;
+
+ public Task AddOrUpdateAsync(int accountId)
+ {
+ var ids = new HashSet<int>(GetLoggedInAccountIds()) { accountId };
+ return SignInAsync(ids, accountId);
+ }
+
+ public Task RemoveAsync(int accountId)
+ {
+ var ids = new HashSet<int>(GetLoggedInAccountIds());
+ ids.Remove(accountId);
+
+ if (ids.Count == 0)
+ {
+ return Context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+ }
+
+ var active = GetActiveAccountId();
+ var newActive = active == accountId ? ids.First() : active ?? ids.First();
+ return SignInAsync(ids, newActive);
+ }
+
+ public Task SetActiveAccountIdAsync(int accountId)
+ {
+ var ids = GetLoggedInAccountIds();
+ return ids.Contains(accountId) ? SignInAsync(ids, accountId) : Task.CompletedTask;
+ }
+
+ public Task LogoutAsync() => Context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+
+ // Requires accountId to be one of *this* cookie's logged-in accounts before decrypting -
+ // otherwise any caller-supplied account id would let a different session send mail as an
+ // account it never authenticated as.
+ public string? GetPassword(int accountId)
+ {
+ if (!GetLoggedInAccountIds().Contains(accountId)) return null;
+ var account = accountStore.Get(accountId);
+ return account == null ? null : accountStore.GetPlainPassword(account);
+ }
+
+ public List<int> GetLoggedInAccountIds() =>
+ [.. Context.User.FindAll(LoggedInClaimType).Select(c => int.Parse(c.Value))];
+
+ public int? GetActiveAccountId()
+ {
+ var claim = Context.User.FindFirst(ActiveClaimType);
+ return claim != null && int.TryParse(claim.Value, out var id) ? id : null;
+ }
+
+ public bool IsLoggedIn => Context.User.Identity?.IsAuthenticated == true && GetLoggedInAccountIds().Count > 0;
+
+ private Task SignInAsync(IEnumerable<int> loggedInIds, int activeId)
+ {
+ var claims = new List<Claim> { new(ActiveClaimType, activeId.ToString()) };
+ claims.AddRange(loggedInIds.Select(id => new Claim(LoggedInClaimType, id.ToString())));
+
+ // The admin claim tracks whichever account is currently active - re-computed here every time
+ // SignInAsync runs (login, switch, add/remove), so switching away from an admin account drops
+ // Maintenance-page access immediately without requiring a full logout.
+ if (accountStore.Get(activeId)?.IsAdmin == true)
+ {
+ claims.Add(new Claim(AdminClaimType, "true"));
+ }
+
+ var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
+ var principal = new ClaimsPrincipal(identity);
+
+ return Context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties
+ {
+ IsPersistent = true,
+ ExpiresUtc = DateTimeOffset.UtcNow.AddHours(24)
+ });
+ }
+}
diff --git a/MailSharp.MailClient/Services/SmtpService.cs b/MailSharp.MailClient/Services/SmtpService.cs
new file mode 100644
index 0000000..c3f368b
--- /dev/null
+++ b/MailSharp.MailClient/Services/SmtpService.cs
@@ -0,0 +1,47 @@
+using MailKit.Net.Smtp;
+using MailSharp.MailClient.Models;
+using MimeKit;
+using System.Diagnostics;
+
+namespace MailSharp.MailClient.Services;
+
+public interface ISmtpService
+{
+ Task SendAsync(Account account, string password, MimeMessage message, CancellationToken ct = default);
+}
+
+public class SmtpService(ILogger<SmtpService> logger) : ISmtpService
+{
+ public async Task SendAsync(Account account, string password, MimeMessage message, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?>
+ {
+ ["accountId"] = account.Id,
+ ["to"] = message.To.ToString()
+ });
+ var sw = Stopwatch.StartNew();
+
+ using var client = new SmtpClient();
+ var secureSocket = account.SmtpSecurity switch
+ {
+ SecurityMode.SslTls => MailKit.Security.SecureSocketOptions.SslOnConnect,
+ SecurityMode.StartTls => MailKit.Security.SecureSocketOptions.StartTls,
+ _ => MailKit.Security.SecureSocketOptions.None
+ };
+
+ try
+ {
+ await client.ConnectAsync(account.SmtpHost, account.SmtpPort, secureSocket, ct);
+ await client.AuthenticateAsync(account.Username, password, ct);
+ await client.SendAsync(message, ct);
+ await client.DisconnectAsync(true, ct);
+
+ logger.LogInformation("Sent message {Subject} for account {AccountId} in {DurationMs}ms", message.Subject, account.Id, sw.ElapsedMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to send message {Subject} for account {AccountId} after {DurationMs}ms", message.Subject, account.Id, sw.ElapsedMilliseconds);
+ throw;
+ }
+ }
+}
diff --git a/MailSharp.MailClient/Views/Account/AccessDenied.cshtml b/MailSharp.MailClient/Views/Account/AccessDenied.cshtml
new file mode 100644
index 0000000..a8bd6b8
--- /dev/null
+++ b/MailSharp.MailClient/Views/Account/AccessDenied.cshtml
@@ -0,0 +1,21 @@
+@{
+ ViewData["Title"] = "Geen toegang";
+}
+<div class="app">
+ <div class="app-body">
+ <div class="content">
+ <div class="settings-page">
+ <div class="settings-wrap">
+ <section class="settings-section">
+ <h2>Geen toegang</h2>
+ <p>Deze pagina is alleen beschikbaar voor beheerders.</p>
+ <p>Ben je zojuist beheerder gemaakt? Log dan opnieuw in, of wissel van account en weer terug - de beheerdersrechten worden pas bij het inloggen in de sessie geladen.</p>
+ <div class="form-actions">
+ <a class="btn btn-primary" asp-controller="Mail" asp-action="Index">Terug naar Mail</a>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/MailSharp.MailClient/Views/Account/Login.cshtml b/MailSharp.MailClient/Views/Account/Login.cshtml
new file mode 100644
index 0000000..a137781
--- /dev/null
+++ b/MailSharp.MailClient/Views/Account/Login.cshtml
@@ -0,0 +1,61 @@
+@{
+ ViewData["Title"] = "Login";
+}
+<div class="auth-wrap">
+ <div class="auth-card">
+ <h1>MailSharp</h1>
+ <p class="sub" data-i18n="login_subtitle"></p>
+
+ <div id="loginError" class="alert alert-error" style="display:none;"></div>
+
+ <form id="loginForm">
+ <label for="EmailAddress" data-i18n="login_email_address"></label>
+ <input type="email" id="EmailAddress" name="EmailAddress" required autofocus />
+
+ <label for="Username" data-i18n="login_username"></label>
+ <input type="text" id="Username" name="Username" />
+
+ <label for="Password" data-i18n="login_password"></label>
+ <input type="password" id="Password" name="Password" required />
+
+ <div class="advanced-toggle"><a href="#" id="advancedToggle" data-i18n="login_advanced_toggle"></a></div>
+
+ <div class="advanced-box" id="advancedBox">
+ <label style="display:flex;align-items:center;gap:6px;">
+ <input type="checkbox" name="Advanced" style="width:auto;" /> <span data-i18n="login_manual_server"></span>
+ </label>
+
+ <h4 style="margin-bottom:0;" data-i18n="login_imap_incoming"></h4>
+ <div class="grid-2">
+ <div><label data-i18n="login_server"></label><input type="text" name="ImapHost" id="ImapHost" /></div>
+ <div><label data-i18n="login_port"></label><input type="number" name="ImapPort" id="ImapPort" /></div>
+ </div>
+ <label data-i18n="login_security"></label>
+ <select name="ImapSecurity" id="ImapSecurity">
+ <option value="SslTls" data-i18n="login_security_ssl_tls"></option>
+ <option value="StartTls" data-i18n="login_security_start_tls"></option>
+ <option value="None" data-i18n="login_security_none"></option>
+ </select>
+
+ <h4 style="margin-bottom:0;" data-i18n="login_smtp_outgoing"></h4>
+ <div class="grid-2">
+ <div><label data-i18n="login_server"></label><input type="text" name="SmtpHost" id="SmtpHost" /></div>
+ <div><label data-i18n="login_port"></label><input type="number" name="SmtpPort" id="SmtpPort" /></div>
+ </div>
+ <label data-i18n="login_security"></label>
+ <select name="SmtpSecurity" id="SmtpSecurity">
+ <option value="StartTls" data-i18n="login_security_start_tls"></option>
+ <option value="SslTls" data-i18n="login_security_ssl_tls"></option>
+ <option value="None" data-i18n="login_security_none"></option>
+ </select>
+ </div>
+
+ <div class="auth-actions">
+ <button type="submit" class="btn btn-primary" data-i18n="login_submit"></button>
+ </div>
+ </form>
+ </div>
+</div>
+@section Scripts {
+ <script src="~/js/login.js"></script>
+}
diff --git a/MailSharp.MailClient/Views/Contacts/Index.cshtml b/MailSharp.MailClient/Views/Contacts/Index.cshtml
new file mode 100644
index 0000000..5f4fc66
--- /dev/null
+++ b/MailSharp.MailClient/Views/Contacts/Index.cshtml
@@ -0,0 +1,34 @@
+@{
+ ViewData["Title"] = "Contacten";
+}
+<div class="app">
+ <div class="topbar">
+ <button class="hamburger" id="sidebarToggle" aria-label="Menu">&#9776;</button>
+ <span class="brand">MailSharp</span>
+ <a class="account-email" id="accountEmail" asp-controller="Mail" asp-action="Index"></a>
+ <a class="btn btn-text active" asp-controller="Contacts" asp-action="Index" data-i18n="topbar_contacts"></a>
+ <div class="spacer"></div>
+ <select class="lang-select" id="langSelect"></select>
+ <a class="btn btn-text" asp-controller="Maintenance" asp-action="Index" id="maintenanceLink" style="display:none">Beheer</a>
+ <a class="btn btn-text" asp-controller="Settings" asp-action="Index" data-i18n="topbar_settings"></a>
+ <a class="btn" href="#" id="logoutBtn" data-i18n="topbar_logout"></a>
+ </div>
+ <div class="app-body">
+ <div class="sidebar" id="sidebar">
+ <input type="text" id="contactSearch" data-i18n-placeholder="contacts_search" style="margin-bottom:10px;" />
+ <div id="contactListBody"></div>
+ </div>
+ <div class="col-resizer" data-resize-target=".sidebar" data-resize-key="contactsidebar" data-min="160" data-max="480"></div>
+ <div class="content" id="contactContent">
+ <div class="message-list-pane">
+ <div class="list-toolbar" id="contactHeader"></div>
+ <div id="contactMailsBody"></div>
+ </div>
+ <div class="col-resizer" data-resize-target=".message-list-pane" data-resize-key="contactmessagelist" data-min="260" data-max="720"></div>
+ <div class="detail-pane" id="contactMailDetailPane"></div>
+ </div>
+ </div>
+</div>
+@section Scripts {
+ <script src="~/js/contacts.js"></script>
+}
diff --git a/MailSharp.MailClient/Views/Mail/Compose.cshtml b/MailSharp.MailClient/Views/Mail/Compose.cshtml
new file mode 100644
index 0000000..ea376bc
--- /dev/null
+++ b/MailSharp.MailClient/Views/Mail/Compose.cshtml
@@ -0,0 +1,106 @@
+@{
+ ViewData["Title"] = "Compose";
+}
+<div class="app">
+ <div class="topbar">
+ <a class="btn" asp-controller="Mail" asp-action="Index">&larr; <span data-i18n="common_back"></span></a>
+ <div class="spacer"></div>
+ <span data-i18n="compose_page_title"></span>
+ </div>
+ <div class="app-body">
+ <div class="content">
+ <div class="compose-wrap">
+ <form id="composeForm">
+ <input type="hidden" name="AccountId" id="AccountId" />
+
+ <label data-i18n="compose_to"></label>
+ <input type="text" name="To" id="ToInput" required />
+
+ <div class="row-inline">
+ <div><label data-i18n="compose_cc"></label><input type="text" name="Cc" /></div>
+ <div><label data-i18n="compose_bcc"></label><input type="text" name="Bcc" /></div>
+ </div>
+
+ <label data-i18n="compose_subject"></label>
+ <input type="text" name="Subject" />
+
+ <label data-i18n="compose_body"></label>
+ <div class="editor-toolbar">
+ <button type="button" data-cmd="bold"><b>B</b></button>
+ <button type="button" data-cmd="italic"><i>I</i></button>
+ <button type="button" data-cmd="underline"><u>U</u></button>
+ <span class="sep"></span>
+ <button type="button" data-cmd="justifyLeft">&#8676;</button>
+ <button type="button" data-cmd="justifyCenter">&#8596;</button>
+ <button type="button" data-cmd="justifyRight">&#8677;</button>
+ <span class="sep"></span>
+ <select id="fontName">
+ <option value="" data-i18n="compose_font_name"></option>
+ <option value="Arial">Arial</option>
+ <option value="Georgia">Georgia</option>
+ <option value="'Courier New'">Courier New</option>
+ <option value="Verdana">Verdana</option>
+ <option value="'Times New Roman'">Times New Roman</option>
+ </select>
+ <select id="fontSize">
+ <option value="" data-i18n="compose_font_size"></option>
+ <option value="1" data-i18n="compose_size_small"></option>
+ <option value="3" selected data-i18n="compose_size_normal"></option>
+ <option value="5" data-i18n="compose_size_large"></option>
+ <option value="7" data-i18n="compose_size_huge"></option>
+ </select>
+ <span class="sep"></span>
+ <label style="display:inline-flex;align-items:center;gap:4px;margin:0;"><span data-i18n="compose_text"></span><input type="color" id="textColor" value="#1f2430" /></label>
+ <label style="display:inline-flex;align-items:center;gap:4px;margin:0;"><span data-i18n="compose_background"></span><input type="color" id="bgColor" value="#ffffff" /></label>
+ <span class="sep"></span>
+ <button type="button" data-cmd="createLink" id="linkBtn" data-i18n="compose_link"></button>
+ <button type="button" data-cmd="insertImage" id="imageBtn" data-i18n="compose_image"></button>
+ <button type="button" id="uploadImageBtn" data-i18n="compose_upload_image"></button>
+ <input type="file" id="inlineImageInput" accept="image/*" multiple style="display:none;" />
+ <span class="sep"></span>
+ <button type="button" data-cmd="insertUnorderedList">&#8226; <span data-i18n="compose_list"></span></button>
+ <button type="button" data-cmd="removeFormat" data-i18n="compose_clear_format"></button>
+ </div>
+ <div id="editor" contenteditable="true" spellcheck="true"></div>
+ <textarea id="HtmlBody" name="HtmlBody" style="display:none;"></textarea>
+
+ <div class="compose-options">
+ <div>
+ <label data-i18n="compose_priority"></label>
+ <select name="Priority">
+ <option value="Low" data-i18n="compose_priority_low"></option>
+ <option value="Normal" selected data-i18n="compose_priority_normal"></option>
+ <option value="High" data-i18n="compose_priority_high"></option>
+ </select>
+ </div>
+ <div>
+ <label data-i18n="compose_sensitivity"></label>
+ <select name="Sensitivity">
+ <option value="Nothing" selected data-i18n="compose_sensitivity_nothing"></option>
+ <option value="Confidential" data-i18n="compose_sensitivity_confidential"></option>
+ <option value="Private" data-i18n="compose_sensitivity_private"></option>
+ <option value="Personal" data-i18n="compose_sensitivity_personal"></option>
+ </select>
+ </div>
+ </div>
+
+ <label class="checkbox-line">
+ <input type="checkbox" name="RequestReadReceipt" value="true" /> <span data-i18n="compose_request_read_receipt"></span>
+ </label>
+
+ <label data-i18n="compose_attachments"></label>
+ <input type="file" id="attachmentsInput" name="files" multiple />
+ <div id="attachmentsList" class="attachments-list"></div>
+
+ <div class="form-actions">
+ <button type="submit" class="btn btn-primary" data-i18n="compose_send"></button>
+ <button type="button" class="btn" id="saveDraftBtn" data-i18n="compose_save_draft"></button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+</div>
+@section Scripts {
+ <script src="~/js/compose.js"></script>
+}
diff --git a/MailSharp.MailClient/Views/Mail/Index.cshtml b/MailSharp.MailClient/Views/Mail/Index.cshtml
new file mode 100644
index 0000000..44a6017
--- /dev/null
+++ b/MailSharp.MailClient/Views/Mail/Index.cshtml
@@ -0,0 +1,63 @@
+@{
+ ViewData["Title"] = "Mail";
+}
+<div class="app">
+ <div class="topbar">
+ <button class="hamburger" id="sidebarToggle" aria-label="Menu">&#9776;</button>
+ <span class="brand">MailSharp</span>
+ <a class="account-email" id="accountEmail" asp-controller="Mail" asp-action="Index"></a>
+ <a class="btn btn-text" asp-controller="Contacts" asp-action="Index" data-i18n="topbar_contacts"></a>
+ <div class="spacer"></div>
+ <select class="lang-select" id="langSelect"></select>
+ <a class="btn btn-text" asp-controller="Maintenance" asp-action="Index" id="maintenanceLink" style="display:none">Beheer</a>
+ <a class="btn btn-text" asp-controller="Settings" asp-action="Index" data-i18n="topbar_settings"></a>
+ <a class="btn" href="#" id="logoutBtn" data-i18n="topbar_logout"></a>
+ </div>
+ <div class="app-body">
+ <div class="sidebar" id="sidebar">
+ <a class="btn btn-primary compose-btn" asp-controller="Mail" asp-action="Compose" data-i18n="sidebar_new_message"></a>
+ <ul class="folder-list" id="folderList"></ul>
+ </div>
+ <div class="col-resizer" data-resize-target=".sidebar" data-resize-key="sidebar" data-min="160" data-max="480"></div>
+ <div class="content" id="mailContent">
+ <div class="message-list-pane">
+ <div class="list-toolbar">
+ <a class="btn" href="#" id="refreshBtn" data-i18n="mail_refresh"></a>
+ <input type="checkbox" data-select-all />
+ <select id="markActions">
+ <option value="" data-i18n="mail_actions" selected></option>
+ <option value="markRead" data-i18n="action_mark_read"></option>
+ <option value="markUnread" data-i18n="action_mark_unread"></option>
+ <option value="flag" data-i18n="action_flag"></option>
+ <option value="unflag" data-i18n="action_unflag"></option>
+ <option value="markAllRead" data-i18n="action_mark_all_read"></option>
+ <option value="markAllUnread" data-i18n="action_mark_all_unread"></option>
+ </select>
+ <select id="moveToFolder">
+ <option value="" data-i18n="mail_move_to" selected></option>
+ </select>
+ <select id="deleteActions">
+ <option value="" data-i18n="mail_delete_menu" selected></option>
+ <option value="delete" data-i18n="action_delete"></option>
+ <option value="emptyTrash" data-i18n="action_empty_trash"></option>
+ <option value="emptySpam" data-i18n="action_empty_spam"></option>
+ </select>
+ <a class="btn" href="#" id="spamBtn" data-i18n="mail_spam"></a>
+ <div class="sort-links">
+ <span data-i18n="mail_sort"></span>
+ <a href="#" data-sort="date" data-i18n="mail_sort_date"></a>
+ <a href="#" data-sort="subject" data-i18n="mail_sort_subject"></a>
+ <a href="#" data-sort="from" data-i18n="mail_sort_from"></a>
+ <a href="#" data-sort="size" data-i18n="mail_sort_size"></a>
+ </div>
+ </div>
+ <div id="messageListBody"></div>
+ </div>
+ <div class="col-resizer" data-resize-target=".message-list-pane" data-resize-key="messagelist" data-min="260" data-max="720"></div>
+ <div class="detail-pane" id="detailPane"></div>
+ </div>
+ </div>
+</div>
+@section Scripts {
+ <script src="~/js/mail.js"></script>
+}
diff --git a/MailSharp.MailClient/Views/Maintenance/Index.cshtml b/MailSharp.MailClient/Views/Maintenance/Index.cshtml
new file mode 100644
index 0000000..4f5b5d7
--- /dev/null
+++ b/MailSharp.MailClient/Views/Maintenance/Index.cshtml
@@ -0,0 +1,133 @@
+@{
+ ViewData["Title"] = "Beheer";
+}
+<div class="app">
+ <div class="topbar">
+ <span class="brand">MailSharp</span>
+ <a class="account-email" id="accountEmail" asp-controller="Mail" asp-action="Index"></a>
+ <a class="btn btn-text" asp-controller="Contacts" asp-action="Index" data-i18n="topbar_contacts"></a>
+ <div class="spacer"></div>
+ <select class="lang-select" id="langSelect"></select>
+ <a class="btn btn-text active" asp-controller="Maintenance" asp-action="Index">Beheer</a>
+ <a class="btn btn-text" asp-controller="Settings" asp-action="Index" data-i18n="topbar_settings"></a>
+ <a class="btn" href="#" id="logoutBtn" data-i18n="topbar_logout"></a>
+ </div>
+ <div class="subheader" id="maintenanceTabs">
+ <a href="#" class="subheader-tab active" data-tab="status">Achtergrondprocessen</a>
+ <a href="#" class="subheader-tab" data-tab="metrics">Berichten per account</a>
+ <a href="#" class="subheader-tab" data-tab="logs">Logs</a>
+ </div>
+ <div class="app-body">
+ <div class="content">
+ <div class="settings-page">
+ <div class="settings-wrap settings-wrap-wide" data-tab-panel="status">
+ <section class="settings-section">
+ <h2>Achtergrondindexering</h2>
+ <div id="maintenanceStatus" class="settings-readonly"></div>
+ </section>
+
+ <section class="settings-section">
+ <h2>Geïndexeerde berichten</h2>
+ <div id="indexTotals" class="settings-readonly"></div>
+ <div class="folder-manage-table-wrap">
+ <table class="folder-manage-table">
+ <thead>
+ <tr>
+ <th>Account</th>
+ <th>Geïndexeerde mappen</th>
+ <th>Aantal berichten</th>
+ <th>Grootte</th>
+ </tr>
+ </thead>
+ <tbody id="indexSummaryBody"></tbody>
+ </table>
+ </div>
+ </section>
+
+ <section class="settings-section">
+ <h2>Cache</h2>
+ <div class="folder-manage-table-wrap">
+ <table class="folder-manage-table">
+ <thead>
+ <tr>
+ <th>Cache</th>
+ <th>Aantal</th>
+ <th>Grootte</th>
+ </tr>
+ </thead>
+ <tbody id="cacheStatsBody"></tbody>
+ </table>
+ </div>
+ </section>
+ </div>
+
+ <div class="settings-wrap settings-wrap-wide" data-tab-panel="metrics" style="display:none;">
+ <section class="settings-section">
+ <h2>Berichten per account</h2>
+ <div class="folder-manage-toolbar">
+ <select id="metricsAccountFilter">
+ <option value="">Alle accounts</option>
+ </select>
+ </div>
+ <div class="folder-manage-table-wrap">
+ <table class="folder-manage-table">
+ <thead>
+ <tr>
+ <th>Account</th>
+ <th>Map</th>
+ <th>Aantal berichten</th>
+ <th>Grootte</th>
+ </tr>
+ </thead>
+ <tbody id="metricsBody"></tbody>
+ </table>
+ </div>
+ </section>
+ </div>
+
+ <div class="settings-wrap settings-wrap-wide" data-tab-panel="logs" style="display:none;">
+ <section class="settings-section">
+ <h2>Logs</h2>
+ <div class="folder-manage-toolbar">
+ <select id="logLevelFilter">
+ <option value="">Alle niveaus</option>
+ <option value="Trace">Trace</option>
+ <option value="Debug">Debug</option>
+ <option value="Information">Information</option>
+ <option value="Warning">Warning</option>
+ <option value="Error">Error</option>
+ <option value="Critical">Critical</option>
+ </select>
+ <input type="text" id="logCategoryFilter" placeholder="Categorie bevat..." />
+ <input type="number" id="logAccountFilter" placeholder="Account ID" style="width:110px" />
+ <input type="text" id="logSearchFilter" placeholder="Zoeken in bericht..." />
+ <a class="btn" href="#" id="logFilterBtn">Filteren</a>
+ </div>
+ <div class="folder-manage-table-wrap">
+ <table class="folder-manage-table">
+ <thead>
+ <tr>
+ <th>Tijd</th>
+ <th>Niveau</th>
+ <th>Categorie</th>
+ <th>Account</th>
+ <th>Bericht</th>
+ </tr>
+ </thead>
+ <tbody id="logsBody"></tbody>
+ </table>
+ </div>
+ <div class="folder-manage-toolbar">
+ <a class="btn" href="#" id="logsPrevBtn">Vorige</a>
+ <span id="logsPageInfo"></span>
+ <a class="btn" href="#" id="logsNextBtn">Volgende</a>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+@section Scripts {
+ <script src="~/js/maintenance.js"></script>
+}
diff --git a/MailSharp.MailClient/Views/Settings/Index.cshtml b/MailSharp.MailClient/Views/Settings/Index.cshtml
new file mode 100644
index 0000000..f946ccc
--- /dev/null
+++ b/MailSharp.MailClient/Views/Settings/Index.cshtml
@@ -0,0 +1,96 @@
+@{
+ ViewData["Title"] = "Instellingen";
+}
+<div class="app">
+ <div class="topbar">
+ <span class="brand">MailSharp</span>
+ <a class="account-email" id="accountEmail" asp-controller="Mail" asp-action="Index"></a>
+ <a class="btn btn-text" asp-controller="Contacts" asp-action="Index" data-i18n="topbar_contacts"></a>
+ <div class="spacer"></div>
+ <select class="lang-select" id="langSelect"></select>
+ <a class="btn btn-text" asp-controller="Maintenance" asp-action="Index" id="maintenanceLink" style="display:none">Beheer</a>
+ <a class="btn btn-text active" asp-controller="Settings" asp-action="Index" data-i18n="topbar_settings"></a>
+ <a class="btn" href="#" id="logoutBtn" data-i18n="topbar_logout"></a>
+ </div>
+ <div class="subheader" id="settingsTabs">
+ <a href="#" class="subheader-tab active" data-tab="general" data-i18n="settings_tab_general"></a>
+ <a href="#" class="subheader-tab" data-tab="folders" data-i18n="settings_tab_folders"></a>
+ </div>
+ <div class="app-body">
+ <div class="content">
+ <div class="settings-page">
+ <div class="settings-wrap" data-tab-panel="general">
+ <section class="settings-section">
+ <h2 data-i18n="settings_email"></h2>
+ <label data-i18n="settings_account"></label>
+ <div id="settingsAccountInfo" class="settings-readonly"></div>
+ </section>
+ <section class="settings-section">
+ <h2 data-i18n="settings_general"></h2>
+
+ <label data-i18n="settings_friendly_name"></label>
+ <input type="text" id="settingsFriendlyName" />
+
+ <label data-i18n="settings_signature"></label>
+ <textarea id="settingsSignature" rows="4"></textarea>
+
+ <label data-i18n="settings_messages_per_page"></label>
+ <input type="number" id="settingsMessagesPerPage" min="1" />
+
+ <label data-i18n="settings_contacts_per_page"></label>
+ <input type="number" id="settingsContactsPerPage" min="1" />
+
+ <label data-i18n="settings_autocheck_interval"></label>
+ <input type="number" id="settingsAutoCheckInterval" min="0" />
+
+ <label data-i18n="settings_timezone"></label>
+ <select id="settingsTimeZone"></select>
+
+ <label data-i18n="settings_default_language"></label>
+ <select id="settingsLanguage"></select>
+
+ <div class="form-actions">
+ <button type="button" class="btn btn-primary" id="saveSettingsBtn" data-i18n="settings_save"></button>
+ </div>
+ </section>
+ </div>
+
+ <div class="settings-wrap settings-wrap-wide" data-tab-panel="folders" style="display:none;">
+ <section class="settings-section">
+ <h2 data-i18n="settings_folders_title"></h2>
+ <div class="folder-manage-toolbar">
+ <a class="btn" href="#" id="addFolderBtn" data-i18n="settings_add_folder"></a>
+ <a class="btn" href="#" id="deleteFoldersBtn" data-i18n="settings_delete_selected"></a>
+ <a class="btn" href="#" id="reloadFoldersBtn" data-i18n="settings_reload_folders"></a>
+ </div>
+ <div class="folder-manage-table-wrap">
+ <table class="folder-manage-table">
+ <thead>
+ <tr>
+ <th></th>
+ <th><input type="checkbox" id="folderSelectAll" /></th>
+ <th data-i18n="settings_folder_column"></th>
+ <th data-i18n="settings_messages_column"></th>
+ <th data-i18n="settings_size_column"></th>
+ <th data-i18n="settings_sync_column"></th>
+ <th data-i18n="settings_subscribed_column"></th>
+ </tr>
+ </thead>
+ <tbody id="folderManageBody"></tbody>
+ <tfoot id="folderManageFoot"></tfoot>
+ </table>
+ </div>
+ <p class="settings-hint" data-i18n="settings_delete_non_empty_warning"></p>
+ <p class="settings-hint" data-i18n="settings_folder_order_hint"></p>
+ <div class="form-actions">
+ <button type="button" class="btn btn-primary" id="saveFoldersBtn" data-i18n="settings_save"></button>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+@section Scripts {
+ <script src="~/js/settings.js"></script>
+}
diff --git a/MailSharp.MailClient/Views/Shared/_Layout.cshtml b/MailSharp.MailClient/Views/Shared/_Layout.cshtml
new file mode 100644
index 0000000..0b13e69
--- /dev/null
+++ b/MailSharp.MailClient/Views/Shared/_Layout.cshtml
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="nl">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
+ <title>MailSharp</title>
+ <link rel="stylesheet" href="~/css/site.css" />
+</head>
+<body>
+ @RenderBody()
+ <div id="navLoadingCancel">
+ <button type="button" id="cancelRequestBtn" data-i18n="common_cancel_request"></button>
+ </div>
+ <script src="~/js/common.js"></script>
+ @await RenderSectionAsync("Scripts", required: false)
+</body>
+</html>
diff --git a/MailSharp.MailClient/Views/_ViewImports.cshtml b/MailSharp.MailClient/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..b79663c
--- /dev/null
+++ b/MailSharp.MailClient/Views/_ViewImports.cshtml
@@ -0,0 +1,4 @@
+@using MailSharp.MailClient
+@using MailSharp.MailClient.Models
+@using MailSharp.MailClient.Controllers
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/MailSharp.MailClient/Views/_ViewStart.cshtml b/MailSharp.MailClient/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..820a2f6
--- /dev/null
+++ b/MailSharp.MailClient/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
diff --git a/MailSharp.MailClient/appsettings.Development.json b/MailSharp.MailClient/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/MailSharp.MailClient/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/MailSharp.MailClient/appsettings.json b/MailSharp.MailClient/appsettings.json
new file mode 100644
index 0000000..0075935
--- /dev/null
+++ b/MailSharp.MailClient/appsettings.json
@@ -0,0 +1,25 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "MailSettings": {
+ "MessageListFetchLimit": 200,
+ "MessageListCacheTtlMinutes": 5,
+ "ContactMailSearchCacheTtlMinutes": 5,
+ "MessageListPageSize": 20,
+ "ContactMailsPageSize": 20,
+ "SessionIdleTimeoutHours": 24,
+ "DataProtectionKeysPath": "C:\\Data\\DataProtection\\Keys",
+ "DefaultImapServer": "127.0.0.1",
+ "DefaultImapPort": 993,
+ "DefaultImapSecurity": "SslTls",
+ "DefaultSmptServer": "127.0.0.1",
+ "DefaultSmtpPort": 465,
+ "DefaultSmtpSecurity": "SslTls",
+ "LogRetentionDays": 30
+ }
+}
diff --git a/MailSharp.MailClient/wwwroot/css/site.css b/MailSharp.MailClient/wwwroot/css/site.css
new file mode 100644
index 0000000..4fcbc98
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/css/site.css
@@ -0,0 +1,439 @@
+:root {
+ --bg: #f4f6f8;
+ --panel: #ffffff;
+ --border: #e2e5e9;
+ --text: #1f2430;
+ --text-muted: #6b7280;
+ --primary: #3860f4;
+ --primary-dark: #2a48c9;
+ --danger: #e5484d;
+ --accent-bg: #eef2ff;
+ --shadow: 0 1px 3px rgba(20, 25, 40, 0.08);
+ --radius: 10px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-size: 15px;
+}
+
+a { color: var(--primary); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* fetch-based navigation loading indicator (only shown once a request takes > 500ms) */
+body.nav-loading::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 18, 25, 0.15);
+ cursor: wait;
+ z-index: 9999;
+}
+
+#navLoadingCancel {
+ display: none;
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 10000;
+}
+body.nav-loading #navLoadingCancel { display: block; }
+
+button, .btn {
+ font-family: inherit;
+ font-size: 14px;
+ padding: 9px 16px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+ cursor: pointer;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+button:hover, .btn:hover { background: var(--accent-bg); }
+
+.btn-primary {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: #fff;
+}
+.btn-primary:hover { background: var(--primary-dark); }
+
+.btn-text {
+ border: none;
+ background: transparent;
+ color: var(--primary);
+ padding: 6px 8px;
+}
+
+input[type=text], input[type=email], input[type=password], input[type=number], select, textarea {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 14px;
+ font-family: inherit;
+ background: #fff;
+ color: var(--text);
+}
+input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
+
+label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom: 4px; margin-top: 12px; }
+
+.field-error { color: var(--danger); font-size: 13px; margin-top: 4px; }
+.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; font-size: 14px; }
+.alert-error { background: #fdeceb; color: var(--danger); border: 1px solid #f6c2c2; }
+.alert-info { background: var(--accent-bg); color: var(--primary-dark); border: 1px solid #c7d3fb; }
+.sensitivity-banner { background: #fff8e1; color: #8a6d1a; border: 1px solid #f0e0a6; }
+
+/* ---------- Auth pages ---------- */
+.auth-wrap {
+ min-height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+}
+.auth-card {
+ width: 100%;
+ max-width: 420px;
+ background: var(--panel);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ padding: 28px 26px;
+}
+.auth-card h1 { font-size: 22px; margin: 0 0 4px; }
+.auth-card p.sub { color: var(--text-muted); margin: 0 0 18px; font-size: 14px; }
+.auth-actions { margin-top: 20px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.advanced-toggle { margin-top: 16px; font-size: 13px; }
+.advanced-box { display: none; border-top: 1px dashed var(--border); margin-top: 16px; padding-top: 8px; }
+.advanced-box.show { display: block; }
+.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
+
+/* ---------- App shell ---------- */
+.app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+.topbar .brand { font-weight: 700; font-size: 17px; margin-right: 8px; }
+.topbar .spacer { flex: 1; }
+.account-email { color: var(--text-muted); font-size: 14px; text-decoration: none; }
+.account-email:hover { color: var(--text); text-decoration: underline; }
+.lang-select { width: auto; flex-shrink: 0; }
+.hamburger {
+ display: none;
+ border: none;
+ background: transparent;
+ font-size: 20px;
+ padding: 4px 8px;
+}
+
+.subheader {
+ display: flex;
+ gap: 4px;
+ padding: 0 14px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+.subheader-tab {
+ padding: 10px 14px;
+ color: var(--text-muted);
+ border-bottom: 2px solid transparent;
+ font-size: 14px;
+}
+.subheader-tab:hover { color: var(--text); text-decoration: none; }
+.subheader-tab.active { color: var(--primary-dark); font-weight: 600; border-bottom-color: var(--primary); }
+
+.app-body {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+
+.sidebar {
+ width: 230px;
+ flex-shrink: 0;
+ background: var(--panel);
+ border-right: 1px solid var(--border);
+ overflow-y: auto;
+ padding: 14px 10px;
+}
+.sidebar .compose-btn { display: block; text-align: center; margin-bottom: 12px; }
+.folder-list { list-style: none; margin: 0; padding: 0; }
+.folder-list li a {
+ display: flex;
+ justify-content: space-between;
+ padding: 9px 10px;
+ border-radius: 8px;
+ color: var(--text);
+}
+.folder-list li a:hover { background: var(--accent-bg); text-decoration: none; }
+.folder-list li a.active { background: var(--accent-bg); color: var(--primary-dark); font-weight: 600; }
+.folder-list .badge { color: var(--text-muted); font-size: 12px; }
+
+.content {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* message list */
+.list-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+ background: var(--panel);
+ flex-wrap: wrap;
+}
+.list-toolbar .sort-links { display: flex; gap: 4px; font-size: 13px; margin-left: auto; }
+.list-toolbar .sort-links a { padding: 6px 8px; border-radius: 6px; color: var(--text-muted); }
+.list-toolbar .sort-links a.active { color: var(--primary-dark); font-weight: 600; background: var(--accent-bg); }
+.list-toolbar select { width: auto; padding: 8px 10px; font-size: 13px; flex-shrink: 0; }
+
+.detail-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
+.detail-toolbar select { width: auto; padding: 8px 10px; font-size: 13px; }
+
+#messageListBody, #contactMailsBody { display: flex; flex-direction: column; flex: 1; min-height: 0; }
+.message-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; background: var(--panel); }
+.message-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+}
+.message-row:hover { background: var(--accent-bg); }
+.message-row.unread { background: #fbfcff; }
+.message-row.unread .subject { font-weight: 700; }
+.message-row.selected { background: var(--accent-bg); box-shadow: inset 3px 0 0 var(--primary); }
+.message-row input[type=checkbox] { flex-shrink: 0; }
+.message-row .main { flex: 1; min-width: 0; }
+.message-row .subject { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.message-row .from { color: var(--text-muted); font-size: 13px; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.message-row .meta { flex-shrink: 0; text-align: right; font-size: 12px; color: var(--text-muted); min-width: 90px; }
+.message-row .meta .date { white-space: nowrap; }
+.message-row .meta .size { margin-top: 2px; }
+.priority-high { color: var(--danger); font-weight: 700; margin-right: 4px; }
+.flag-toggle { flex-shrink: 0; color: var(--border); font-size: 16px; line-height: 1; cursor: pointer; }
+.flag-toggle:hover { color: var(--text-muted); }
+.flag-toggle.flagged { color: var(--danger); }
+
+.pager {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: nowrap;
+ gap: 14px;
+ padding: 10px 14px;
+ border-top: 1px solid var(--border);
+ background: var(--panel);
+ flex-shrink: 0;
+}
+.pager .pager-info { font-size: 12px; color: var(--text-muted); }
+.pager .btn.disabled { pointer-events: none; opacity: 0.4; }
+
+.empty-state { padding: 40px 20px; text-align: center; color: var(--text-muted); }
+
+.btn-text.active { color: var(--primary-dark); font-weight: 600; }
+
+.settings-page { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
+.settings-wrap { max-width: 640px; margin: 0 auto; padding: 24px; width: 100%; }
+.settings-wrap-wide { max-width: 1000px; }
+.settings-section { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; margin-bottom: 20px; }
+.settings-section h2 { font-size: 16px; margin: 0 0 14px; }
+.settings-section select { width: auto; }
+.settings-readonly { padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text-muted); }
+
+.folder-manage-toolbar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
+.folder-manage-table-wrap { overflow-x: auto; }
+.folder-manage-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.folder-manage-table th, .folder-manage-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
+.folder-manage-table th:nth-child(3), .folder-manage-table td:nth-child(3),
+.folder-manage-table th:nth-child(4), .folder-manage-table td:nth-child(4) { text-align: right; }
+.folder-manage-table select { width: auto; padding: 6px 8px; font-size: 13px; }
+.folder-manage-table tfoot td { border-top: 2px solid var(--border); border-bottom: none; }
+.settings-hint { color: var(--text-muted); font-size: 13px; margin-top: 12px; }
+
+.folder-manage-table .move-handle { cursor: grab; color: var(--text-muted); display: flex; gap: 2px; align-items: center; }
+.folder-manage-table .move-handle button {
+ border: none; background: transparent; padding: 2px 4px; font-size: 11px; cursor: pointer; color: var(--text-muted); line-height: 1;
+}
+.folder-manage-table .move-handle button:hover { color: var(--text); }
+.folder-manage-table .move-handle button:disabled { opacity: 0.25; cursor: default; }
+.folder-manage-table tr.dragging { opacity: 0.4; }
+.folder-manage-table tr.drag-over { box-shadow: inset 0 2px 0 var(--primary); }
+.folder-manage-table tr[data-protected="true"] .folder-row-select { visibility: hidden; }
+
+/* three-column mail layout: sidebar (folders) | message-list-pane | detail-pane */
+.content { flex-direction: row; }
+.message-list-pane {
+ width: 380px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-right: 1px solid var(--border);
+ background: var(--panel);
+}
+.detail-pane {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--bg);
+}
+.detail-pane .empty-state { margin: auto; }
+
+.col-resizer {
+ width: 6px;
+ flex-shrink: 0;
+ cursor: col-resize;
+ background: transparent;
+ position: relative;
+}
+.col-resizer::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 2px;
+ width: 2px;
+ background: transparent;
+}
+.col-resizer:hover::after, .col-resizer.resizing::after { background: var(--primary); }
+body.col-resizing { cursor: col-resize; user-select: none; }
+
+/* message detail */
+.message-detail {
+ padding: 20px 24px 0;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--panel);
+}
+.message-detail .subject-line { font-size: 20px; font-weight: 700; margin-bottom: 10px; flex-shrink: 0; }
+.message-detail .headers { font-size: 13px; color: var(--text-muted); margin-bottom: 16px; line-height: 1.8; flex-shrink: 0; }
+.message-detail .headers b { color: var(--text); }
+.message-detail .body-html {
+ border-top: 1px solid var(--border);
+ padding-top: 16px;
+ padding-bottom: 20px;
+ line-height: 1.5;
+ flex: 1;
+ min-height: 0;
+}
+.message-detail .body-html iframe { width: 100%; height: 100%; border: none; display: block; }
+.message-detail .body-html pre { height: 100%; overflow-y: auto; margin: 0; }
+
+#contactHeader { flex-wrap: wrap; align-items: flex-start; }
+.contact-header-info { display: flex; flex-direction: column; gap: 2px; margin-right: auto; }
+.contact-header-info .name { font-weight: 600; }
+.contact-header-info .email { color: var(--text-muted); font-size: 13px; }
+.attachments { margin-top: 18px; border-top: 1px dashed var(--border); padding-top: 12px; flex-shrink: 0; }
+.attachment-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; font-size: 13px; }
+.attachment-row .btn { padding: 4px 10px; font-size: 12px; }
+.attachments-list { margin-top: 6px; }
+
+/* compose */
+.compose-wrap { padding: 18px 22px; overflow-y: auto; flex: 1; background: var(--panel); }
+.compose-wrap .row-inline { display: flex; gap: 14px; flex-wrap: wrap; }
+.compose-wrap .row-inline > div { flex: 1; min-width: 140px; max-width: 420px; }
+#composeForm > input[type=text] { max-width: 420px; }
+.editor-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 6px;
+ border: 1px solid var(--border);
+ border-bottom: none;
+ border-radius: 8px 8px 0 0;
+ background: #fafbfc;
+}
+.editor-toolbar button {
+ padding: 6px 9px;
+ border: 1px solid transparent;
+ background: transparent;
+ font-size: 13px;
+ border-radius: 6px;
+}
+.editor-toolbar button:hover { background: var(--accent-bg); border-color: var(--border); }
+.editor-toolbar select, .editor-toolbar input[type=color] {
+ padding: 4px 6px;
+ width: auto;
+ font-size: 13px;
+}
+.editor-toolbar .sep { width: 1px; background: var(--border); margin: 3px 4px; }
+#editor {
+ min-height: 260px;
+ border: 1px solid var(--border);
+ border-radius: 0 0 8px 8px;
+ padding: 14px;
+ background: #fff;
+ line-height: 1.5;
+}
+#editor:focus { outline: none; }
+.compose-options { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 16px; align-items: center; }
+.checkbox-line { display: flex; align-items: center; gap: 6px; font-size: 14px; margin-top: 16px; }
+.checkbox-line input { width: auto; }
+.attach-list { margin-top: 10px; font-size: 13px; color: var(--text-muted); }
+.form-actions { margin-top: 20px; display: flex; gap: 10px; }
+
+/* ---------- Mobile ---------- */
+@media (max-width: 860px) {
+ .hamburger { display: inline-block; }
+ .col-resizer { display: none; }
+ .sidebar {
+ position: fixed;
+ top: 53px;
+ left: 0;
+ bottom: 0;
+ z-index: 20;
+ transform: translateX(-100%);
+ transition: transform .2s ease;
+ box-shadow: 2px 0 8px rgba(0,0,0,0.15);
+ }
+ .sidebar.open { transform: translateX(0); }
+ .app-body { position: relative; }
+ .message-row .meta { min-width: 60px; }
+ .message-row .from { display: none; }
+ .grid-2 { grid-template-columns: 1fr; }
+ .compose-wrap .row-inline { flex-direction: column; }
+}
+
+@media (max-width: 700px) {
+ .message-list-pane { width: 100%; border-right: none; }
+ .detail-pane { display: none; position: absolute; inset: 0; z-index: 5; }
+ .content { position: relative; }
+ .content.has-detail .message-list-pane { display: none; }
+ .content.has-detail .detail-pane { display: flex; }
+ .mobile-back { display: inline-block !important; }
+}
+
+.mobile-back { display: none; }
diff --git a/MailSharp.MailClient/wwwroot/js/common.js b/MailSharp.MailClient/wwwroot/js/common.js
new file mode 100644
index 0000000..a62f434
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/js/common.js
@@ -0,0 +1,315 @@
+window.MailSharp = (function ()
+{
+ "use strict";
+
+ var LOADING_DELAY_MS = 500;
+ var loadingTimer = null;
+ var dragging = null;
+ var activeControllers = [];
+ var currentStrings = {};
+ var currentTimeZone = null;
+
+ function esc(s)
+ {
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c)
+ {
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
+ });
+ }
+
+ function formatTemplate(template)
+ {
+ var args = Array.prototype.slice.call(arguments, 1);
+ var result = template || "";
+ args.forEach(function (a, i) { result = result.replace("{" + i + "}", a); });
+ return result;
+ }
+
+ function formatDate(iso)
+ {
+ var d = new Date(iso);
+ try
+ {
+ var parts = new Intl.DateTimeFormat("nl", {
+ timeZone: currentTimeZone || undefined,
+ day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false
+ }).formatToParts(d).reduce(function (acc, p) { acc[p.type] = p.value; return acc; }, {});
+ return parts.day + "-" + parts.month + "-" + parts.year + " " + parts.hour + ":" + parts.minute;
+ } catch (e)
+ {
+ var pad = function (n) { return n < 10 ? "0" + n : "" + n; };
+ return pad(d.getDate()) + "-" + pad(d.getMonth() + 1) + "-" + d.getFullYear() + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
+ }
+ }
+
+ function formatFullDate(iso, lang)
+ {
+ var d = new Date(iso);
+ try
+ {
+ return new Intl.DateTimeFormat(lang || "nl", {
+ timeZone: currentTimeZone || undefined,
+ weekday: "long", day: "2-digit", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false
+ }).format(d);
+ } catch (e)
+ {
+ return d.toString();
+ }
+ }
+
+ function showLoadingSoon()
+ {
+ clearTimeout(loadingTimer);
+ loadingTimer = setTimeout(function ()
+ {
+ document.body.classList.add("nav-loading");
+ }, LOADING_DELAY_MS);
+ }
+
+ function hideLoading()
+ {
+ clearTimeout(loadingTimer);
+ loadingTimer = null;
+ document.body.classList.remove("nav-loading");
+ }
+
+ // ASP.NET's [ApiController] returns a ValidationProblemDetails body ({ errors: { Field: [...] } })
+ // for model-binding/validation failures, and our own controllers return { error: "..." } - fall
+ // back to the ProblemDetails "title" or a bare status code if neither shape matches.
+ function extractErrorMessage(data, status)
+ {
+ if (data && data.error) return data.error;
+ if (data && data.errors)
+ {
+ var messages = [];
+ Object.keys(data.errors).forEach(function (field)
+ {
+ (data.errors[field] || []).forEach(function (m) { messages.push(m); });
+ });
+ if (messages.length > 0) return messages.join(" ");
+ }
+ if (data && data.title) return data.title;
+ return "HTTP " + status;
+ }
+
+ // Cancels every fetch currently in flight (started via apiFetch below) - wired to the
+ // "Cancel" button that appears once a request has been slow enough to show the loading
+ // overlay (see showLoadingSoon). The request reaches the server as a genuinely aborted HTTP
+ // connection, which ASP.NET Core surfaces as a cancelled RequestAborted token; controller
+ // actions that accept a CancellationToken and thread it into the IMAP calls (see
+ // MailApiController/ContactsApiController) will actually stop the in-progress IMAP command
+ // instead of just abandoning the response.
+ function cancelActiveRequests()
+ {
+ activeControllers.slice().forEach(function (c) { c.abort(); });
+ }
+
+ // Every page's data comes from these JSON endpoints via fetch - no data is ever
+ // embedded server-side into the HTML itself.
+ function apiFetch(url, options)
+ {
+ options = options || {};
+ var headers = Object.assign({ "X-Requested-With": "fetch" }, options.headers || {});
+ var fetchOpts = { method: options.method || "GET", headers: headers, credentials: "same-origin" };
+
+ if (options.body !== undefined)
+ {
+ if (options.body instanceof FormData)
+ {
+ fetchOpts.body = options.body;
+ } else
+ {
+ headers["Content-Type"] = "application/json";
+ fetchOpts.body = JSON.stringify(options.body);
+ }
+ }
+
+ var controller = new AbortController();
+ fetchOpts.signal = controller.signal;
+ activeControllers.push(controller);
+
+ showLoadingSoon();
+ return fetch(url, fetchOpts)
+ .then(function (response)
+ {
+ if (response.status === 401)
+ {
+ window.location.href = "/Account/Login";
+ throw new Error("unauthorized");
+ }
+ return response.text().then(function (text)
+ {
+ var data = text ? JSON.parse(text) : null;
+ if (!response.ok)
+ {
+ throw new Error(extractErrorMessage(data, response.status));
+ }
+ return data;
+ });
+ })
+ .catch(function (err)
+ {
+ if (err.name === "AbortError")
+ {
+ // Deliberate, user-initiated cancellation (see cancelActiveRequests) - not a
+ // real failure, so callers shouldn't alert the user about it. Flagged rather
+ // than silently swallowed here so callers can still skip their own follow-up
+ // work (e.g. not re-rendering with stale state) if they check err.cancelled.
+ var cancelledErr = new Error(currentStrings.common_request_cancelled || "Cancelled.");
+ cancelledErr.cancelled = true;
+ throw cancelledErr;
+ }
+ throw err;
+ })
+ .finally(function ()
+ {
+ var idx = activeControllers.indexOf(controller);
+ if (idx !== -1) activeControllers.splice(idx, 1);
+ hideLoading();
+ });
+ }
+
+ // Fills every element marked data-i18n="key" with the matching localized string.
+ function applyStrings(strings)
+ {
+ currentStrings = strings || {};
+ document.querySelectorAll("[data-i18n]").forEach(function (el)
+ {
+ var key = el.getAttribute("data-i18n");
+ if (strings[key] != null) el.textContent = strings[key];
+ });
+ document.querySelectorAll("[data-i18n-placeholder]").forEach(function (el)
+ {
+ var key = el.getAttribute("data-i18n-placeholder");
+ if (strings[key] != null) el.placeholder = strings[key];
+ });
+ }
+
+ function applySavedColumnWidths()
+ {
+ document.querySelectorAll(".col-resizer").forEach(function (handle)
+ {
+ var key = "colwidth_" + handle.getAttribute("data-resize-key");
+ var target = document.querySelector(handle.getAttribute("data-resize-target"));
+ if (!target) return;
+ var saved = localStorage.getItem(key);
+ if (saved) target.style.width = saved + "px";
+ });
+ }
+
+ document.addEventListener("mousedown", function (e)
+ {
+ var handle = e.target.closest(".col-resizer");
+ if (!handle) return;
+ var target = document.querySelector(handle.getAttribute("data-resize-target"));
+ if (!target) return;
+
+ dragging = {
+ handle: handle,
+ target: target,
+ key: "colwidth_" + handle.getAttribute("data-resize-key"),
+ min: parseInt(handle.getAttribute("data-min"), 10) || 150,
+ max: parseInt(handle.getAttribute("data-max"), 10) || 800,
+ startX: e.clientX,
+ startWidth: target.getBoundingClientRect().width
+ };
+ handle.classList.add("resizing");
+ document.body.classList.add("col-resizing");
+ e.preventDefault();
+ });
+
+ document.addEventListener("mousemove", function (e)
+ {
+ if (!dragging) return;
+ var newWidth = dragging.startWidth + (e.clientX - dragging.startX);
+ newWidth = Math.max(dragging.min, Math.min(dragging.max, newWidth));
+ dragging.target.style.width = newWidth + "px";
+ });
+
+ document.addEventListener("mouseup", function ()
+ {
+ if (!dragging) return;
+ dragging.handle.classList.remove("resizing");
+ document.body.classList.remove("col-resizing");
+ localStorage.setItem(dragging.key, Math.round(dragging.target.getBoundingClientRect().width));
+ dragging = null;
+ });
+
+ document.addEventListener("DOMContentLoaded", applySavedColumnWidths);
+
+ document.addEventListener("DOMContentLoaded", function ()
+ {
+ var btn = document.getElementById("cancelRequestBtn");
+ if (btn) btn.addEventListener("click", cancelActiveRequests);
+ });
+
+ // Shared bootstrap for every page's topbar: auth check + redirect, account email text,
+ // language select + logout wiring. Elements are optional - pages without a #langSelect or
+ // #logoutBtn (there are none currently, but future page shells might trim the header) just
+ // skip that part instead of throwing.
+ function initHeader()
+ {
+ return apiFetch("/api/auth/state").then(function (authState)
+ {
+ if (!authState.isLoggedIn)
+ {
+ window.location.href = "/Account/Login";
+ throw new Error("redirecting to login");
+ }
+
+ var emailEl = document.getElementById("accountEmail");
+ if (emailEl) emailEl.textContent = authState.account.emailAddress;
+ currentTimeZone = authState.account.timeZoneId || null;
+
+ var maintenanceLink = document.getElementById("maintenanceLink");
+ if (maintenanceLink && authState.account.isAdmin) maintenanceLink.style.display = "";
+
+ return apiFetch("/api/language/state").then(function (langState)
+ {
+ document.documentElement.lang = langState.current;
+ applyStrings(langState.strings);
+
+ var langSel = document.getElementById("langSelect");
+ if (langSel)
+ {
+ langSel.title = langState.strings.topbar_language || "";
+ langSel.innerHTML = langState.languages.map(function (l)
+ {
+ return '<option value="' + esc(l.code) + '"' + (l.code === langState.current ? " selected" : "") + ">" + esc(l.name) + "</option>";
+ }).join("");
+ langSel.addEventListener("change", function (e)
+ {
+ apiFetch("/api/language/set", { method: "POST", body: { lang: e.target.value } })
+ .then(function () { window.location.reload(); });
+ });
+ }
+
+ var logoutBtn = document.getElementById("logoutBtn");
+ if (logoutBtn)
+ {
+ logoutBtn.addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ apiFetch("/api/auth/logout", { method: "POST" }).then(function ()
+ {
+ window.location.href = "/Account/Login";
+ });
+ });
+ }
+
+ return { authState: authState, langState: langState };
+ });
+ });
+ }
+
+ return {
+ esc: esc,
+ formatTemplate: formatTemplate,
+ formatDate: formatDate,
+ formatFullDate: formatFullDate,
+ apiFetch: apiFetch,
+ applyStrings: applyStrings,
+ applySavedColumnWidths: applySavedColumnWidths,
+ initHeader: initHeader
+ };
+})();
diff --git a/MailSharp.MailClient/wwwroot/js/compose.js b/MailSharp.MailClient/wwwroot/js/compose.js
new file mode 100644
index 0000000..a70c2ac
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/js/compose.js
@@ -0,0 +1,187 @@
+(function ()
+{
+ "use strict";
+ var $ = window.MailSharp;
+
+ // Images inserted via the upload button (as opposed to the URL-based insertImage command) -
+ // {cid, file}. The editor only ever holds a data: URI preview of these locally; the raw file
+ // is uploaded separately (see submit()) and the src is rewritten to "cid:<cid>" right before
+ // the HtmlBody is serialized, so the final message references the attached inline part instead
+ // of embedding the image twice.
+ var inlineImages = [];
+
+ $.apiFetch("/api/auth/state").then(function (authState)
+ {
+ if (!authState.isLoggedIn)
+ {
+ window.location.href = "/Account/Login";
+ return;
+ }
+ document.getElementById("AccountId").value = authState.account.id;
+
+ return $.apiFetch("/api/language/state").then(function (langState)
+ {
+ $.applyStrings(langState.strings);
+ document.documentElement.lang = langState.current;
+ document.getElementById("linkBtn").setAttribute("data-prompt", langState.strings.compose_prompt_link_url);
+ document.getElementById("imageBtn").setAttribute("data-prompt", langState.strings.compose_prompt_image_url);
+ document.getElementById("ToInput").placeholder = langState.strings.compose_to_placeholder;
+ applyPrefill(authState.account);
+ });
+ });
+
+ function applyPrefill(account)
+ {
+ var raw = sessionStorage.getItem("mailsharp_compose_prefill");
+ sessionStorage.removeItem("mailsharp_compose_prefill");
+ var p = {};
+ if (raw) { try { p = JSON.parse(raw) || {}; } catch (e) { p = {}; } }
+
+ if (p.to) document.getElementById("ToInput").value = p.to;
+ if (p.cc) document.querySelector("[name=Cc]").value = p.cc;
+ if (p.subject) document.querySelector("[name=Subject]").value = p.subject;
+
+ var editor = document.getElementById("editor");
+ if (p.quoteHtml)
+ {
+ editor.innerHTML = p.quoteHtml;
+ } else if (account && account.signature)
+ {
+ editor.innerHTML = "<br><br>" + $.esc(account.signature).replace(/\n/g, "<br>");
+ }
+ }
+
+ document.querySelectorAll("[data-cmd]").forEach(function (btn)
+ {
+ btn.addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ var editor = document.getElementById("editor");
+ var cmd = btn.getAttribute("data-cmd");
+ if (cmd === "createLink")
+ {
+ var url = window.prompt(btn.getAttribute("data-prompt"), "https://");
+ if (!url) return;
+ document.execCommand("createLink", false, url);
+ } else if (cmd === "insertImage")
+ {
+ var imgUrl = window.prompt(btn.getAttribute("data-prompt"), "https://");
+ if (!imgUrl) return;
+ document.execCommand("insertImage", false, imgUrl);
+ } else
+ {
+ document.execCommand(cmd, false, undefined);
+ }
+ editor.focus();
+ });
+ });
+
+ document.getElementById("uploadImageBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ document.getElementById("inlineImageInput").click();
+ });
+
+ document.getElementById("inlineImageInput").addEventListener("change", function (e)
+ {
+ var files = Array.prototype.slice.call(e.target.files || []);
+ e.target.value = ""; // allow re-selecting the same file later
+
+ files.forEach(function (file)
+ {
+ var cid = "img" + Date.now() + Math.floor(Math.random() * 100000) + "@mailsharp";
+ var reader = new FileReader();
+ reader.onload = function ()
+ {
+ inlineImages.push({ cid: cid, file: file });
+ var editor = document.getElementById("editor");
+ editor.focus();
+ document.execCommand("insertHTML", false,
+ '<img src="' + reader.result + '" data-cid="' + cid + '" style="max-width:100%;" />');
+ };
+ reader.readAsDataURL(file);
+ });
+ });
+
+ function renderAttachmentsList()
+ {
+ var input = document.getElementById("attachmentsInput");
+ var files = Array.prototype.slice.call(input.files || []);
+ var list = document.getElementById("attachmentsList");
+ list.innerHTML = files.map(function (f)
+ {
+ return '<div class="attachment-row"><span>&#128206; ' + $.esc(f.name) + " (" + Math.round(f.size / 1024 * 10) / 10 + " kB)</span></div>";
+ }).join("");
+ }
+
+ document.getElementById("attachmentsInput").addEventListener("change", renderAttachmentsList);
+
+ document.getElementById("fontName").addEventListener("change", function (e)
+ {
+ document.execCommand("fontName", false, e.target.value);
+ document.getElementById("editor").focus();
+ });
+ document.getElementById("fontSize").addEventListener("change", function (e)
+ {
+ document.execCommand("fontSize", false, e.target.value);
+ document.getElementById("editor").focus();
+ });
+ document.getElementById("textColor").addEventListener("input", function (e)
+ {
+ document.execCommand("foreColor", false, e.target.value);
+ document.getElementById("editor").focus();
+ });
+ document.getElementById("bgColor").addEventListener("input", function (e)
+ {
+ document.execCommand("hiliteColor", false, e.target.value);
+ document.getElementById("editor").focus();
+ });
+
+ function submit(endpoint)
+ {
+ var form = document.getElementById("composeForm");
+ var editor = document.getElementById("editor");
+
+ // Rewrite each uploaded inline image's src from its local data: URI preview to "cid:<cid>"
+ // right before serializing - only for images still actually present in the editor (the
+ // user may have deleted one after inserting it), so nothing gets uploaded/attached for an
+ // image that's no longer referenced anywhere in the body. Done on a detached clone, not the
+ // live editor, so a failed send doesn't leave the visible editor showing broken images.
+ var clone = editor.cloneNode(true);
+ var usedCids = {};
+ clone.querySelectorAll("img[data-cid]").forEach(function (img)
+ {
+ var cid = img.getAttribute("data-cid");
+ usedCids[cid] = true;
+ img.setAttribute("src", "cid:" + cid);
+ });
+
+ document.getElementById("HtmlBody").value = clone.innerHTML;
+ var formData = new FormData(form);
+ inlineImages.forEach(function (item)
+ {
+ if (usedCids[item.cid]) formData.append("inline:" + item.cid, item.file, item.file.name);
+ });
+ $.apiFetch(endpoint, { method: "POST", body: formData })
+ .then(function (result)
+ {
+ if (result && result.sentError)
+ {
+ window.alert("Verzonden, maar niet opgeslagen in Verzonden items: " + result.sentError);
+ }
+ window.location.href = "/";
+ })
+ .catch(function (err) { if (!err.cancelled) window.alert(err.message); });
+ }
+
+ document.getElementById("composeForm").addEventListener("submit", function (e)
+ {
+ e.preventDefault();
+ submit("/api/mail/send");
+ });
+
+ document.getElementById("saveDraftBtn").addEventListener("click", function ()
+ {
+ submit("/api/mail/save-draft");
+ });
+})();
diff --git a/MailSharp.MailClient/wwwroot/js/contacts.js b/MailSharp.MailClient/wwwroot/js/contacts.js
new file mode 100644
index 0000000..873a6ba
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/js/contacts.js
@@ -0,0 +1,457 @@
+(function ()
+{
+ "use strict";
+ var $ = window.MailSharp;
+
+ var DEFAULT_mailsPageSize = 20;
+ var mailsPageSize = DEFAULT_mailsPageSize;
+
+ var state = {
+ strings: {},
+ lang: "nl",
+ contacts: [],
+ filter: "",
+ selectedEmail: null,
+ editing: false,
+ mails: null,
+ mailsPage: 1,
+ selectedMail: null,
+ selectedMessage: null
+ };
+
+ function bootstrap()
+ {
+ $.initHeader().then(function (result)
+ {
+ state.strings = result.langState.strings;
+ state.lang = result.langState.current;
+ if (result.authState.account && result.authState.account.contactsPerPage > 0)
+ {
+ mailsPageSize = result.authState.account.contactsPerPage;
+ }
+ return $.apiFetch("/api/contacts");
+ }).then(function (contacts)
+ {
+ state.contacts = contacts;
+ renderContactList();
+ renderHeader();
+ renderMailsList();
+ renderMailDetail();
+ });
+ }
+
+ function filteredContacts()
+ {
+ var f = state.filter.toLowerCase();
+ if (!f) return state.contacts;
+ return state.contacts.filter(function (c)
+ {
+ return c.displayName.toLowerCase().indexOf(f) !== -1 || c.email.toLowerCase().indexOf(f) !== -1;
+ });
+ }
+
+ function selectedContact()
+ {
+ return state.contacts.find(function (c) { return c.email === state.selectedEmail; });
+ }
+
+ // ---------- rendering ----------
+
+ function renderContactList()
+ {
+ var t = state.strings;
+ var list = filteredContacts();
+ var html;
+ if (list.length === 0)
+ {
+ html = '<div class="empty-state">' + $.esc(t.contacts_empty) + "</div>";
+ } else
+ {
+ html = '<ul class="message-list">' + list.map(function (c)
+ {
+ var selected = c.email === state.selectedEmail ? "selected" : "";
+ return (
+ '<li class="message-row ' + selected + '" data-email="' + $.esc(c.email) + '">' +
+ '<div class="main"><div class="subject">' + $.esc(c.displayName) + '</div>' +
+ '<div class="from">' + $.esc(c.email) + "</div></div>" +
+ "</li>"
+ );
+ }).join("") + "</ul>";
+ }
+ document.getElementById("contactListBody").innerHTML = html;
+ }
+
+ function renderHeader()
+ {
+ var t = state.strings;
+ var header = document.getElementById("contactHeader");
+ var contact = selectedContact();
+
+ if (!contact)
+ {
+ header.innerHTML = '<span class="contact-header-info">' + $.esc(t.contacts_select) + "</span>";
+ return;
+ }
+
+ var infoHtml = state.editing
+ ? (
+ '<div class="row-inline"><input type="text" id="editDisplayName" value="' + $.esc(contact.displayName) + '" /></div>'
+ )
+ : (
+ '<div class="contact-header-info">' +
+ '<span class="name">' + $.esc(contact.displayName) + "</span>" +
+ '<span class="email">' + $.esc(contact.email) + "</span>" +
+ "</div>"
+ );
+
+ var actionsHtml = state.editing
+ ? (
+ '<button type="button" class="btn btn-primary" id="saveContactBtn">' + $.esc(t.contacts_save) + "</button>" +
+ '<button type="button" class="btn" id="cancelEditBtn">' + $.esc(t.contacts_cancel) + "</button>"
+ )
+ : (
+ '<a class="btn" href="#" id="refreshContactMailsBtn">' + $.esc(t.mail_refresh) + "</a>" +
+ '<a class="btn" href="#" id="editContactBtn">' + $.esc(t.contacts_edit) + "</a>" +
+ '<a class="btn btn-primary" href="#" id="mailContactBtn">' + $.esc(t.contacts_mail) + "</a>"
+ );
+
+ header.innerHTML = infoHtml + actionsHtml;
+ }
+
+ function renderMailsList()
+ {
+ var t = state.strings;
+ var body = document.getElementById("contactMailsBody");
+ var contact = selectedContact();
+
+ if (!contact)
+ {
+ body.innerHTML = '<div class="empty-state">' + $.esc(t.contacts_select) + "</div>";
+ return;
+ }
+
+ if (state.mails == null)
+ {
+ body.innerHTML = "";
+ return;
+ }
+
+ if (state.mails.length === 0)
+ {
+ body.innerHTML = '<div class="empty-state">' + $.esc(t.contacts_no_mails) + "</div>";
+ return;
+ }
+
+ var totalCount = state.mails.length;
+ var totalPages = Math.max(1, Math.ceil(totalCount / mailsPageSize));
+ var page = Math.min(Math.max(1, state.mailsPage), totalPages);
+ var pageMails = state.mails.slice((page - 1) * mailsPageSize, page * mailsPageSize);
+
+ body.innerHTML =
+ '<ul class="message-list">' + pageMails.map(renderMailRow).join("") + "</ul>" +
+ '<div class="pager">' +
+ '<a href="#" class="btn ' + (page <= 1 ? "disabled" : "") + '" data-mail-page="' + (page - 1) + '">' + $.esc(t.mail_previous) + "</a>" +
+ '<span class="pager-info">' + $.esc($.formatTemplate(t.mail_pager_info, page, totalPages, totalCount)) + "</span>" +
+ '<a href="#" class="btn ' + (page >= totalPages ? "disabled" : "") + '" data-mail-page="' + (page + 1) + '">' + $.esc(t.mail_next) + "</a>" +
+ "</div>";
+ }
+
+ function renderMailRow(m)
+ {
+ var selected = state.selectedMail && state.selectedMail.uid === m.uid && state.selectedMail.folder === m.folder ? "selected" : "";
+ return (
+ '<li class="message-row ' + selected + '" data-folder="' + $.esc(m.folder) + '" data-uid="' + m.uid + '">' +
+ '<div class="main"><div class="subject">' + $.esc(m.subject) + '</div>' +
+ '<div class="from">' + $.esc(m.from) + "</div></div>" +
+ '<div class="meta"><div class="date">' + $.formatDate(m.date) + "</div></div>" +
+ "</li>"
+ );
+ }
+
+ function updateSelectedMailRowClasses()
+ {
+ document.querySelectorAll("#contactMailsBody .message-row").forEach(function (row)
+ {
+ var uid = parseInt(row.getAttribute("data-uid"), 10);
+ var folder = row.getAttribute("data-folder");
+ var isSelected = !!state.selectedMail && state.selectedMail.uid === uid && state.selectedMail.folder === folder;
+ row.classList.toggle("selected", isSelected);
+ });
+ }
+
+ function renderMailDetail()
+ {
+ var t = state.strings;
+ var pane = document.getElementById("contactMailDetailPane");
+ var content = document.getElementById("contactContent");
+ var m = state.selectedMessage;
+
+ if (!m)
+ {
+ pane.innerHTML = '<div class="empty-state">' + $.esc(t.detail_empty) + "</div>";
+ content.classList.remove("has-detail");
+ return;
+ }
+ content.classList.add("has-detail");
+
+ var imagesBanner = "";
+ if (m.hasExternalImages && !m.imagesAllowed)
+ {
+ imagesBanner =
+ '<div class="alert alert-info image-block-banner">' +
+ $.esc(t.detail_images_blocked) + " " +
+ '<button type="button" class="btn-link" id="allowImagesBtn">' +
+ $.esc($.formatTemplate(t.detail_always_show_for, m.senderEmail)) +
+ "</button>" +
+ "</div>";
+ }
+
+ var attachmentsHtml = "";
+ if (m.attachments && m.attachments.length > 0)
+ {
+ attachmentsHtml =
+ '<div class="attachments">' +
+ m.attachments.map(function (att)
+ {
+ return (
+ '<div class="attachment-row">' +
+ "<span>&#128206; " + $.esc(att.fileName) + " (" + (Math.round(att.sizeBytes / 1024 * 10) / 10) + " kB)</span>" +
+ '<button type="button" class="btn" data-attachment="view" data-part="' + att.partIndex + '" data-filename="' + $.esc(att.fileName) + '">' + $.esc(t.detail_view) + "</button>" +
+ '<button type="button" class="btn" data-attachment="download" data-part="' + att.partIndex + '" data-filename="' + $.esc(att.fileName) + '">' + $.esc(t.detail_download) + "</button>" +
+ "</div>"
+ );
+ }).join("") +
+ "</div>";
+ }
+
+ var bodyHtml = (m.htmlBody && m.htmlBody.trim() !== "")
+ ? '<div class="body-html"><iframe srcdoc="' + $.esc(m.htmlBody) + '" sandbox=""></iframe></div>'
+ : '<div class="body-html"><pre style="white-space:pre-wrap;font-family:inherit;">' + $.esc(m.textBody) + "</pre></div>";
+
+ pane.innerHTML =
+ '<div class="message-detail">' +
+ '<a class="btn mobile-back" href="#" id="closeMailDetailBtn" style="margin-bottom:12px;">' + $.esc(t.detail_back_to_list) + "</a>" +
+ '<div class="subject-line">' + $.esc(m.subject) + "</div>" +
+ '<div class="headers">' +
+ "<div><b>" + $.esc(t.detail_from) + "</b> " + $.esc(m.from) + "</div>" +
+ "<div><b>" + $.esc(t.detail_to) + "</b> " + $.esc(m.to) + "</div>" +
+ (m.cc ? "<div><b>" + $.esc(t.detail_cc) + "</b> " + $.esc(m.cc) + "</div>" : "") +
+ "<div><b>" + $.esc(t.detail_date) + "</b> " + $.esc($.formatFullDate(m.date, state.lang)) + "</div>" +
+ "</div>" +
+ imagesBanner +
+ attachmentsHtml +
+ bodyHtml +
+ "</div>";
+ }
+
+ // ---------- data loading ----------
+
+ function loadContactMails(email, opts)
+ {
+ opts = opts || {};
+ var params = new URLSearchParams({ email: email, refresh: !!opts.refresh });
+ return $.apiFetch("/api/contacts/mails?" + params.toString()).then(function (mails)
+ {
+ state.mails = mails;
+ state.mailsPage = 1;
+ });
+ }
+
+ function openMail(folder, uid)
+ {
+ if (state.selectedMail && state.selectedMail.uid === uid && state.selectedMail.folder === folder) return;
+
+ state.selectedMail = { folder: folder, uid: uid };
+ updateSelectedMailRowClasses();
+
+ var params = new URLSearchParams({ folder: folder });
+ $.apiFetch("/api/mail/messages/" + uid + "?" + params.toString()).then(function (detail)
+ {
+ state.selectedMessage = detail;
+ renderMailDetail();
+ }).catch(reportError);
+ }
+
+ function closeMailDetail()
+ {
+ state.selectedMail = null;
+ state.selectedMessage = null;
+ renderMailDetail();
+ updateSelectedMailRowClasses();
+ }
+
+ function reportError(err)
+ {
+ if (err.cancelled) return;
+ window.alert(err.message);
+ }
+
+ // ---------- events ----------
+
+ document.getElementById("sidebarToggle").addEventListener("click", function ()
+ {
+ document.getElementById("sidebar").classList.toggle("open");
+ });
+
+ document.getElementById("contactSearch").addEventListener("input", function (e)
+ {
+ state.filter = e.target.value;
+ renderContactList();
+ });
+
+ document.getElementById("contactListBody").addEventListener("click", function (e)
+ {
+ var row = e.target.closest(".message-row[data-email]");
+ if (!row) return;
+ var email = row.getAttribute("data-email");
+ if (email === state.selectedEmail) return;
+
+ state.selectedEmail = email;
+ state.editing = false;
+ state.mails = null;
+ state.mailsPage = 1;
+ state.selectedMail = null;
+ state.selectedMessage = null;
+ document.getElementById("sidebar").classList.remove("open");
+
+ renderContactList();
+ renderHeader();
+ renderMailsList();
+ renderMailDetail();
+
+ loadContactMails(email).then(renderMailsList).catch(reportError);
+ });
+
+ document.getElementById("contactHeader").addEventListener("click", function (e)
+ {
+ if (e.target.id === "refreshContactMailsBtn")
+ {
+ e.preventDefault();
+ loadContactMails(state.selectedEmail, { refresh: true }).then(renderMailsList).catch(reportError);
+ return;
+ }
+ if (e.target.id === "mailContactBtn")
+ {
+ e.preventDefault();
+ sessionStorage.setItem("mailsharp_compose_prefill", JSON.stringify({ to: state.selectedEmail }));
+ window.location.href = "/Mail/Compose";
+ return;
+ }
+ if (e.target.id === "editContactBtn")
+ {
+ e.preventDefault();
+ state.editing = true;
+ renderHeader();
+ return;
+ }
+ if (e.target.id === "cancelEditBtn")
+ {
+ e.preventDefault();
+ state.editing = false;
+ renderHeader();
+ return;
+ }
+ if (e.target.id === "saveContactBtn")
+ {
+ e.preventDefault();
+ var newName = document.getElementById("editDisplayName").value.trim();
+ $.apiFetch("/api/contacts/update", { method: "POST", body: { email: state.selectedEmail, displayName: newName } })
+ .then(function ()
+ {
+ var contact = selectedContact();
+ if (contact) contact.displayName = newName || contact.email;
+ state.editing = false;
+ renderContactList();
+ renderHeader();
+ })
+ .catch(reportError);
+ return;
+ }
+ });
+
+ document.getElementById("contactMailsBody").addEventListener("click", function (e)
+ {
+ var mailPageLink = e.target.closest("a[data-mail-page]");
+ if (mailPageLink)
+ {
+ e.preventDefault();
+ var newPage = parseInt(mailPageLink.getAttribute("data-mail-page"), 10) || 1;
+ state.mailsPage = newPage;
+ renderMailsList();
+ return;
+ }
+
+ var row = e.target.closest(".message-row[data-uid]");
+ if (row) openMail(row.getAttribute("data-folder"), parseInt(row.getAttribute("data-uid"), 10));
+ });
+
+ document.getElementById("contactMailDetailPane").addEventListener("click", function (e)
+ {
+ if (e.target.id === "closeMailDetailBtn")
+ {
+ e.preventDefault();
+ closeMailDetail();
+ return;
+ }
+ if (e.target.id === "allowImagesBtn")
+ {
+ e.preventDefault();
+ var sender = state.selectedMessage.senderEmail;
+ $.apiFetch("/api/mail/allow-images", { method: "POST", body: { sender: sender } }).then(function ()
+ {
+ return openMailRefresh();
+ }).catch(reportError);
+ return;
+ }
+
+ var attBtn = e.target.closest("[data-attachment]");
+ if (attBtn) downloadOrViewAttachment(attBtn);
+ });
+
+ function openMailRefresh()
+ {
+ var mail = state.selectedMail;
+ var params = new URLSearchParams({ folder: mail.folder });
+ return $.apiFetch("/api/mail/messages/" + mail.uid + "?" + params.toString()).then(function (detail)
+ {
+ state.selectedMessage = detail;
+ renderMailDetail();
+ });
+ }
+
+ function downloadOrViewAttachment(btn)
+ {
+ var mail = state.selectedMail;
+ var part = btn.getAttribute("data-part");
+ var fileName = btn.getAttribute("data-filename");
+ var mode = btn.getAttribute("data-attachment");
+ var params = new URLSearchParams({ folder: mail.folder });
+
+ fetch("/api/mail/attachments/" + mail.uid + "/" + part + "?" + params.toString(), { credentials: "same-origin" })
+ .then(function (response)
+ {
+ if (!response.ok) throw new Error("HTTP " + response.status);
+ return response.blob();
+ })
+ .then(function (blob)
+ {
+ var blobUrl = URL.createObjectURL(blob);
+ if (mode === "view")
+ {
+ window.open(blobUrl, "_blank");
+ } else
+ {
+ var a = document.createElement("a");
+ a.href = blobUrl;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ }
+ setTimeout(function () { URL.revokeObjectURL(blobUrl); }, 60000);
+ })
+ .catch(reportError);
+ }
+
+ bootstrap();
+})();
diff --git a/MailSharp.MailClient/wwwroot/js/login.js b/MailSharp.MailClient/wwwroot/js/login.js
new file mode 100644
index 0000000..ba6ddfb
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/js/login.js
@@ -0,0 +1,71 @@
+(function ()
+{
+ "use strict";
+ var $ = window.MailSharp;
+
+ $.apiFetch("/api/auth/state").then(function (authState)
+ {
+ if (authState.isLoggedIn)
+ {
+ window.location.href = "/";
+ return;
+ }
+
+ return Promise.all([
+ $.apiFetch("/api/language/state"),
+ $.apiFetch("/api/auth/login-defaults")
+ ]).then(function (results)
+ {
+ var langState = results[0];
+ var defaults = results[1];
+
+ $.applyStrings(langState.strings);
+ document.documentElement.lang = langState.current;
+
+ document.getElementById("ImapHost").value = defaults.imapHost;
+ document.getElementById("SmtpHost").value = defaults.smtpHost;
+ document.getElementById("ImapPort").value = defaults.imapPort;
+ document.getElementById("SmtpPort").value = defaults.smtpPort;
+ document.getElementById("ImapSecurity").value = defaults.imapSecurity;
+ document.getElementById("SmtpSecurity").value = defaults.smtpSecurity;
+ });
+ });
+
+ document.getElementById("advancedToggle").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ document.getElementById("advancedBox").classList.toggle("show");
+ });
+
+ document.getElementById("loginForm").addEventListener("submit", function (e)
+ {
+ e.preventDefault();
+ var form = e.target;
+ var fd = new FormData(form);
+ var advancedCheckbox = form.querySelector('input[name="Advanced"]');
+
+ var payload = {
+ emailAddress: fd.get("EmailAddress") || "",
+ username: fd.get("Username") || "",
+ password: fd.get("Password") || "",
+ advanced: advancedCheckbox ? advancedCheckbox.checked : false,
+ imapHost: fd.get("ImapHost") || "",
+ imapPort: parseInt(fd.get("ImapPort"), 10) || 0,
+ imapSecurity: fd.get("ImapSecurity") || "SslTls",
+ smtpHost: fd.get("SmtpHost") || "",
+ smtpPort: parseInt(fd.get("SmtpPort"), 10) || 0,
+ smtpSecurity: fd.get("SmtpSecurity") || "StartTls"
+ };
+
+ var errorBox = document.getElementById("loginError");
+ errorBox.style.display = "none";
+
+ $.apiFetch("/api/auth/login", { method: "POST", body: payload })
+ .then(function () { window.location.href = "/"; })
+ .catch(function (err)
+ {
+ errorBox.textContent = err.message;
+ errorBox.style.display = "";
+ });
+ });
+})();
diff --git a/MailSharp.MailClient/wwwroot/js/mail.js b/MailSharp.MailClient/wwwroot/js/mail.js
new file mode 100644
index 0000000..0c81ac4
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/js/mail.js
@@ -0,0 +1,641 @@
+(function ()
+{
+ "use strict";
+ var $ = window.MailSharp;
+
+ var state = {
+ lang: "nl",
+ strings: {},
+ account: null,
+ accounts: [],
+ languages: [],
+ folders: [],
+ currentFolder: "INBOX",
+ sort: "date",
+ desc: true,
+ page: 1,
+ pageSize: 20,
+ totalCount: 0,
+ totalPages: 1,
+ messages: [],
+ selectedUid: null,
+ selectedMessage: null
+ };
+
+ function bootstrap()
+ {
+ var pendingOpen = null;
+ var pendingRaw = sessionStorage.getItem("mailsharp_open_message");
+ if (pendingRaw)
+ {
+ sessionStorage.removeItem("mailsharp_open_message");
+ try { pendingOpen = JSON.parse(pendingRaw); } catch (e) { pendingOpen = null; }
+ }
+ if (pendingOpen && pendingOpen.folder) state.currentFolder = pendingOpen.folder;
+
+ $.initHeader().then(function (result)
+ {
+ state.account = result.authState.account;
+ state.accounts = result.authState.accounts;
+ state.lang = result.langState.current;
+ state.languages = result.langState.languages;
+ state.strings = result.langState.strings;
+ updateSortLinksActive();
+
+ return Promise.all([loadFolders(), loadMessages()]);
+ }).then(function ()
+ {
+ renderFolderList();
+ renderMessageList();
+ renderDetailPane();
+ $.applySavedColumnWidths();
+ if (pendingOpen && pendingOpen.uid != null) openMessage(pendingOpen.uid);
+ setupAutoCheck();
+ });
+ }
+
+ // Silently re-polls the current folder at the account's configured interval (Settings >
+ // Autocheck interval) - a plain setInterval rather than something restartable, since the
+ // interval only changes via the Settings page, which does a full page reload after saving.
+ function setupAutoCheck()
+ {
+ var minutes = state.account && state.account.autoCheckIntervalMinutes;
+ if (!minutes || minutes <= 0) return;
+ setInterval(function () { refreshAfterAction().catch(function () { }); }, minutes * 60 * 1000);
+ }
+
+ function renderMoveToFolderOptions()
+ {
+ var sel = document.getElementById("moveToFolder");
+ var placeholder = sel.options[0];
+ sel.innerHTML = "";
+ sel.appendChild(placeholder);
+ state.folders.forEach(function (f)
+ {
+ if (!f.isSelectable || f.fullName === state.currentFolder) return;
+ var opt = document.createElement("option");
+ opt.value = f.fullName;
+ opt.textContent = f.displayName;
+ sel.appendChild(opt);
+ });
+ }
+
+ function getSelectedUids()
+ {
+ var uids = [];
+ document.querySelectorAll(".message-row input[type=checkbox]:checked").forEach(function (cb)
+ {
+ uids.push(parseInt(cb.closest(".message-row").getAttribute("data-uid"), 10));
+ });
+
+ // Only fall back to the currently open message when nothing is explicitly ticked - opening
+ // a message counts as an implicit selection (without visibly checking its checkbox), but
+ // that implicit selection steps aside the moment the user starts explicitly picking
+ // checkboxes, so a bulk action then only applies to what's actually ticked.
+ if (uids.length === 0 && state.selectedUid != null) uids.push(state.selectedUid);
+
+ return uids;
+ }
+
+ // ---------- data loading ----------
+
+ function loadFolders()
+ {
+ return $.apiFetch("/api/mail/folders").then(function (folders) { state.folders = folders; });
+ }
+
+ function loadMessages(opts)
+ {
+ opts = opts || {};
+ var params = new URLSearchParams({
+ folder: state.currentFolder,
+ sort: state.sort,
+ desc: state.desc,
+ page: state.page,
+ refresh: !!opts.refresh
+ });
+ return $.apiFetch("/api/mail/messages?" + params.toString()).then(function (result)
+ {
+ state.messages = result.messages;
+ state.page = result.page;
+ state.pageSize = result.pageSize;
+ state.totalCount = result.totalCount;
+ state.totalPages = result.totalPages;
+ });
+ }
+
+ function loadMessage(uid)
+ {
+ var params = new URLSearchParams({ folder: state.currentFolder });
+ return $.apiFetch("/api/mail/messages/" + uid + "?" + params.toString()).then(function (detail)
+ {
+ state.selectedMessage = detail;
+ });
+ }
+
+ // ---------- rendering ----------
+
+ function renderFolderList()
+ {
+ document.getElementById("folderList").innerHTML = state.folders.map(function (f)
+ {
+ var active = f.fullName === state.currentFolder;
+ return (
+ '<li><a href="#" class="' + (active ? "active" : "") + '" data-folder="' + $.esc(f.fullName) + '">' +
+ "<span>" + $.esc(f.displayName) + "</span>" +
+ (f.unreadCount > 0 ? '<span class="badge">' + f.unreadCount + "</span>" : "") +
+ "</a></li>"
+ );
+ }).join("");
+ renderMoveToFolderOptions();
+ }
+
+ function renderMessageList()
+ {
+ var t = state.strings;
+ var html;
+ if (state.messages.length === 0)
+ {
+ html = '<div class="empty-state">' + $.esc(t.mail_empty) + "</div>";
+ } else
+ {
+ html =
+ '<ul class="message-list">' + state.messages.map(renderMessageRow).join("") + "</ul>" +
+ '<div class="pager">' +
+ '<a href="#" class="btn ' + (state.page <= 1 ? "disabled" : "") + '" data-page="' + (state.page - 1) + '">' + $.esc(t.mail_previous) + "</a>" +
+ '<span class="pager-info">' + $.esc($.formatTemplate(t.mail_pager_info, state.page, state.totalPages, state.totalCount)) + "</span>" +
+ '<a href="#" class="btn ' + (state.page >= state.totalPages ? "disabled" : "") + '" data-page="' + (state.page + 1) + '">' + $.esc(t.mail_next) + "</a>" +
+ "</div>";
+ }
+ document.getElementById("messageListBody").innerHTML = html;
+
+ // The rendered rows always start unchecked, but the select-all checkbox lives outside this
+ // re-rendered container (see its own listener below) and would otherwise keep showing
+ // "checked" across a page/folder/sort change even though none of the new rows are selected.
+ var selectAll = document.querySelector("[data-select-all]");
+ if (selectAll) selectAll.checked = false;
+ }
+
+ function renderMessageRow(m)
+ {
+ var unread = !m.isRead ? "unread" : "";
+ var selected = m.uid === state.selectedUid ? "selected" : "";
+ var flagTitle = m.isFlagged ? state.strings.action_unflag : state.strings.action_flag;
+ return (
+ '<li class="message-row ' + unread + " " + selected + '" data-uid="' + m.uid + '">' +
+ '<span class="flag-toggle' + (m.isFlagged ? " flagged" : "") + '" data-flag-toggle title="' + $.esc(flagTitle || "") + '">&#9873;</span>' +
+ '<input type="checkbox" />' +
+ '<div class="main"><div class="subject">' +
+ (m.priority === "High" ? '<span class="priority-high">!</span>' : "") +
+ $.esc(m.subject) + (m.hasAttachments ? "<span>&#128206;</span>" : "") +
+ "</div><div class=\"from\">" + $.esc(m.from) + "</div></div>" +
+ '<div class="meta"><div class="date">' + $.formatDate(m.date) + '</div><div class="size">' + m.sizeKb.toFixed(1) + " kB</div></div>" +
+ "</li>"
+ );
+ }
+
+ function renderDetailPane()
+ {
+ var t = state.strings;
+ var m = state.selectedMessage;
+ var pane = document.getElementById("detailPane");
+ var content = document.getElementById("mailContent");
+
+ if (!m)
+ {
+ pane.innerHTML = '<div class="empty-state">' + $.esc(t.detail_empty) + "</div>";
+ content.classList.remove("has-detail");
+ return;
+ }
+ content.classList.add("has-detail");
+
+ var sensitivityKey = { Confidential: "detail_sensitivity_confidential", Private: "detail_sensitivity_private", Personal: "detail_sensitivity_personal" }[m.sensitivity];
+ var sensitivityBanner = sensitivityKey ? '<div class="alert alert-info sensitivity-banner">' + $.esc(t[sensitivityKey] || m.sensitivity) + "</div>" : "";
+
+ var imagesBanner = "";
+ if (m.hasExternalImages && !m.imagesAllowed)
+ {
+ imagesBanner =
+ '<div class="alert alert-info image-block-banner">' +
+ $.esc(t.detail_images_blocked) + " " +
+ '<button type="button" class="btn-link" id="allowImagesBtn">' +
+ $.esc($.formatTemplate(t.detail_always_show_for, m.senderEmail)) +
+ "</button>" +
+ "</div>";
+ }
+
+ var attachmentsHtml = "";
+ if (m.attachments && m.attachments.length > 0)
+ {
+ attachmentsHtml =
+ '<div class="attachments">' +
+ m.attachments.map(function (att)
+ {
+ return (
+ '<div class="attachment-row">' +
+ "<span>&#128206; " + $.esc(att.fileName) + " (" + (Math.round(att.sizeBytes / 1024 * 10) / 10) + " kB)</span>" +
+ '<button type="button" class="btn" data-attachment="view" data-part="' + att.partIndex + '" data-filename="' + $.esc(att.fileName) + '">' + $.esc(t.detail_view) + "</button>" +
+ '<button type="button" class="btn" data-attachment="download" data-part="' + att.partIndex + '" data-filename="' + $.esc(att.fileName) + '">' + $.esc(t.detail_download) + "</button>" +
+ "</div>"
+ );
+ }).join("") +
+ "</div>";
+ }
+
+ var bodyHtml = (m.htmlBody && m.htmlBody.trim() !== "")
+ ? '<div class="body-html"><iframe srcdoc="' + $.esc(m.htmlBody) + '" sandbox=""></iframe></div>'
+ : '<div class="body-html"><pre style="white-space:pre-wrap;font-family:inherit;">' + $.esc(m.textBody) + "</pre></div>";
+
+ pane.innerHTML =
+ '<div class="message-detail">' +
+ '<a class="btn mobile-back" href="#" id="closeDetailBtn" style="margin-bottom:12px;">' + $.esc(t.detail_back_to_list) + "</a>" +
+ '<div class="detail-toolbar">' +
+ '<select id="replyActions">' +
+ '<option value="" selected>' + $.esc(t.detail_reply_menu) + "</option>" +
+ '<option value="reply">' + $.esc(t.action_reply) + "</option>" +
+ '<option value="replyAll">' + $.esc(t.action_reply_all) + "</option>" +
+ "</select>" +
+ '<a class="btn" href="#" id="forwardBtn">' + $.esc(t.detail_forward) + "</a>" +
+ "</div>" +
+ '<div class="subject-line">' +
+ (m.priority === "High" ? '<span class="priority-high" title="' + $.esc(t.detail_priority_high || "") + '">!</span>' : "") +
+ $.esc(m.subject) + "</div>" +
+ '<div class="headers">' +
+ "<div><b>" + $.esc(t.detail_from) + "</b> " + $.esc(m.from) + "</div>" +
+ "<div><b>" + $.esc(t.detail_to) + "</b> " + $.esc(m.to) + "</div>" +
+ (m.cc ? "<div><b>" + $.esc(t.detail_cc) + "</b> " + $.esc(m.cc) + "</div>" : "") +
+ "<div><b>" + $.esc(t.detail_date) + "</b> " + $.esc($.formatFullDate(m.date, state.lang)) + "</div>" +
+ "</div>" +
+ sensitivityBanner +
+ imagesBanner +
+ attachmentsHtml +
+ bodyHtml +
+ "</div>";
+ }
+
+ function updateSortLinksActive()
+ {
+ document.querySelectorAll(".sort-links a").forEach(function (a)
+ {
+ a.classList.toggle("active", a.getAttribute("data-sort") === state.sort);
+ });
+ }
+
+ function updateSelectedRowClasses()
+ {
+ document.querySelectorAll(".message-row").forEach(function (row)
+ {
+ var uid = parseInt(row.getAttribute("data-uid"), 10);
+ row.classList.toggle("selected", uid === state.selectedUid);
+ if (uid === state.selectedUid) row.classList.remove("unread");
+ });
+ }
+
+ // ---------- message selection (no-op if the same message is clicked again;
+ // exactly one detail render once the data has actually loaded, to avoid flicker) ----------
+
+ function openMessage(uid)
+ {
+ if (uid === state.selectedUid) return;
+
+ state.selectedUid = uid;
+ updateSelectedRowClasses();
+
+ Promise.all([loadMessage(uid), loadFolders()]).then(function ()
+ {
+ var item = state.messages.find(function (x) { return x.uid === uid; });
+ if (item) item.isRead = true;
+ renderDetailPane();
+ renderFolderList();
+ updateSelectedRowClasses();
+ });
+ }
+
+ function closeDetail()
+ {
+ if (state.selectedUid == null) return;
+ state.selectedUid = null;
+ state.selectedMessage = null;
+ renderDetailPane();
+ updateSelectedRowClasses();
+ }
+
+ // ---------- events ----------
+
+ document.getElementById("sidebarToggle").addEventListener("click", function ()
+ {
+ document.getElementById("sidebar").classList.toggle("open");
+ });
+
+ document.getElementById("refreshBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ loadMessages({ refresh: true }).then(renderMessageList);
+ });
+
+ function refreshAfterAction()
+ {
+ return Promise.all([loadFolders(), loadMessages({ refresh: true })]).then(function ()
+ {
+ renderFolderList();
+ renderMessageList();
+ });
+ }
+
+ function reportError(err)
+ {
+ if (err.cancelled) return;
+ window.alert(err.message);
+ }
+
+ document.getElementById("markActions").addEventListener("change", function (e)
+ {
+ var action = e.target.value;
+ e.target.value = "";
+ if (!action) return;
+
+ if (action === "markAllRead" || action === "markAllUnread")
+ {
+ $.apiFetch("/api/mail/" + (action === "markAllRead" ? "mark-all-read" : "mark-all-unread"), {
+ method: "POST", body: { folder: state.currentFolder }
+ }).then(refreshAfterAction).catch(reportError);
+ return;
+ }
+
+ var uids = getSelectedUids();
+ if (uids.length === 0) return;
+
+ var endpoints = { markRead: "mark-read", markUnread: "mark-unread", flag: "flag", unflag: "unflag" };
+ $.apiFetch("/api/mail/" + endpoints[action], {
+ method: "POST", body: { folder: state.currentFolder, uids: uids }
+ }).then(refreshAfterAction).catch(reportError);
+ });
+
+ document.getElementById("moveToFolder").addEventListener("change", function (e)
+ {
+ var target = e.target.value;
+ e.target.value = "";
+ if (!target) return;
+ var uids = getSelectedUids();
+ if (uids.length === 0) return;
+
+ $.apiFetch("/api/mail/move", {
+ method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: target }
+ }).then(function ()
+ {
+ state.selectedUid = null;
+ state.selectedMessage = null;
+ refreshAfterAction().then(renderDetailPane);
+ }).catch(reportError);
+ });
+
+ document.getElementById("deleteActions").addEventListener("change", function (e)
+ {
+ var action = e.target.value;
+ e.target.value = "";
+ if (!action) return;
+
+ if (action === "emptyTrash" || action === "emptySpam")
+ {
+ var folderName = action === "emptyTrash" ? "Trash" : "Spam";
+ var folder = state.folders.find(function (f)
+ {
+ return f.fullName.toLowerCase() === folderName.toLowerCase() || f.displayName.toLowerCase() === folderName.toLowerCase();
+ });
+ if (!folder) return;
+ $.apiFetch("/api/mail/empty-folder", { method: "POST", body: { folder: folder.fullName } }).then(refreshAfterAction).catch(reportError);
+ return;
+ }
+
+ var uids = getSelectedUids();
+ if (uids.length === 0) return;
+ $.apiFetch("/api/mail/delete", { method: "POST", body: { folder: state.currentFolder, uids: uids } }).then(function ()
+ {
+ state.selectedUid = null;
+ state.selectedMessage = null;
+ refreshAfterAction().then(renderDetailPane);
+ }).catch(reportError);
+ });
+
+ document.getElementById("spamBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ var uids = getSelectedUids();
+ if (uids.length === 0) return;
+ var spamFolder = state.folders.find(function (f)
+ {
+ return f.fullName.toLowerCase() === "spam" || f.displayName.toLowerCase() === "spam" || f.fullName.toLowerCase() === "junk" || f.displayName.toLowerCase() === "junk";
+ });
+ if (!spamFolder) return;
+ $.apiFetch("/api/mail/move", {
+ method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: spamFolder.fullName }
+ }).then(refreshAfterAction).catch(reportError);
+ });
+
+
+ document.querySelectorAll(".sort-links a").forEach(function (a)
+ {
+ a.addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ var key = a.getAttribute("data-sort");
+ state.desc = state.sort === key ? !state.desc : true;
+ state.sort = key;
+ updateSortLinksActive();
+ loadMessages().then(renderMessageList);
+ });
+ });
+
+ document.getElementById("folderList").addEventListener("click", function (e)
+ {
+ var a = e.target.closest("a[data-folder]");
+ if (!a) return;
+ e.preventDefault();
+ var folder = a.getAttribute("data-folder");
+ if (folder === state.currentFolder) return;
+
+ state.currentFolder = folder;
+ state.page = 1;
+ state.selectedUid = null;
+ state.selectedMessage = null;
+ document.getElementById("sidebar").classList.remove("open");
+
+ loadMessages().then(function ()
+ {
+ renderFolderList();
+ renderMessageList();
+ renderDetailPane();
+ });
+ });
+
+ document.getElementById("messageListBody").addEventListener("click", function (e)
+ {
+ var pagerLink = e.target.closest("a[data-page]");
+ if (pagerLink)
+ {
+ e.preventDefault();
+ var page = parseInt(pagerLink.getAttribute("data-page"), 10) || 1;
+ if (page < 1 || page > state.totalPages || page === state.page) return;
+ state.page = page;
+ loadMessages().then(renderMessageList);
+ return;
+ }
+
+ var flagToggle = e.target.closest("[data-flag-toggle]");
+ if (flagToggle)
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleFlag(parseInt(flagToggle.closest(".message-row").getAttribute("data-uid"), 10), flagToggle);
+ return;
+ }
+
+ var row = e.target.closest(".message-row");
+ if (row)
+ {
+ if (e.target.tagName === "INPUT") return;
+ openMessage(parseInt(row.getAttribute("data-uid"), 10));
+ }
+ });
+
+ // Toggles a single message's flag directly from the list, without opening it or requiring a
+ // selection first (unlike the bulk "Acties" flag/unflag entries) - optimistic UI update, rolled
+ // back on failure so the icon never lies about the request's outcome.
+ function toggleFlag(uid, el)
+ {
+ var item = state.messages.find(function (x) { return x.uid === uid; });
+ if (!item) return;
+
+ var newValue = !item.isFlagged;
+ item.isFlagged = newValue;
+ el.classList.toggle("flagged", newValue);
+
+ $.apiFetch("/api/mail/" + (newValue ? "flag" : "unflag"), {
+ method: "POST", body: { folder: state.currentFolder, uids: [uid] }
+ }).catch(function (err)
+ {
+ item.isFlagged = !newValue;
+ el.classList.toggle("flagged", !newValue);
+ if (!err.cancelled) window.alert(err.message);
+ });
+ }
+
+ // The checkbox itself lives in .list-toolbar (a static sibling of #messageListBody, not a
+ // descendant), so it needs its own listener rather than being caught by #messageListBody's
+ // delegated one - it never re-renders, so binding it once here directly is safe.
+ document.querySelector("[data-select-all]").addEventListener("change", function (e)
+ {
+ var checked = e.target.checked;
+ document.querySelectorAll(".message-row input[type=checkbox]").forEach(function (cb) { cb.checked = checked; });
+ });
+
+ function buildQuoteHtml(m)
+ {
+ var header = $.esc(m.from) + " - " + $.esc($.formatFullDate(m.date, state.lang)) + ":";
+ var body = (m.htmlBody && m.htmlBody.trim() !== "") ? m.htmlBody : "<pre>" + $.esc(m.textBody) + "</pre>";
+ return (
+ "<br/><br/>" + header +
+ '<blockquote style="margin:0 0 0 8px;padding-left:12px;border-left:2px solid #ccc;">' + body + "</blockquote>"
+ );
+ }
+
+ function goToCompose(mode)
+ {
+ var m = state.selectedMessage;
+ if (!m) return;
+ var prefill = { mode: mode, quoteHtml: buildQuoteHtml(m) };
+ if (mode === "reply" || mode === "replyAll")
+ {
+ prefill.to = m.senderEmail;
+ prefill.subject = /^re:/i.test(m.subject) ? m.subject : "Re: " + m.subject;
+ if (mode === "replyAll") prefill.cc = m.cc || "";
+ } else if (mode === "forward")
+ {
+ prefill.subject = /^fwd?:/i.test(m.subject) ? m.subject : "Fwd: " + m.subject;
+ }
+ sessionStorage.setItem("mailsharp_compose_prefill", JSON.stringify(prefill));
+ window.location.href = "/Mail/Compose";
+ }
+
+ document.getElementById("detailPane").addEventListener("change", function (e)
+ {
+ if (e.target.id === "replyActions")
+ {
+ var mode = e.target.value;
+ e.target.value = "";
+ if (mode) goToCompose(mode);
+ }
+ });
+
+ document.getElementById("detailPane").addEventListener("click", function (e)
+ {
+ if (e.target.id === "closeDetailBtn")
+ {
+ e.preventDefault();
+ closeDetail();
+ return;
+ }
+ if (e.target.id === "forwardBtn")
+ {
+ e.preventDefault();
+ goToCompose("forward");
+ return;
+ }
+ if (e.target.id === "allowImagesBtn")
+ {
+ $.apiFetch("/api/mail/allow-images", {
+ method: "POST",
+ body: { folder: state.currentFolder, uid: state.selectedUid, sender: state.selectedMessage.senderEmail }
+ }).then(function ()
+ {
+ return loadMessage(state.selectedUid);
+ }).then(renderDetailPane);
+ return;
+ }
+
+ var attachmentBtn = e.target.closest("[data-attachment]");
+ if (attachmentBtn)
+ {
+ openAttachment(attachmentBtn);
+ }
+ });
+
+ // Fetched as a blob via an authenticated fetch() call (never a plain navigation), so no
+ // attachment URL is ever visible in the address bar, a new tab, or browser history - the
+ // content is handed to the browser via a local, temporary blob: URL instead.
+ function openAttachment(btn)
+ {
+ var mode = btn.getAttribute("data-attachment");
+ var part = btn.getAttribute("data-part");
+ var fileName = btn.getAttribute("data-filename");
+ var url = "/api/mail/attachments/" + state.selectedUid + "/" + part + "?folder=" + encodeURIComponent(state.currentFolder);
+
+ fetch(url, { headers: { "X-Requested-With": "fetch" }, credentials: "same-origin" })
+ .then(function (response)
+ {
+ if (!response.ok) throw new Error("HTTP " + response.status);
+ return response.blob();
+ })
+ .then(function (blob)
+ {
+ var blobUrl = URL.createObjectURL(blob);
+ if (mode === "view")
+ {
+ window.open(blobUrl, "_blank");
+ }
+ else
+ {
+ var a = document.createElement("a");
+ a.href = blobUrl;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ }
+ setTimeout(function () { URL.revokeObjectURL(blobUrl); }, 60000);
+ })
+ .catch(reportError);
+ }
+
+ bootstrap();
+})();
diff --git a/MailSharp.MailClient/wwwroot/js/maintenance.js b/MailSharp.MailClient/wwwroot/js/maintenance.js
new file mode 100644
index 0000000..aa24c81
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/js/maintenance.js
@@ -0,0 +1,221 @@
+(function ()
+{
+ "use strict";
+ var $ = window.MailSharp;
+
+ var state = {
+ page: 1,
+ pageSize: 50,
+ totalCount: 0,
+ statusLoaded: false,
+ metricsLoaded: false,
+ logsLoaded: false
+ };
+
+ function reportError(err)
+ {
+ if (err.cancelled) return;
+ window.alert(err.message);
+ }
+
+ function formatSize(bytes)
+ {
+ if (!bytes) return "0 KB";
+ var kb = bytes / 1024;
+ return kb >= 1024 ? (kb / 1024).toFixed(1) + " MB" : kb.toFixed(1) + " KB";
+ }
+
+ function bootstrap()
+ {
+ $.initHeader().then(function ()
+ {
+ loadStatus();
+ }).catch(reportError);
+ }
+
+ function loadStatus()
+ {
+ state.statusLoaded = true;
+ $.apiFetch("/api/maintenance/status").then(function (s)
+ {
+ renderIndexingStatus(s.indexing);
+ renderIndexTotals(s.index);
+ renderCacheStats(s.cache);
+ }).catch(reportError);
+ }
+
+ function renderIndexingStatus(indexing)
+ {
+ var el = document.getElementById("maintenanceStatus");
+ var text = "Achtergrondindexering: " + (indexing.maxConcurrentIndexing - indexing.currentlyAvailableSlots) + " / " + indexing.maxConcurrentIndexing + " actief.";
+ if (indexing.inProgress.length > 0)
+ {
+ text += " Bezig met: " + indexing.inProgress.join(", ");
+ }
+ else
+ {
+ text += " Niets in behandeling.";
+ }
+ el.textContent = text;
+ }
+
+ function renderIndexTotals(index)
+ {
+ document.getElementById("indexTotals").textContent =
+ index.totalIndexedFolders + " geïndexeerde mappen, " + index.totalIndexedMessages + " berichten, " + formatSize(index.totalIndexedSizeBytes) + " totaal.";
+
+ var rows = index.accounts.map(function (a)
+ {
+ return "<tr><td>" + $.esc(a.emailAddress) + "</td><td>" + a.indexedFolderCount + "</td><td>" + a.indexedMessageCount + "</td><td>" + formatSize(a.indexedSizeBytes) + "</td></tr>";
+ });
+ document.getElementById("indexSummaryBody").innerHTML = rows.join("");
+ }
+
+ function renderCacheStats(cache)
+ {
+ var rows = [
+ ["Berichtenlijst-cache (TTL)", cache.messageListCacheEntries + " entries", ""],
+ ["Contactzoekopdracht-cache (TTL)", cache.contactMailSearchEntries + " entries", ""],
+ ["Afbeeldingsvoorkeuren per afzender", cache.senderImagePreferences + " entries", ""],
+ ["Afbeelding-URL mappings", cache.imageUrlMappings + " entries", ""],
+ ["Uid → Message-ID mappings", cache.messageIdMappings + " entries", ""],
+ ["Gedownloade berichten (.eml)", cache.cachedMessages.fileCount + " bestanden", formatSize(cache.cachedMessages.totalBytes)],
+ ["Gedownloade bijlagen", cache.cachedAttachments.fileCount + " bestanden", formatSize(cache.cachedAttachments.totalBytes)],
+ ["Gedownloade afbeeldingen", cache.cachedImages.fileCount + " bestanden", formatSize(cache.cachedImages.totalBytes)]
+ ];
+ document.getElementById("cacheStatsBody").innerHTML = rows.map(function (r)
+ {
+ return "<tr><td>" + $.esc(r[0]) + "</td><td>" + $.esc(r[1]) + "</td><td>" + $.esc(r[2]) + "</td></tr>";
+ }).join("");
+ }
+
+ function populateMetricsAccountFilter(accounts)
+ {
+ var sel = document.getElementById("metricsAccountFilter");
+ if (sel.options.length > 1) return; // already populated on an earlier load
+
+ accounts.forEach(function (a)
+ {
+ var opt = document.createElement("option");
+ opt.value = a.accountId;
+ opt.textContent = a.emailAddress;
+ sel.appendChild(opt);
+ });
+ }
+
+ function loadMetrics()
+ {
+ state.metricsLoaded = true;
+ var accountId = document.getElementById("metricsAccountFilter").value;
+ var url = "/api/maintenance/metrics" + (accountId ? "?accountId=" + encodeURIComponent(accountId) : "");
+
+ $.apiFetch(url).then(function (m)
+ {
+ if (!accountId) populateMetricsAccountFilter(m.accounts);
+
+ var rows = [];
+ m.accounts.forEach(function (a)
+ {
+ if (a.folders.length === 0)
+ {
+ rows.push("<tr><td>" + $.esc(a.emailAddress) + "</td><td colspan='3'>(nog niet geïndexeerd)</td></tr>");
+ return;
+ }
+ a.folders.forEach(function (f)
+ {
+ rows.push("<tr><td>" + $.esc(a.emailAddress) + "</td><td>" + $.esc(f.folder) + "</td><td>" + f.messageCount + "</td><td>" + formatSize(f.sizeBytes) + "</td></tr>");
+ });
+ });
+ document.getElementById("metricsBody").innerHTML = rows.join("");
+ }).catch(reportError);
+ }
+
+ document.getElementById("metricsAccountFilter").addEventListener("change", function ()
+ {
+ loadMetrics();
+ });
+
+ function buildLogsQuery()
+ {
+ var params = new URLSearchParams();
+ var level = document.getElementById("logLevelFilter").value;
+ var category = document.getElementById("logCategoryFilter").value.trim();
+ var accountId = document.getElementById("logAccountFilter").value.trim();
+ var search = document.getElementById("logSearchFilter").value.trim();
+
+ if (level) params.set("level", level);
+ if (category) params.set("category", category);
+ if (accountId) params.set("accountId", accountId);
+ if (search) params.set("search", search);
+ params.set("page", state.page);
+ params.set("pageSize", state.pageSize);
+ return params.toString();
+ }
+
+ function loadLogs()
+ {
+ state.logsLoaded = true;
+ $.apiFetch("/api/maintenance/logs?" + buildLogsQuery()).then(function (result)
+ {
+ state.totalCount = result.totalCount;
+ var rows = result.items.map(function (e)
+ {
+ return "<tr><td>" + $.esc($.formatDate(e.timestamp)) + "</td><td>" + $.esc(e.level) + "</td><td>" + $.esc(e.category) + "</td><td>" + (e.accountId != null ? e.accountId : "") + "</td><td>" + $.esc(e.message) + (e.exception ? " — " + $.esc(e.exception) : "") + "</td></tr>";
+ });
+ document.getElementById("logsBody").innerHTML = rows.join("");
+
+ var totalPages = Math.max(1, Math.ceil(state.totalCount / state.pageSize));
+ document.getElementById("logsPageInfo").textContent = "Pagina " + state.page + " van " + totalPages + " (" + state.totalCount + " logregels)";
+ }).catch(reportError);
+ }
+
+ document.getElementById("logFilterBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ state.page = 1;
+ loadLogs();
+ });
+
+ // The level select filters immediately on change, unlike the free-text/account inputs (which
+ // wait for the Filteren button so every keystroke doesn't fire a request) - a dropdown change
+ // is already a single deliberate action, so there's no "still typing" state to debounce.
+ document.getElementById("logLevelFilter").addEventListener("change", function ()
+ {
+ state.page = 1;
+ loadLogs();
+ });
+
+ document.getElementById("logsPrevBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ if (state.page > 1) { state.page--; loadLogs(); }
+ });
+
+ document.getElementById("logsNextBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ var totalPages = Math.max(1, Math.ceil(state.totalCount / state.pageSize));
+ if (state.page < totalPages) { state.page++; loadLogs(); }
+ });
+
+ // ---------- tabs ----------
+
+ document.getElementById("maintenanceTabs").addEventListener("click", function (e)
+ {
+ var tab = e.target.closest(".subheader-tab");
+ if (!tab) return;
+ e.preventDefault();
+
+ var name = tab.getAttribute("data-tab");
+ document.querySelectorAll(".subheader-tab").forEach(function (t) { t.classList.toggle("active", t === tab); });
+ document.querySelectorAll("[data-tab-panel]").forEach(function (p)
+ {
+ p.style.display = p.getAttribute("data-tab-panel") === name ? "" : "none";
+ });
+
+ if (name === "metrics" && !state.metricsLoaded) loadMetrics();
+ if (name === "logs" && !state.logsLoaded) loadLogs();
+ });
+
+ bootstrap();
+})();
diff --git a/MailSharp.MailClient/wwwroot/js/settings.js b/MailSharp.MailClient/wwwroot/js/settings.js
new file mode 100644
index 0000000..7c9ec5d
--- /dev/null
+++ b/MailSharp.MailClient/wwwroot/js/settings.js
@@ -0,0 +1,304 @@
+(function ()
+{
+ "use strict";
+ var $ = window.MailSharp;
+
+ var SYNC_MODES = ["NotSynchronized", "NewHeaders", "AllHeaders", "NewMessages", "AllMessages", "Direct"];
+
+ var state = {
+ strings: {},
+ folders: [],
+ foldersLoaded: false,
+ dragFullName: null
+ };
+
+ function reportError(err)
+ {
+ if (err.cancelled) return;
+ window.alert(err.message);
+ }
+
+ function syncModeLabel(mode)
+ {
+ var t = state.strings;
+ return {
+ NotSynchronized: t.sync_not_synchronized,
+ NewHeaders: t.sync_new_headers,
+ AllHeaders: t.sync_all_headers,
+ NewMessages: t.sync_new_messages,
+ AllMessages: t.sync_all_messages,
+ Direct: t.sync_direct
+ }[mode] || mode;
+ }
+
+ function formatSize(bytes, estimated)
+ {
+ if (!bytes) return "0 KB";
+ var kb = bytes / 1024;
+ var text = kb >= 1024 ? (kb / 1024).toFixed(1) + " MB" : kb.toFixed(1) + " KB";
+ return estimated ? "~" + text : text;
+ }
+
+ function bootstrap()
+ {
+ $.initHeader().then(function (result)
+ {
+ state.strings = result.langState.strings;
+ document.getElementById("settingsAccountInfo").textContent = result.authState.account.emailAddress;
+
+ return $.apiFetch("/api/settings");
+ }).then(renderSettingsForm).catch(reportError);
+ }
+
+ function renderSettingsForm(settingsState)
+ {
+ var s = settingsState.settings;
+ document.getElementById("settingsFriendlyName").value = s.friendlyName;
+ document.getElementById("settingsSignature").value = s.signature;
+ document.getElementById("settingsMessagesPerPage").value = s.messagesPerPage || "";
+ document.getElementById("settingsContactsPerPage").value = s.contactsPerPage || "";
+ document.getElementById("settingsAutoCheckInterval").value = s.autoCheckIntervalMinutes;
+
+ var tzSel = document.getElementById("settingsTimeZone");
+ tzSel.innerHTML = settingsState.timeZones.map(function (tz)
+ {
+ return '<option value="' + $.esc(tz) + '"' + (tz === s.timeZoneId ? " selected" : "") + ">" + $.esc(tz) + "</option>";
+ }).join("");
+
+ var langSel = document.getElementById("settingsLanguage");
+ langSel.innerHTML = settingsState.languages.map(function (l)
+ {
+ return '<option value="' + $.esc(l.code) + '"' + (l.code === s.language ? " selected" : "") + ">" + $.esc(l.name) + "</option>";
+ }).join("");
+ }
+
+ document.getElementById("saveSettingsBtn").addEventListener("click", function ()
+ {
+ var body = {
+ friendlyName: document.getElementById("settingsFriendlyName").value.trim(),
+ signature: document.getElementById("settingsSignature").value,
+ messagesPerPage: parseInt(document.getElementById("settingsMessagesPerPage").value, 10) || 0,
+ contactsPerPage: parseInt(document.getElementById("settingsContactsPerPage").value, 10) || 0,
+ autoCheckIntervalMinutes: parseInt(document.getElementById("settingsAutoCheckInterval").value, 10) || 0,
+ timeZoneId: document.getElementById("settingsTimeZone").value,
+ language: document.getElementById("settingsLanguage").value
+ };
+
+ $.apiFetch("/api/settings", { method: "POST", body: body }).then(function ()
+ {
+ window.alert(state.strings.settings_saved);
+ window.location.reload();
+ }).catch(reportError);
+ });
+
+ // ---------- tabs ----------
+
+ document.getElementById("settingsTabs").addEventListener("click", function (e)
+ {
+ var tab = e.target.closest(".subheader-tab");
+ if (!tab) return;
+ e.preventDefault();
+
+ var name = tab.getAttribute("data-tab");
+ document.querySelectorAll(".subheader-tab").forEach(function (t) { t.classList.toggle("active", t === tab); });
+ document.querySelectorAll("[data-tab-panel]").forEach(function (p)
+ {
+ p.style.display = p.getAttribute("data-tab-panel") === name ? "" : "none";
+ });
+
+ if (name === "folders" && !state.foldersLoaded) loadFolders().catch(reportError);
+ });
+
+ // ---------- folder management ----------
+
+ function loadFolders(opts)
+ {
+ var refresh = !!(opts && opts.refresh);
+ return $.apiFetch("/api/settings/folders?refresh=" + refresh).then(function (folders)
+ {
+ state.folders = folders;
+ state.foldersLoaded = true;
+ renderFolderTable();
+ });
+ }
+
+ function renderFolderTable()
+ {
+ var t = state.strings;
+ var body = document.getElementById("folderManageBody");
+
+ body.innerHTML = state.folders.map(function (f, index)
+ {
+ var syncOptions = SYNC_MODES.map(function (m)
+ {
+ return '<option value="' + m + '"' + (m === f.syncMode ? " selected" : "") + ">" + $.esc(syncModeLabel(m)) + "</option>";
+ }).join("");
+
+ return (
+ '<tr draggable="true" data-folder="' + $.esc(f.fullName) + '" data-protected="' + (f.isProtected ? "true" : "false") + '">' +
+ '<td><span class="move-handle">' +
+ '<button type="button" class="move-up" data-dir="-1"' + (index === 0 ? " disabled" : "") + ">▲</button>" +
+ '<button type="button" class="move-down" data-dir="1"' + (index === state.folders.length - 1 ? " disabled" : "") + ">▼</button>" +
+ "</span></td>" +
+ '<td><input type="checkbox" class="folder-row-select"' + (f.isProtected ? " disabled" : "") + " /></td>" +
+ '<td style="padding-left:' + (f.depth * 20) + 'px;">' + $.esc(f.displayName) + "</td>" +
+ '<td>' + f.messageCount + "</td>" +
+ '<td>' + formatSize(f.sizeBytes, f.isSizeEstimated) + "</td>" +
+ '<td><select class="folder-sync-mode">' + syncOptions + "</select></td>" +
+ '<td><input type="checkbox" class="folder-subscribed"' + (f.subscribed ? " checked" : "") + " /></td>" +
+ "</tr>"
+ );
+ }).join("");
+
+ var totalMessages = state.folders.reduce(function (sum, f) { return sum + f.messageCount; }, 0);
+ var totalSize = state.folders.reduce(function (sum, f) { return sum + f.sizeBytes; }, 0);
+ var anyEstimated = state.folders.some(function (f) { return f.isSizeEstimated; });
+ document.getElementById("folderManageFoot").innerHTML =
+ "<tr><td></td><td></td><td><b>" + $.esc(t.settings_total) + "</b></td><td><b>" + totalMessages + "</b></td>" +
+ "<td><b>" + formatSize(totalSize, anyEstimated) + "</b></td><td></td><td></td></tr>";
+ }
+
+ // Reads the table's *current on-screen row order* back out (rather than trusting
+ // state.folders, which is only touched on load/reorder) so save always persists exactly what
+ // the user sees, regardless of which of the two reorder mechanisms (arrows or drag) got there.
+ function currentRowOrder()
+ {
+ return [].slice.call(document.querySelectorAll("#folderManageBody tr[data-folder]")).map(function (row)
+ {
+ return row.getAttribute("data-folder");
+ });
+ }
+
+ function reorderState(newFullNameOrder)
+ {
+ var byName = {};
+ state.folders.forEach(function (f) { byName[f.fullName] = f; });
+ state.folders = newFullNameOrder.map(function (name) { return byName[name]; }).filter(Boolean);
+ renderFolderTable();
+ }
+
+ document.getElementById("folderSelectAll").addEventListener("change", function (e)
+ {
+ document.querySelectorAll(".folder-row-select:not(:disabled)").forEach(function (cb) { cb.checked = e.target.checked; });
+ });
+
+ document.getElementById("folderManageBody").addEventListener("click", function (e)
+ {
+ var btn = e.target.closest(".move-handle button");
+ if (!btn || btn.disabled) return;
+
+ var row = btn.closest("tr[data-folder]");
+ var order = currentRowOrder();
+ var index = order.indexOf(row.getAttribute("data-folder"));
+ var target = index + parseInt(btn.getAttribute("data-dir"), 10);
+ if (target < 0 || target >= order.length) return;
+
+ var tmp = order[index];
+ order[index] = order[target];
+ order[target] = tmp;
+ reorderState(order);
+ });
+
+ document.getElementById("folderManageBody").addEventListener("dragstart", function (e)
+ {
+ var row = e.target.closest("tr[data-folder]");
+ if (!row) return;
+ state.dragFullName = row.getAttribute("data-folder");
+ row.classList.add("dragging");
+ e.dataTransfer.effectAllowed = "move";
+ });
+
+ document.getElementById("folderManageBody").addEventListener("dragend", function (e)
+ {
+ var row = e.target.closest("tr[data-folder]");
+ if (row) row.classList.remove("dragging");
+ document.querySelectorAll("#folderManageBody tr.drag-over").forEach(function (r) { r.classList.remove("drag-over"); });
+ state.dragFullName = null;
+ });
+
+ document.getElementById("folderManageBody").addEventListener("dragover", function (e)
+ {
+ var row = e.target.closest("tr[data-folder]");
+ if (!row || !state.dragFullName) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ document.querySelectorAll("#folderManageBody tr.drag-over").forEach(function (r) { if (r !== row) r.classList.remove("drag-over"); });
+ row.classList.add("drag-over");
+ });
+
+ document.getElementById("folderManageBody").addEventListener("drop", function (e)
+ {
+ var row = e.target.closest("tr[data-folder]");
+ if (!row || !state.dragFullName) return;
+ e.preventDefault();
+ row.classList.remove("drag-over");
+
+ var draggedName = state.dragFullName;
+ var targetName = row.getAttribute("data-folder");
+ if (draggedName === targetName) return;
+
+ var order = currentRowOrder().filter(function (name) { return name !== draggedName; });
+ var targetIndex = order.indexOf(targetName);
+ order.splice(targetIndex, 0, draggedName);
+ reorderState(order);
+ });
+
+ document.getElementById("saveFoldersBtn").addEventListener("click", function ()
+ {
+ var folders = [];
+ document.querySelectorAll("#folderManageBody tr[data-folder]").forEach(function (row)
+ {
+ folders.push({
+ fullName: row.getAttribute("data-folder"),
+ syncMode: row.querySelector(".folder-sync-mode").value,
+ subscribed: row.querySelector(".folder-subscribed").checked
+ });
+ });
+
+ $.apiFetch("/api/settings/folders", { method: "POST", body: { folders: folders } }).then(function ()
+ {
+ window.alert(state.strings.settings_saved);
+ }).catch(reportError);
+ });
+
+ document.getElementById("addFolderBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ var name = window.prompt(state.strings.settings_new_folder_prompt);
+ if (!name) return;
+
+ $.apiFetch("/api/settings/folders/add", { method: "POST", body: { name: name } })
+ .then(loadFolders)
+ .catch(reportError);
+ });
+
+ document.getElementById("deleteFoldersBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ var names = [];
+ document.querySelectorAll("#folderManageBody tr[data-folder]").forEach(function (row)
+ {
+ if (row.querySelector(".folder-row-select").checked) names.push(row.getAttribute("data-folder"));
+ });
+ if (names.length === 0) return;
+
+ $.apiFetch("/api/settings/folders/delete", { method: "POST", body: { folderNames: names } })
+ .then(function (result)
+ {
+ if (result.failed && result.failed.length > 0)
+ {
+ window.alert(state.strings.settings_delete_non_empty_warning + "\n" + result.failed.join(", "));
+ }
+ return loadFolders();
+ })
+ .catch(reportError);
+ });
+
+ document.getElementById("reloadFoldersBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ loadFolders({ refresh: true }).catch(reportError);
+ });
+
+ bootstrap();
+})();
diff --git a/MailSharp.POP3/Extensions/Pop3ServiceExtensions.cs b/MailSharp.POP3/Extensions/Pop3ServiceExtensions.cs
new file mode 100644
index 0000000..45376f0
--- /dev/null
+++ b/MailSharp.POP3/Extensions/Pop3ServiceExtensions.cs
@@ -0,0 +1,16 @@
+using MailSharp.POP3.Metrics;
+using MailSharp.POP3.Services;
+
+namespace MailSharp.POP3.Extensions;
+
+public static class Pop3ServiceExtensions
+{
+ // Adds POP3 services to the specified IServiceCollection
+ public static IServiceCollection AddPop3Services(this IServiceCollection services)
+ {
+ services.AddSingleton<Pop3Metrics>();
+ services.AddSingleton<Pop3Service>();
+ services.AddHostedService(provider => provider.GetRequiredService<Pop3Service>());
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.POP3/MailSharp.POP3.csproj b/MailSharp.POP3/MailSharp.POP3.csproj
new file mode 100644
index 0000000..0cd7069
--- /dev/null
+++ b/MailSharp.POP3/MailSharp.POP3.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <OutputType>Library</OutputType>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\MailSharp.Common\MailSharp.Common.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/MailSharp.POP3/Metrics/Pop3Metrics.cs b/MailSharp.POP3/Metrics/Pop3Metrics.cs
new file mode 100644
index 0000000..2643307
--- /dev/null
+++ b/MailSharp.POP3/Metrics/Pop3Metrics.cs
@@ -0,0 +1,49 @@
+using System.Collections.Concurrent;
+
+namespace MailSharp.POP3.Metrics;
+
+public sealed class Pop3Metrics
+{
+ private long totalConnections;
+ private long activeSessions;
+ private long loginSuccess;
+ private long loginFailed;
+ private long messagesRetrieved;
+ private long messagesDeleted;
+ private long bytesSent;
+
+ public long TotalConnections => totalConnections;
+ public long ActiveSessions => activeSessions;
+ public long LoginSuccess => loginSuccess;
+ public long LoginFailed => loginFailed;
+ public long MessagesRetrieved => messagesRetrieved;
+ public long MessagesDeleted => messagesDeleted;
+ public long BytesSent => bytesSent;
+ public long UptimeSeconds => (long)(DateTime.UtcNow - StartTime).TotalSeconds;
+ public bool IsRunning { get; set; } = false;
+ public DateTime StartTime { get; } = DateTime.UtcNow;
+ public DateTime? LastActivityAt { get; private set; }
+
+ private readonly ConcurrentDictionary<string, long> activeUsers = new(StringComparer.OrdinalIgnoreCase);
+ public IReadOnlyDictionary<string, long> TopUsers => activeUsers;
+
+ public void IncrementConnections() => Interlocked.Increment(ref totalConnections);
+ public void IncrementActive() => Interlocked.Increment(ref activeSessions);
+ public void DecrementActive() => Interlocked.Decrement(ref activeSessions);
+ public void IncrementLoginFailed() => Interlocked.Increment(ref loginFailed);
+ public void IncrementDeleted() => Interlocked.Increment(ref messagesDeleted);
+
+ public void LoginSucceeded(string username)
+ {
+ Interlocked.Increment(ref loginSuccess);
+ LastActivityAt = DateTime.UtcNow;
+ activeUsers.AddOrUpdate(username, 1, (_, c) => c + 1);
+ }
+
+ public void MessageRetrieved(long bytes)
+ {
+ Interlocked.Increment(ref messagesRetrieved);
+ Interlocked.Add(ref bytesSent, bytes);
+ LastActivityAt = DateTime.UtcNow;
+ }
+}
diff --git a/MailSharp.POP3/Properties/launchSettings.json b/MailSharp.POP3/Properties/launchSettings.json
new file mode 100644
index 0000000..44e172a
--- /dev/null
+++ b/MailSharp.POP3/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "MailSharp.POP3": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:10236;http://localhost:10237"
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.POP3/Server/Pop3Server.cs b/MailSharp.POP3/Server/Pop3Server.cs
new file mode 100644
index 0000000..d63b3ba
--- /dev/null
+++ b/MailSharp.POP3/Server/Pop3Server.cs
@@ -0,0 +1,139 @@
+using MailSharp.Common;
+using MailSharp.Common.Services;
+using MailSharp.POP3.Metrics;
+using MailSharp.POP3.Session;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MailSharp.POP3.Server;
+
+public class Pop3Server
+{
+ private readonly IConfiguration configuration;
+ private readonly ILogger<Pop3Server> logger;
+ private readonly ILogger<Pop3Session> sessionLogger;
+ private readonly AuthenticationService authService;
+ private readonly MailboxService mailboxService;
+ private readonly List<ServerContext> listeners = [];
+ private readonly Pop3Metrics pop3Metrics;
+ private CancellationTokenSource? cts;
+
+ public Pop3Server(
+ IConfiguration configuration,
+ ILogger<Pop3Server> logger,
+ ILogger<Pop3Session> sessionLogger,
+ AuthenticationService authService,
+ MailboxService mailboxService,
+ Pop3Metrics pop3Metrics)
+ {
+ this.configuration = configuration;
+ this.logger = logger;
+ this.sessionLogger = sessionLogger;
+ this.authService = authService;
+ this.mailboxService = mailboxService;
+ this.pop3Metrics = pop3Metrics;
+
+ var ports = configuration.GetSection("Pop3Settings:Ports").Get<List<PortConfig>>()
+ ?? throw new InvalidOperationException("Ports not configured");
+
+ foreach (var port in ports)
+ {
+ listeners.Add(new(new TcpListener(IPAddress.Parse(port.Host), port.Port), port.Security));
+ }
+ }
+
+ public async Task StartAsync()
+ {
+ cts = new CancellationTokenSource();
+ var active = new List<ServerContext>();
+ foreach (var context in listeners)
+ {
+ try
+ {
+ context.Listener.Start();
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ServerStarted").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ServerStarted");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["Pop3LogMessages:ServerStarted"],
+ context.Listener.LocalEndpoint, context.Security);
+ active.Add(context);
+ }
+ catch (SocketException ex)
+ {
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ServerStartFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ServerStartFailed");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["Pop3LogMessages:ServerStartFailed"],
+ ((IPEndPoint)context.Listener.LocalEndpoint).Port,
+ ex.Message);
+ }
+ }
+ if (active.Count > 0)
+ await Task.WhenAll(active.Select(context =>
+ Task.Run(() => AcceptClientsAsync(context, cts.Token), cts.Token)));
+ await StopAsync();
+ }
+
+ private async Task AcceptClientsAsync(ServerContext context, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var client = await context.Listener.AcceptTcpClientAsync(cancellationToken);
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ClientAccepted").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ClientAccepted");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["Pop3LogMessages:ClientAccepted"],
+ client.Client.RemoteEndPoint);
+
+ var session = new Pop3Session(client, configuration, context.Security, authService, mailboxService, pop3Metrics, sessionLogger);
+ _ = session.ProcessAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ListenerStopped").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ListenerStopped");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["Pop3LogMessages:ListenerStopped"],
+ context.Listener.LocalEndpoint);
+ break;
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ClientAcceptError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ClientAcceptError");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["Pop3LogMessages:ClientAcceptError"],
+ context.Listener.LocalEndpoint);
+ }
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ if (cts != null)
+ {
+ await cts.CancelAsync();
+ }
+ foreach (var context in listeners)
+ {
+ context.Listener.Stop();
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ListenerStopped").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ListenerStopped");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["Pop3LogMessages:ListenerStopped"],
+ context.Listener.LocalEndpoint);
+ context.Listener.Dispose();
+ }
+ listeners.Clear();
+ }
+}
diff --git a/MailSharp.POP3/Services/Pop3Service.cs b/MailSharp.POP3/Services/Pop3Service.cs
new file mode 100644
index 0000000..39d04f9
--- /dev/null
+++ b/MailSharp.POP3/Services/Pop3Service.cs
@@ -0,0 +1,69 @@
+using MailSharp.Common;
+using MailSharp.Common.Services;
+using MailSharp.POP3.Metrics;
+using MailSharp.POP3.Server;
+using MailSharp.POP3.Session;
+
+namespace MailSharp.POP3.Services;
+
+public class Pop3Service : BackgroundService
+{
+ private readonly Pop3Server server;
+ private readonly IConfiguration configuration;
+ private readonly Pop3Metrics metrics;
+ private readonly ILogger<Pop3Service> logger;
+
+ public Pop3Service(
+ IConfiguration configuration,
+ ILogger<Pop3Service> logger,
+ ILogger<Pop3Server> serverLogger,
+ ILogger<Pop3Session> sessionLogger,
+ AuthenticationService authService,
+ MailboxService mailboxService,
+ Pop3Metrics metrics)
+ {
+ this.configuration = configuration;
+ this.metrics = metrics;
+ this.logger = logger;
+ this.server = new Pop3Server(configuration, serverLogger, sessionLogger, authService, mailboxService, metrics);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ metrics.IsRunning = true;
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ServiceStarting").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ServiceStarting");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["Pop3LogMessages:ServiceStarting"]);
+
+ await server.StartAsync();
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ServiceError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ServiceError");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["Pop3LogMessages:ServiceError"]);
+ metrics.IsRunning = false;
+ throw;
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:ServiceStopping").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:ServiceStopping");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["Pop3LogMessages:ServiceStopping"]);
+ metrics.IsRunning = false;
+ await server.StopAsync();
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/MailSharp.POP3/Session/Pop3Session.cs b/MailSharp.POP3/Session/Pop3Session.cs
new file mode 100644
index 0000000..d0fcb59
--- /dev/null
+++ b/MailSharp.POP3/Session/Pop3Session.cs
@@ -0,0 +1,196 @@
+using MailSharp.Common;
+using MailSharp.Common.Services;
+using MailSharp.POP3.Metrics;
+using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Net.Security;
+using System.Text;
+
+namespace MailSharp.POP3.Session;
+
+public class Pop3Session(
+ TcpClient client,
+ IConfiguration configuration,
+ SecurityEnum security,
+ AuthenticationService authService,
+ MailboxService mailboxService,
+ Pop3Metrics metrics,
+ ILogger<Pop3Session> logger)
+{
+ private Stream? stream;
+
+ public async Task ProcessAsync(CancellationToken cancellationToken)
+ {
+ metrics.IncrementConnections();
+ metrics.IncrementActive();
+ try
+ {
+ stream = security == SecurityEnum.Tls ? await InitializeTlsStreamAsync() : client.GetStream();
+ await SendResponseAsync("+OK POP3 server ready", cancellationToken);
+ bool isAuthenticated = false;
+ string? username = null;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ string? command = await ReadCommandAsync(cancellationToken);
+ if (string.IsNullOrEmpty(command))
+ {
+ break;
+ }
+ var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 0)
+ {
+ await SendResponseAsync("-ERR Invalid command", cancellationToken);
+ continue;
+ }
+ switch (parts[0].ToUpper())
+ {
+ case "USER":
+ if (parts.Length != 2)
+ {
+ await SendResponseAsync("-ERR USER requires username", cancellationToken);
+ continue;
+ }
+ username = parts[1];
+ await SendResponseAsync("+OK Username accepted", cancellationToken);
+ break;
+ case "PASS":
+ if (parts.Length != 2 || username == null)
+ {
+ await SendResponseAsync("-ERR PASS requires prior USER command", cancellationToken);
+ continue;
+ }
+ isAuthenticated = await authService.AuthenticateAsync(username, parts[1], cancellationToken);
+ if (isAuthenticated)
+ metrics.LoginSucceeded(username);
+ else
+ metrics.IncrementLoginFailed();
+ await SendResponseAsync(
+ isAuthenticated ? "+OK Authentication successful" : "-ERR Authentication failed",
+ cancellationToken);
+ break;
+ case "LIST":
+ if (!isAuthenticated)
+ {
+ await SendResponseAsync("-ERR Authentication required", cancellationToken);
+ continue;
+ }
+ var messages = await mailboxService.GetMessagesAsync(username!, "INBOX", cancellationToken);
+ await SendResponseAsync($"+OK {messages.Count} messages", cancellationToken);
+ for (int i = 0; i < messages.Count; i++)
+ {
+ string? listContent = await mailboxService.GetMessageContentAsync(username!, "INBOX", messages[i].Uid, cancellationToken);
+ long size = listContent?.Length ?? 0;
+ await SendResponseAsync($"{i + 1} {size}", cancellationToken);
+ }
+ await SendResponseAsync(".", cancellationToken);
+ break;
+ case "RETR":
+ if (!isAuthenticated)
+ {
+ await SendResponseAsync("-ERR Authentication required", cancellationToken);
+ continue;
+ }
+ if (parts.Length != 2 || !int.TryParse(parts[1], out int msgNum) || msgNum < 1)
+ {
+ await SendResponseAsync("-ERR RETR requires valid message number", cancellationToken);
+ continue;
+ }
+ var retrMessages = await mailboxService.GetMessagesAsync(username!, "INBOX", cancellationToken);
+ if (msgNum > retrMessages.Count)
+ {
+ await SendResponseAsync("-ERR No such message", cancellationToken);
+ continue;
+ }
+ string? content = await mailboxService.GetMessageContentAsync(username!, "INBOX", retrMessages[msgNum - 1].Uid, cancellationToken);
+ if (content == null)
+ {
+ await SendResponseAsync("-ERR Message not found", cancellationToken);
+ continue;
+ }
+ await SendResponseAsync($"+OK {content.Length} octets", cancellationToken);
+ await SendResponseAsync(content, cancellationToken);
+ await SendResponseAsync(".", cancellationToken);
+ metrics.MessageRetrieved(content.Length);
+ break;
+ case "QUIT":
+ await SendResponseAsync("+OK POP3 server signing off", cancellationToken);
+ return;
+ default:
+ await SendResponseAsync("-ERR Unknown command", cancellationToken);
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:SessionError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:SessionError");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["Pop3LogMessages:SessionError"],
+ client.Client.RemoteEndPoint);
+ }
+ finally
+ {
+ metrics.DecrementActive();
+ stream?.Dispose();
+ client.Close();
+ }
+ }
+
+ // Initialize TLS stream for POP3 session
+ private async Task<Stream> InitializeTlsStreamAsync()
+ {
+ try
+ {
+ string certPath = configuration["Pop3Settings:CertificatePath"] ?? throw new InvalidOperationException("CertificatePath not configured");
+ string certPassword = configuration["Pop3Settings:CertificatePassword"] ?? string.Empty;
+ X509Certificate2 certificate = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
+ SslStream sslStream = new(client.GetStream(), false);
+ await sslStream.AuthenticateAsServerAsync(certificate, false, System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13, false);
+
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:TlsInitialized").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:TlsInitialized");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["Pop3LogMessages:TlsInitialized"],
+ client.Client.RemoteEndPoint);
+
+ return sslStream;
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("Pop3EventIds:TlsInitializationFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing Pop3EventIds:TlsInitializationFailed");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["Pop3LogMessages:TlsInitializationFailed"],
+ client.Client.RemoteEndPoint);
+ throw;
+ }
+ }
+
+ private async Task<string?> ReadCommandAsync(CancellationToken cancellationToken)
+ {
+ if (stream == null)
+ {
+ return null;
+ }
+ byte[] buffer = new byte[1024];
+ int bytesRead = await stream.ReadAsync(buffer, cancellationToken);
+ return Encoding.ASCII.GetString(buffer, 0, bytesRead).Trim();
+ }
+
+ private async Task SendResponseAsync(string response, CancellationToken cancellationToken)
+ {
+ if (stream == null)
+ {
+ return;
+ }
+ byte[] data = Encoding.ASCII.GetBytes(response + "\r\n");
+ await stream.WriteAsync(data, cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+ }
+}
diff --git a/MailSharp.SMTP/Extensions/SmtpServiceExtensions.cs b/MailSharp.SMTP/Extensions/SmtpServiceExtensions.cs
new file mode 100644
index 0000000..3f1211b
--- /dev/null
+++ b/MailSharp.SMTP/Extensions/SmtpServiceExtensions.cs
@@ -0,0 +1,23 @@
+using MailSharp.DNS;
+using MailSharp.SMTP.Metrics;
+using MailSharp.SMTP.Services;
+
+namespace MailSharp.SMTP.Extensions;
+
+public static class SmtpServiceExtensions
+{
+ // Adds SMTP services to the specified IServiceCollection
+ public static IServiceCollection AddSmtpServices(this IServiceCollection services)
+ {
+ services.AddSingleton<Resolver>();
+ services.AddSingleton<SmtpMetrics>();
+ services.AddSingleton<SmtpService>();
+ services.AddHostedService(provider => provider.GetRequiredService<SmtpService>());
+ services.AddSingleton<DkimSigner>();
+ services.AddSingleton<SpfChecker>();
+ services.AddSingleton<DkimVerifier>();
+ services.AddSingleton<DmarcChecker>();
+ services.AddHostedService<RelayService>();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.SMTP/Extensions/StreamWriterExtensions.cs b/MailSharp.SMTP/Extensions/StreamWriterExtensions.cs
new file mode 100644
index 0000000..bc5087d
--- /dev/null
+++ b/MailSharp.SMTP/Extensions/StreamWriterExtensions.cs
@@ -0,0 +1,9 @@
+namespace MailSharp.SMTP.Extensions;
+
+public static class StreamWriterExtensions
+{
+ // Did someone forgot this extension method in the BCL?
+ public static Task WriteLineAsync(this StreamWriter writer, string? text, CancellationToken cancellationToken) =>
+ writer.WriteLineAsync(text?.AsMemory() ?? ReadOnlyMemory<char>.Empty, cancellationToken);
+
+}
diff --git a/MailSharp.SMTP/MailSharp.SMTP.csproj b/MailSharp.SMTP/MailSharp.SMTP.csproj
new file mode 100644
index 0000000..ccaf38b
--- /dev/null
+++ b/MailSharp.SMTP/MailSharp.SMTP.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <OutputType>Library</OutputType>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\MailSharp.Common\MailSharp.Common.csproj" />
+ <ProjectReference Include="..\MailSharp.DNS\MailSharp.DNS.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/MailSharp.SMTP/Metrics/SmtpMetrics.cs b/MailSharp.SMTP/Metrics/SmtpMetrics.cs
new file mode 100644
index 0000000..7e5a016
--- /dev/null
+++ b/MailSharp.SMTP/Metrics/SmtpMetrics.cs
@@ -0,0 +1,60 @@
+using System.Collections.Concurrent;
+
+namespace MailSharp.SMTP.Metrics;
+
+public sealed class SmtpMetrics
+{
+ private long totalConnections;
+ private long activeSessions;
+ private long messagesReceived;
+ private long messagesRelayed;
+ private long messagesRejectedSpf;
+ private long messagesRejectedDkim;
+ private long messagesRejectedDmarc;
+ private long authSuccess;
+ private long authFailed;
+ private long bytesReceived;
+
+ public long TotalConnections => totalConnections;
+ public long ActiveSessions => activeSessions;
+ public long MessagesReceived => messagesReceived;
+ public long MessagesRelayed => messagesRelayed;
+ public long MessagesRejectedSpf => messagesRejectedSpf;
+ public long MessagesRejectedDkim => messagesRejectedDkim;
+ public long MessagesRejectedDmarc => messagesRejectedDmarc;
+ public long MessagesRejectedTotal => messagesRejectedSpf + messagesRejectedDkim + messagesRejectedDmarc;
+ public long AuthSuccess => authSuccess;
+ public long AuthFailed => authFailed;
+ public long BytesReceived => bytesReceived;
+ public long UptimeSeconds => (long)(DateTime.UtcNow - StartTime).TotalSeconds;
+ public bool IsRunning { get; set; } = false;
+ public DateTime StartTime { get; } = DateTime.UtcNow;
+ public DateTime? LastMessageReceivedAt { get; private set; }
+
+ private readonly ConcurrentDictionary<string, long> senderDomains = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary<string, long> recipientDomains = new(StringComparer.OrdinalIgnoreCase);
+
+ public IReadOnlyDictionary<string, long> TopSenderDomains => senderDomains;
+ public IReadOnlyDictionary<string, long> TopRecipientDomains => recipientDomains;
+
+ public void IncrementConnections() => Interlocked.Increment(ref totalConnections);
+ public void IncrementActive() => Interlocked.Increment(ref activeSessions);
+ public void DecrementActive() => Interlocked.Decrement(ref activeSessions);
+ public void IncrementRelayed() => Interlocked.Increment(ref messagesRelayed);
+ public void IncrementRejectedSpf() => Interlocked.Increment(ref messagesRejectedSpf);
+ public void IncrementRejectedDkim() => Interlocked.Increment(ref messagesRejectedDkim);
+ public void IncrementRejectedDmarc() => Interlocked.Increment(ref messagesRejectedDmarc);
+ public void IncrementAuthSuccess() => Interlocked.Increment(ref authSuccess);
+ public void IncrementAuthFailed() => Interlocked.Increment(ref authFailed);
+
+ public void MessageReceived(string senderDomain, IEnumerable<string> recipientDomains, long bytes)
+ {
+ Interlocked.Increment(ref messagesReceived);
+ Interlocked.Add(ref bytesReceived, bytes);
+ LastMessageReceivedAt = DateTime.UtcNow;
+
+ senderDomains.AddOrUpdate(senderDomain, 1, (_, c) => c + 1);
+ foreach (var domain in recipientDomains)
+ this.recipientDomains.AddOrUpdate(domain, 1, (_, c) => c + 1);
+ }
+}
diff --git a/MailSharp.SMTP/Properties/launchSettings.json b/MailSharp.SMTP/Properties/launchSettings.json
new file mode 100644
index 0000000..b8b2e0f
--- /dev/null
+++ b/MailSharp.SMTP/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "MailSharp.SmtpServer": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:5603;http://localhost:5604"
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.SMTP/Server/SmtpServer.cs b/MailSharp.SMTP/Server/SmtpServer.cs
new file mode 100644
index 0000000..fb9e678
--- /dev/null
+++ b/MailSharp.SMTP/Server/SmtpServer.cs
@@ -0,0 +1,141 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Metrics;
+using MailSharp.SMTP.Services;
+using MailSharp.SMTP.Session;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MailSharp.SMTP.Server;
+
+public class SmtpServer
+{
+ private readonly IConfiguration configuration;
+ private readonly ILogger<SmtpServer> logger;
+ private readonly ILogger<SmtpSession> sessionLogger;
+ private readonly DkimSigner dkimSigner;
+ private readonly SpfChecker spfChecker;
+ private readonly DkimVerifier dkimVerifier;
+ private readonly DmarcChecker dmarcChecker;
+ private readonly List<ServerContext> listeners = [];
+ private readonly SmtpMetrics metrics;
+ private CancellationTokenSource? cts;
+
+ public SmtpServer(
+ IConfiguration configuration,
+ ILogger<SmtpServer> logger,
+ ILogger<SmtpSession> sessionLogger,
+ DkimSigner dkimSigner,
+ SpfChecker spfChecker,
+ DkimVerifier dkimVerifier,
+ DmarcChecker dmarcChecker,
+ SmtpMetrics metrics)
+ {
+ this.configuration = configuration;
+ this.logger = logger;
+ this.sessionLogger = sessionLogger;
+ this.dkimSigner = dkimSigner;
+ this.spfChecker = spfChecker;
+ this.dkimVerifier = dkimVerifier;
+ this.dmarcChecker = dmarcChecker;
+ this.metrics = metrics;
+ var ports = configuration.GetSection("SmtpSettings:Ports").Get<List<PortConfig>>()
+ ?? throw new InvalidOperationException("Ports not configured");
+ foreach (var port in ports)
+ {
+ listeners.Add(new(new TcpListener(IPAddress.Parse(port.Host), port.Port), port.Security));
+ }
+ }
+
+ public async Task StartAsync()
+ {
+ cts = new CancellationTokenSource();
+ var active = new List<ServerContext>();
+ foreach (var context in listeners)
+ {
+ try
+ {
+ context.Listener.Start();
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ServerStarted").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ServerStarted");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["SmtpLogMessages:ServerStarted"],
+ context.Listener.LocalEndpoint, context.Security);
+ active.Add(context);
+ }
+ catch (SocketException ex)
+ {
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ServerStartFailed").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ServerStartFailed");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["SmtpLogMessages:ServerStartFailed"],
+ ((IPEndPoint)context.Listener.LocalEndpoint).Port);
+ }
+ }
+ if (active.Count > 0)
+ await Task.WhenAll(active.Select(context =>
+ Task.Run(() => AcceptClientsAsync(context, cts.Token), cts.Token)));
+ await StopAsync();
+ }
+
+ private async Task AcceptClientsAsync(ServerContext context, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var client = await context.Listener.AcceptTcpClientAsync(cancellationToken);
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ClientAccepted").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ClientAccepted");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["SmtpLogMessages:ClientAccepted"],
+ client.Client.RemoteEndPoint);
+ var session = new SmtpSession(client, configuration, context.Security, dkimSigner, spfChecker, dkimVerifier, dmarcChecker, metrics, sessionLogger);
+ _ = session.ProcessAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ListenerStopped").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ListenerStopped");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["SmtpLogMessages:ListenerStopped"],
+ context.Listener.LocalEndpoint);
+ break;
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ClientAcceptError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ClientAcceptError");
+ logger.LogError(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ ex,
+ configuration["SmtpLogMessages:ClientAcceptError"],
+ context.Listener.LocalEndpoint);
+ }
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ if (cts != null)
+ {
+ await cts.CancelAsync();
+ }
+ foreach (var context in listeners)
+ {
+ context.Listener.Stop();
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ListenerStopped").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ListenerStopped");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["SmtpLogMessages:ListenerStopped"],
+ context.Listener.LocalEndpoint);
+ context.Listener.Dispose();
+ }
+ listeners.Clear();
+ }
+}
diff --git a/MailSharp.SMTP/Services/DkimSigner.cs b/MailSharp.SMTP/Services/DkimSigner.cs
new file mode 100644
index 0000000..75683a7
--- /dev/null
+++ b/MailSharp.SMTP/Services/DkimSigner.cs
@@ -0,0 +1,76 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace MailSharp.SMTP.Services;
+
+public class DkimSigner(IConfiguration configuration)
+{
+ private static readonly string[] separator = ["\r\n", "\n"];
+
+ // Sign email content with DKIM
+ public string SignEmail(string emlContent, string selector, string domain)
+ {
+ string privateKey = configuration[$"SmtpSettings:Dkim:{domain}:PrivateKey"]
+ ?? throw new InvalidOperationException($"DKIM private key for {domain} not configured");
+
+ // Parse email headers and body
+ int headerEnd = emlContent.IndexOf("\r\n\r\n", StringComparison.Ordinal);
+ if (headerEnd == -1)
+ {
+ throw new InvalidOperationException("Invalid email format: no header-body separator");
+ }
+
+ string headers = emlContent[..headerEnd];
+ string body = emlContent[(headerEnd + 4)..];
+
+ // Calculate body hash (BHASH)
+ string canonicalBody = CanonicalizeBody(body);
+ byte[] bodyBytes = Encoding.ASCII.GetBytes(canonicalBody);
+ byte[] bodyHash = SHA256.HashData(bodyBytes);
+ string bodyHashBase64 = Convert.ToBase64String(bodyHash);
+
+ // Prepare DKIM-Signature header
+ string dkimHeader = GenerateDkimHeader(selector, domain, bodyHashBase64, headers);
+ byte[] headerBytes = Encoding.ASCII.GetBytes(dkimHeader);
+
+ // Sign the DKIM-Signature header
+ using RSA rsa = RSA.Create();
+ rsa.ImportFromPem(privateKey);
+ byte[] signature = rsa.SignData(headerBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ string signatureBase64 = Convert.ToBase64String(signature);
+
+ // Append signature to DKIM-Signature header
+ dkimHeader += $"; s={signatureBase64}";
+
+ // Insert DKIM-Signature header at the start of headers
+ return $"{dkimHeader}\r\n{emlContent}";
+ }
+
+ // Canonicalize body (simple canonicalization per RFC 6376)
+ private static string CanonicalizeBody(string body)
+ {
+ // Remove trailing empty lines and normalize line endings
+ body = body.TrimEnd();
+ body = string.Join("\r\n", body.Split(separator, StringSplitOptions.None));
+ return body + "\r\n";
+ }
+
+ // Generate DKIM-Signature header
+ private static string GenerateDkimHeader(string selector, string domain, string bodyHash, string headers)
+ {
+ string[] headerLines = headers.Split("\r\n");
+ var signedHeaders = new[] { "from", "to", "subject", "date", "message-id" };
+ var headersToSign = headerLines
+ .Where(h => signedHeaders.Any(sh => h.StartsWith(sh + ":", StringComparison.OrdinalIgnoreCase)))
+ .Select(h => h.ToLower())
+ .Reverse()
+ .ToList();
+
+ string hValue = string.Join(":", signedHeaders);
+ string canonicalHeaders = string.Join("\r\n", headersToSign);
+
+ return $"DKIM-Signature: v=1; a=rsa-sha256; d={domain}; s={selector}; " +
+ $"c=simple/simple; q=dns/txt; t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}; " +
+ $"bh={bodyHash}; h={hValue}; b=";
+ }
+}
diff --git a/MailSharp.SMTP/Services/DkimVerifier.cs b/MailSharp.SMTP/Services/DkimVerifier.cs
new file mode 100644
index 0000000..33b428a
--- /dev/null
+++ b/MailSharp.SMTP/Services/DkimVerifier.cs
@@ -0,0 +1,137 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace MailSharp.SMTP.Services;
+
+public class DkimVerifier(IConfiguration configuration)
+{
+ private static readonly string[] separator = ["\r\n", "\n"];
+
+ // Verify DKIM signature of incoming email
+ public async Task<bool> VerifyDkimAsync(string emlContent, string clientIp)
+ {
+ // Split headers and body
+ int headerEnd = emlContent.IndexOf("\r\n\r\n", StringComparison.Ordinal);
+ if (headerEnd == -1)
+ {
+ return false; // Invalid email format
+ }
+
+ string headers = emlContent[..headerEnd];
+ string body = emlContent[(headerEnd + 4)..];
+
+ // Extract DKIM-Signature header
+ string[] headerLines = headers.Split("\r\n");
+ string? dkimHeader = headerLines.FirstOrDefault(h => h.StartsWith("DKIM-Signature:", StringComparison.OrdinalIgnoreCase));
+ if (dkimHeader == null)
+ {
+ return false; // No DKIM signature
+ }
+
+ // Parse DKIM-Signature fields
+ var dkimFields = ParseDkimHeader(dkimHeader);
+ if (!dkimFields.TryGetValue("d", out string? domain) ||
+ !dkimFields.TryGetValue("s", out string? selector) ||
+ !dkimFields.TryGetValue("b", out string? signatureBase64) ||
+ !dkimFields.TryGetValue("bh", out string? bodyHashBase64) ||
+ !dkimFields.TryGetValue("h", out string? signedHeaders))
+ {
+ return false; // Invalid DKIM header
+ }
+
+ // Verify body hash
+ string canonicalBody = CanonicalizeBody(body);
+ byte[] bodyBytes = Encoding.ASCII.GetBytes(canonicalBody);
+ byte[] computedBodyHash = SHA256.HashData(bodyBytes);
+ string computedBodyHashBase64 = Convert.ToBase64String(computedBodyHash);
+ if (computedBodyHashBase64 != bodyHashBase64)
+ {
+ return false; // Body hash mismatch
+ }
+
+ // Fetch public key from DNS
+ string? publicKey = await FetchDkimPublicKeyAsync(selector, domain);
+ if (publicKey == null)
+ {
+ return false; // Public key not found
+ }
+
+ // Prepare headers for verification
+ string[] signedHeaderNames = [.. signedHeaders.Split(':').Select(h => h.Trim().ToLower())];
+ var headersToVerify = headerLines
+ .Where(h => signedHeaderNames.Any(sh => h.StartsWith(sh + ":", StringComparison.OrdinalIgnoreCase)))
+ .Reverse()
+ .ToList();
+ headersToVerify.Add(dkimHeader[..dkimHeader.IndexOf("; b=")]); // Include DKIM-Signature without signature
+ string canonicalHeaders = string.Join("\r\n", headersToVerify);
+
+ // Verify signature
+ try
+ {
+ using RSA rsa = RSA.Create();
+ rsa.ImportFromPem(publicKey);
+ byte[] signature = Convert.FromBase64String(signatureBase64);
+ byte[] headerBytes = Encoding.ASCII.GetBytes(canonicalHeaders);
+ return rsa.VerifyData(headerBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ }
+ catch
+ {
+ return false; // Signature verification failed
+ }
+ }
+
+ // Canonicalize body (simple canonicalization per RFC 6376)
+ private static string CanonicalizeBody(string body)
+ {
+ body = body.TrimEnd();
+ body = string.Join("\r\n", body.Split(separator, StringSplitOptions.None));
+ return body + "\r\n";
+ }
+
+ // Parse DKIM-Signature header into key-value pairs
+ private static Dictionary<string, string> ParseDkimHeader(string dkimHeader)
+ {
+ var fields = new Dictionary<string, string>();
+ string[] parts = dkimHeader["DKIM-Signature:".Length..].Split(';');
+ foreach (string part in parts)
+ {
+ string trimmed = part.Trim();
+ if (string.IsNullOrEmpty(trimmed))
+ continue;
+ int equalsIndex = trimmed.IndexOf('=');
+ if (equalsIndex == -1)
+ continue;
+ string key = trimmed[..equalsIndex].Trim();
+ string value = trimmed[(equalsIndex + 1)..].Trim();
+ fields[key] = value;
+ }
+ return fields;
+ }
+
+ // Fetch DKIM public key from DNS TXT record
+ private static async Task<string?> FetchDkimPublicKeyAsync(string selector, string domain)
+ {
+ try
+ {
+ string query = $"{selector}._domainkey.{domain}";
+ var result = await Dns.GetHostEntryAsync(query);
+ string[] txtRecords = [.. result.Aliases
+ .Select(a => Dns.GetHostEntry(a).HostName)
+ .Where(h => h.Contains("p="))];
+
+ string? txtRecord = txtRecords.FirstOrDefault();
+ if (txtRecord == null)
+ return null;
+
+ int keyStart = txtRecord.IndexOf("p=") + 2;
+ string keyBase64 = txtRecord[keyStart..];
+ byte[] keyBytes = Convert.FromBase64String(keyBase64);
+ return Encoding.ASCII.GetString(keyBytes);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/MailSharp.SMTP/Services/DmarcChecker.cs b/MailSharp.SMTP/Services/DmarcChecker.cs
new file mode 100644
index 0000000..8c7fd4d
--- /dev/null
+++ b/MailSharp.SMTP/Services/DmarcChecker.cs
@@ -0,0 +1,160 @@
+using MailSharp.Common;
+using MailSharp.DNS;
+using MailSharp.SMTP.Metrics;
+using System.Net;
+
+namespace MailSharp.SMTP.Services;
+
+public class DmarcChecker(
+ IConfiguration configuration,
+ Resolver resolver,
+ DkimVerifier dkimVerifier,
+ SpfChecker spfChecker,
+ SmtpMetrics metrics,
+ ILogger<DmarcChecker> logger)
+{
+
+ // Check DMARC policy for incoming email
+ public async Task<bool> CheckDmarcAsync(string emlContent, string clientIp, string mailFromDomain, string heloDomain, CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("DmarcEventIds:DmarcCheck").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing DmarcEventIds:DmarcCheck");
+ logger.LogInformation(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["DmarcLogMessages:DmarcCheck"],
+ mailFromDomain);
+
+ // Fetch DMARC policy from DNS
+ string? dmarcRecord = await ResolveDmarcRecordAsync(mailFromDomain, cancellationToken);
+ if (string.IsNullOrEmpty(dmarcRecord))
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["DmarcLogMessages:NoDmarcRecord"],
+ mailFromDomain);
+ return configuration.GetValue<bool>("DmarcSettings:FailOpen");
+ }
+
+ // Parse DMARC record
+ var dmarcFields = ParseDmarcRecord(dmarcRecord);
+ if (!dmarcFields.TryGetValue("v", out string? version) || version != "DMARC1")
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["DmarcLogMessages:InvalidDmarcRecord"],
+ mailFromDomain);
+ return configuration.GetValue<bool>("DmarcSettings:FailOpen");
+ }
+
+ if (!dmarcFields.TryGetValue("p", out string? policy))
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["DmarcLogMessages:MissingPolicy"],
+ mailFromDomain);
+ return configuration.GetValue<bool>("DmarcSettings:FailOpen");
+ }
+
+ // Check SPF and DKIM alignment
+ bool spfPass = await spfChecker.CheckSpfAsync(clientIp, mailFromDomain, heloDomain);
+
+ if(spfPass == false)
+ metrics.IncrementRejectedSpf();
+
+ bool dkimPass = await dkimVerifier.VerifyDkimAsync(emlContent, clientIp);
+
+ if(dkimPass == false)
+ metrics.IncrementRejectedDkim();
+
+ bool spfAligned = spfPass && IsSpfAligned(mailFromDomain, heloDomain);
+ bool dkimAligned = dkimPass && IsDkimAligned(emlContent, mailFromDomain);
+
+ bool dmarcPass = (spfAligned && dmarcFields.GetValueOrDefault("aspf", "r") == "r") ||
+ (dkimAligned && dmarcFields.GetValueOrDefault("adkim", "r") == "r");
+
+ if(dmarcPass == false)
+ metrics.IncrementRejectedDmarc();
+
+ // Apply DMARC policy
+ switch (policy.ToLower())
+ {
+ case "none":
+ return true; // Always pass for monitoring
+ case "quarantine":
+ case "reject":
+ if (!dmarcPass)
+ {
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["DmarcLogMessages:PolicyFailed"],
+ policy, mailFromDomain);
+ return false;
+ }
+ return true;
+ default:
+ logger.LogWarning(
+ new EventId(eventIdConfig.Id, eventIdConfig.Name),
+ configuration["DmarcLogMessages:UnknownPolicy"],
+ policy, mailFromDomain);
+ return configuration.GetValue<bool>("DmarcSettings:FailOpen");
+ }
+ }
+
+ // Resolve DMARC TXT record via DNS
+ private async Task<string?> ResolveDmarcRecordAsync(string domain, CancellationToken cancellationToken)
+ {
+ try
+ {
+ string host = configuration["DmarcSettings:DnsServer"] ?? "8.8.8.8";
+ int port = configuration.GetValue<int?>("DmarcSettings:DnsPort") ?? 53;
+ var dnsServer = new IPEndPoint(IPAddress.Parse(host), port);
+ var response = await resolver.QueryAsync(dnsServer, $"_dmarc.{domain}", DnsQType.TXT, DnsQClass.IN, cancellationToken);
+ return response.RecordsTXT
+ .SelectMany(r => r.Texts)
+ .FirstOrDefault(t => t.StartsWith("v=DMARC1", StringComparison.OrdinalIgnoreCase));
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ // Parse DMARC record into key-value pairs
+ private static Dictionary<string, string> ParseDmarcRecord(string dmarcRecord)
+ {
+ var fields = new Dictionary<string, string>();
+ string[] parts = dmarcRecord.Split(';', StringSplitOptions.RemoveEmptyEntries);
+ foreach (string part in parts)
+ {
+ string trimmed = part.Trim();
+ if (string.IsNullOrEmpty(trimmed))
+ continue;
+ int equalsIndex = trimmed.IndexOf('=');
+ if (equalsIndex == -1)
+ continue;
+ string key = trimmed[..equalsIndex].Trim();
+ string value = trimmed[(equalsIndex + 1)..].Trim();
+ fields[key] = value;
+ }
+ return fields;
+ }
+
+ // Check SPF alignment (relaxed alignment)
+ private static bool IsSpfAligned(string mailFromDomain, string heloDomain)
+ {
+ return mailFromDomain.Equals(heloDomain, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Check DKIM alignment (relaxed alignment)
+ private static bool IsDkimAligned(string emlContent, string mailFromDomain)
+ {
+ string[] headers = emlContent.Split(new[] { "\r\n" }, StringSplitOptions.None);
+ string? dkimHeader = headers.FirstOrDefault(h => h.StartsWith("DKIM-Signature:", StringComparison.OrdinalIgnoreCase));
+ if (dkimHeader == null)
+ return false;
+
+ var dkimFields = ParseDmarcRecord(dkimHeader["DKIM-Signature:".Length..]);
+ return dkimFields.TryGetValue("d", out string? dkimDomain) &&
+ dkimDomain.Equals(mailFromDomain, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/MailSharp.SMTP/Services/RelayService.cs b/MailSharp.SMTP/Services/RelayService.cs
new file mode 100644
index 0000000..69f4025
--- /dev/null
+++ b/MailSharp.SMTP/Services/RelayService.cs
@@ -0,0 +1,128 @@
+using MailSharp.SMTP.Metrics;
+using System.Net;
+using System.Net.Mail;
+using System.Net.Sockets;
+
+namespace MailSharp.SMTP.Services;
+
+public class RelayService(IConfiguration configuration, SmtpMetrics metrics, ILogger<RelayService> logger) : BackgroundService
+{
+ private readonly Queue<string> queue = new();
+
+ public void Enqueue(string emlPath)
+ {
+ queue.Enqueue(emlPath);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ string relayQueuePath = configuration["SmtpSettings:RelayQueuePath"] ?? throw new InvalidOperationException("RelayQueuePath not configured");
+
+ if (Directory.Exists(relayQueuePath))
+ {
+ foreach (string file in Directory.GetFiles(relayQueuePath, "*.eml"))
+ {
+ queue.Enqueue(file);
+ }
+ }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ if (queue.TryDequeue(out string? emlPath) && File.Exists(emlPath))
+ {
+ await ProcessEmailAsync(emlPath, stoppingToken);
+ }
+ try
+ {
+ await Task.Delay(1000, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+
+ private async Task ProcessEmailAsync(string emlPath, CancellationToken ct)
+ {
+ try
+ {
+ // Use CancellationToken.None so an in-progress relay completes cleanly on shutdown
+ string emlContent = await File.ReadAllTextAsync(emlPath, CancellationToken.None);
+ string recipient = ExtractRecipient(emlContent);
+ string domain = recipient[(recipient.IndexOf('@') + 1)..];
+
+ // MX record lookup using System.Net.Dns
+ IPHostEntry? dnsEntry = null;
+ try
+ {
+ dnsEntry = await Dns.GetHostEntryAsync(domain, ct);
+ }
+ catch (SocketException)
+ {
+ logger.LogWarning("No MX records found for domain {Domain}", domain);
+ return;
+ }
+
+ // Extract MX records from aliases (simplified, not ideal)
+ string[] mxRecords = [.. dnsEntry.Aliases
+ .Where(a => a.Contains("mail exchanger", StringComparison.OrdinalIgnoreCase))
+ .Select(a => a.Split(' ').Last())];
+
+ if (mxRecords.Length == 0)
+ {
+ logger.LogWarning("No valid MX records found for domain {Domain}", domain);
+ return;
+ }
+
+ foreach (string mxServer in mxRecords)
+ {
+ try
+ {
+ using var client = new SmtpClient(mxServer)
+ {
+ EnableSsl = configuration.GetValue<bool>("SmtpSettings:RelayUseTls"),
+ Timeout = configuration.GetValue<int>("SmtpSettings:RelayTimeoutSeconds") * 1000
+ };
+
+ if (configuration.GetValue<bool>("SmtpSettings:RelayRequiresAuth"))
+ {
+ client.Credentials = new NetworkCredential(
+ configuration["SmtpSettings:RelayUsername"],
+ configuration["SmtpSettings:RelayPassword"]);
+ }
+
+ await client.SendMailAsync(new MailMessage
+ {
+ Body = emlContent,
+ To = { recipient }
+ }, CancellationToken.None);
+
+ metrics.IncrementRelayed();
+
+ File.Delete(emlPath);
+ logger.LogInformation("Relayed email to {Domain} via {MxServer}", domain, mxServer);
+ return;
+ }
+ catch (SmtpException ex)
+ {
+ logger.LogWarning("Failed to relay to {MxServer}: {Error}", mxServer, ex.Message);
+ continue;
+ }
+ }
+
+ logger.LogError("Failed to relay email to {Domain}: No reachable MX servers", domain);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError("Error processing email {Path}: {Error}", emlPath, ex.Message);
+ }
+ }
+
+ private static string ExtractRecipient(string emlContent)
+ {
+ string[] lines = emlContent.Split("\r\n");
+ string? toLine = lines.FirstOrDefault(l => l.StartsWith("To:", StringComparison.OrdinalIgnoreCase));
+ return toLine?[(toLine.IndexOf(':') + 1)..].Trim() ?? throw new InvalidOperationException("No recipient found");
+ }
+}
diff --git a/MailSharp.SMTP/Services/SmtpService.cs b/MailSharp.SMTP/Services/SmtpService.cs
new file mode 100644
index 0000000..58cde07
--- /dev/null
+++ b/MailSharp.SMTP/Services/SmtpService.cs
@@ -0,0 +1,58 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Metrics;
+using MailSharp.SMTP.Server;
+using MailSharp.SMTP.Session;
+
+namespace MailSharp.SMTP.Services;
+
+public class SmtpService(IConfiguration configuration,
+ ILogger<SmtpService> logger,
+ ILogger<SmtpServer> serverLogger,
+ ILogger<SmtpSession> sessionLogger,
+ DkimSigner dkimSigner,
+ SpfChecker spfChecker,
+ DkimVerifier dkimVerifier,
+ DmarcChecker dmarcChecker,
+ SmtpMetrics metrics) : BackgroundService
+{
+ private readonly SmtpServer server = new (
+ configuration,
+ serverLogger,
+ sessionLogger,
+ dkimSigner,
+ spfChecker,
+ dkimVerifier,
+ dmarcChecker,
+ metrics);
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ metrics.IsRunning = true;
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ServiceStarting").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ServiceStarting");
+ logger.LogInformation(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:ServiceStarting"]);
+ await server.StartAsync();
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ServiceError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ServiceError");
+ logger.LogError(new EventId(eventIdConfig.Id, eventIdConfig.Name), ex, configuration["SmtpLogMessages:ServiceError"]);
+ metrics.IsRunning = false;
+ throw;
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:ServiceStopping").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ServiceStopping");
+ logger.LogInformation(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:ServiceStopping"]);
+ metrics.IsRunning = false;
+ await server.StopAsync();
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/MailSharp.SMTP/Services/SpfChecker.cs b/MailSharp.SMTP/Services/SpfChecker.cs
new file mode 100644
index 0000000..4c0beb2
--- /dev/null
+++ b/MailSharp.SMTP/Services/SpfChecker.cs
@@ -0,0 +1,115 @@
+using MailSharp.DNS;
+using System.Net;
+
+namespace MailSharp.SMTP.Services;
+
+public class SpfChecker(IConfiguration configuration, Resolver resolver)
+{
+ public async Task<bool> CheckSpfAsync(string clientIp, string mailFromDomain, string heloDomain, CancellationToken cancellationToken = default)
+ {
+ string spfRecord = await ResolveSpfRecordAsync(mailFromDomain, cancellationToken)
+ ?? await ResolveSpfRecordAsync(heloDomain, cancellationToken)
+ ?? string.Empty;
+
+ if (string.IsNullOrEmpty(spfRecord))
+ return false;
+
+ return await EvaluateSpfRecordAsync(spfRecord, clientIp, mailFromDomain, cancellationToken);
+ }
+
+ private async Task<string?> ResolveSpfRecordAsync(string domain, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var dnsServer = GetDnsServer();
+ var response = await resolver.QueryAsync(dnsServer, domain, DnsQType.TXT, DnsQClass.IN, cancellationToken);
+ return response.RecordsTXT
+ .SelectMany(r => r.Texts)
+ .FirstOrDefault(t => t.StartsWith("v=spf1", StringComparison.OrdinalIgnoreCase));
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private async Task<bool> EvaluateSpfRecordAsync(string spfRecord, string clientIp, string mailFromDomain, CancellationToken cancellationToken)
+ {
+ if (!spfRecord.StartsWith("v=spf1", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ string[] mechanisms = spfRecord.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (string mechanism in mechanisms.Skip(1))
+ {
+ if (mechanism.StartsWith("ip4:"))
+ {
+ if (IsIpInRange(clientIp, mechanism[4..]))
+ return true;
+ }
+ else if (mechanism.StartsWith("include:"))
+ {
+ string includeDomain = mechanism[8..];
+ string? includeSpf = await ResolveSpfRecordAsync(includeDomain, cancellationToken);
+ if (includeSpf != null && await EvaluateSpfRecordAsync(includeSpf, clientIp, mailFromDomain, cancellationToken))
+ return true;
+ }
+ else if (mechanism == "-all")
+ {
+ return false;
+ }
+ else if (mechanism == "+all")
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsIpInRange(string clientIp, string ipRange)
+ {
+ try
+ {
+ if (ipRange.Contains('/'))
+ {
+ var parts = ipRange.Split('/');
+ var networkAddress = IPAddress.Parse(parts[0]);
+ var cidr = int.Parse(parts[1]);
+ var clientAddress = IPAddress.Parse(clientIp);
+
+ byte[] networkBytes = networkAddress.GetAddressBytes();
+ byte[] clientBytes = clientAddress.GetAddressBytes();
+ int maskBytes = cidr / 8;
+ int maskRemainder = cidr % 8;
+
+ for (int i = 0; i < maskBytes; i++)
+ {
+ if (networkBytes[i] != clientBytes[i])
+ return false;
+ }
+
+ if (maskRemainder > 0)
+ {
+ byte mask = (byte)(0xFF << (8 - maskRemainder));
+ return (networkBytes[maskBytes] & mask) == (clientBytes[maskBytes] & mask);
+ }
+
+ return true;
+ }
+
+ return clientIp == ipRange;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private IPEndPoint GetDnsServer()
+ {
+ string host = configuration["SpfSettings:DnsServer"] ?? "8.8.8.8";
+ int port = configuration.GetValue<int?>("SpfSettings:DnsPort") ?? 53;
+ return new IPEndPoint(IPAddress.Parse(host), port);
+ }
+}
diff --git a/MailSharp.SMTP/Session/AUTH.cs b/MailSharp.SMTP/Session/AUTH.cs
new file mode 100644
index 0000000..e2ee85b
--- /dev/null
+++ b/MailSharp.SMTP/Session/AUTH.cs
@@ -0,0 +1,192 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Extensions;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle AUTH command
+ private async Task HandleAuthAsync(string[] parts, string line, CancellationToken ct)
+ {
+ bool tlsActive = security == SecurityEnum.Tls || state == SmtpState.TlsStarted;
+ if (ipGroup == null || (ipGroup.Access.RequireSslTlsForAuth && !tlsActive))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:CommandNotRecognized"], ct);
+ return;
+ }
+
+ if (state != SmtpState.HeloReceived && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+
+ if (security == SecurityEnum.StartTls && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:TlsRequired"], ct);
+ return;
+ }
+
+ if (parts.Length < 2)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:SyntaxError"], ct);
+ return;
+ }
+
+ string mechanism = parts[1].ToUpper();
+ if (mechanism == "PLAIN")
+ {
+ // Handle AUTH PLAIN
+ string? credentials = parts.Length > 2 ? parts[2] : await reader.ReadLineAsync(ct);
+ if (credentials == null)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+
+ try
+ {
+ string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(credentials));
+ string[] credentialParts = decoded.Split('\0');
+ if (credentialParts.Length != 3 || !await ValidateCredentialsAsync(credentialParts[1], credentialParts[2]))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthSuccess"], ct);
+ metrics.IncrementAuthSuccess();
+ }
+ catch
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ }
+ }
+ else if (mechanism == "CRAM-MD5")
+ {
+ // Handle AUTH CRAM-MD5
+ string challenge = $"<{Guid.NewGuid()}.{DateTime.UtcNow.Ticks}@{configuration["SmtpSettings:Host"]}>";
+ await writer.WriteLineAsync(string.Format(configuration["SmtpResponses:CramMd5Challenge"]!, Convert.ToBase64String(Encoding.UTF8.GetBytes(challenge))), ct);
+
+ string? response = await reader.ReadLineAsync(ct);
+ if (response == null)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+
+ try
+ {
+ string decodedResponse = Encoding.UTF8.GetString(Convert.FromBase64String(response));
+ string[] responseParts = decodedResponse.Split(' ');
+ if (responseParts.Length != 2)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+
+ string username = responseParts[0];
+ string clientDigest = responseParts[1];
+ string? password = configuration[$"SmtpSettings:Credentials:{username}"];
+ if (password == null || !ValidateCramMd5(challenge, password, clientDigest))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthSuccess"], ct);
+ metrics.IncrementAuthSuccess();
+ }
+ catch
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ }
+ }
+ else if (mechanism == "LOGIN")
+ {
+ // Handle AUTH LOGIN
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthLoginUsernamePrompt"], ct);
+ string? usernameBase64 = await reader.ReadLineAsync(ct);
+ if (usernameBase64 == null)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthLoginPasswordPrompt"], ct);
+ string? passwordBase64 = await reader.ReadLineAsync(ct);
+ if (passwordBase64 == null)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+
+ try
+ {
+ string username = Encoding.UTF8.GetString(Convert.FromBase64String(usernameBase64));
+ string password = Encoding.UTF8.GetString(Convert.FromBase64String(passwordBase64));
+ if (!await ValidateCredentialsAsync(username, password))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ return;
+ }
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthSuccess"], ct);
+ metrics.IncrementAuthSuccess();
+ }
+ catch
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthFailed"], ct);
+ metrics.IncrementAuthFailed();
+ }
+ }
+ else
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:SyntaxError"], ct);
+ }
+ }
+
+ // Validate PLAIN and LOGIN credentials
+ private async Task<bool> ValidateCredentialsAsync(string username, string password)
+ {
+ try
+ {
+ string userStorePath = configuration["SmtpSettings:UserStorePath"] ?? throw new InvalidOperationException("UserStorePath not configured");
+ if (!File.Exists(userStorePath))
+ {
+ logger.LogWarning("User store file {0} not found", userStorePath);
+ return false;
+ }
+
+ string json = await File.ReadAllTextAsync(userStorePath);
+ var users = JsonSerializer.Deserialize<List<UserConfig>>(json) ?? throw new InvalidOperationException("Invalid user store format");
+ var user = users.FirstOrDefault(u => u.Username == username && u.Password == password);
+
+ return user != null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError("Error validating credentials for user {0}: {1}", username, ex.Message);
+ return false;
+ }
+ }
+
+ // Validate CRAM-MD5 response
+ private static bool ValidateCramMd5(string challenge, string password, string clientDigest)
+ {
+ using HMACMD5 hmac = new(Encoding.UTF8.GetBytes(password));
+ byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(challenge));
+ string expectedDigest = BitConverter.ToString(hash).Replace("-", "").ToLower();
+ return clientDigest.Equals(expectedDigest, StringComparison.OrdinalIgnoreCase);
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.SMTP/Session/DATA.cs b/MailSharp.SMTP/Session/DATA.cs
new file mode 100644
index 0000000..9a125fd
--- /dev/null
+++ b/MailSharp.SMTP/Session/DATA.cs
@@ -0,0 +1,27 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Extensions;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle DATA command
+ private async Task HandleDataAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.RcptToReceived)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"] ,ct);
+ return;
+ }
+
+ if (security == SecurityEnum.StartTls && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:TlsRequired"], ct);
+ return;
+ }
+
+ state = SmtpState.HeaderStarted;
+ await writer.WriteLineAsync(configuration["SmtpResponses:HeaderStart"], ct);
+ }
+
+}
diff --git a/MailSharp.SMTP/Session/DataEnd.cs b/MailSharp.SMTP/Session/DataEnd.cs
new file mode 100644
index 0000000..47fc145
--- /dev/null
+++ b/MailSharp.SMTP/Session/DataEnd.cs
@@ -0,0 +1,121 @@
+using MailSharp.SMTP.Extensions;
+using System.Net;
+using System.Text;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // In HandleDataEndAsync, add DKIM verification
+ private async Task HandleDataEndAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.DataStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+
+ state = SmtpState.HeloReceived;
+
+ // Gather connection info
+ var remoteEndPoint = client.Client.RemoteEndPoint as IPEndPoint;
+ string clientIp = remoteEndPoint?.Address.ToString() ?? "Unknown";
+ int clientPort = remoteEndPoint?.Port ?? 0;
+ string ptrRecord = "Unknown";
+ try
+ {
+ var ptrResult = await Dns.GetHostEntryAsync(remoteEndPoint!.Address);
+ ptrRecord = ptrResult.HostName;
+ }
+ catch
+ {
+ // PTR lookup failed, keep default
+ }
+
+ // Build .eml content
+ StringBuilder emlContent = new();
+ emlContent.AppendLine($"Received: from {clientIp} ({ptrRecord}) by {configuration["SmtpSettings:Host"]} with SMTP; {DateTime.UtcNow:ddd, dd MMM yyyy HH:mm:ss} +0000");
+ emlContent.AppendLine($"From: {mailFrom}");
+ emlContent.AppendLine($"To: {string.Join(", ", rcptTo)}");
+ emlContent.AppendLine($"Date: {DateTime.UtcNow:ddd, dd MMM yyyy HH:mm:ss} +0000");
+ emlContent.AppendLine($"Message-ID: <{Guid.NewGuid()}@{configuration["SmtpSettings:Host"]}>");
+ emlContent.Append(data);
+
+ // Verify DKIM signature
+ //bool dkimValid = await dkimVerifier.VerifyDkimAsync(emlContent.ToString(), clientIp);
+ //if (!dkimValid && configuration.GetValue<bool>("SmtpSettings:RequireDkim"))
+ //{
+ // await writer.WriteLineAsync(configuration["SmtpResponses:DkimFailed"], ct);
+ // return;
+ //}
+
+ // Verify DMARC
+ string mailFromDomain = ExtractDomain(mailFrom!);
+
+ bool dmarcValid = await dmarcChecker.CheckDmarcAsync(emlContent.ToString(), clientIp, mailFromDomain, mailFromDomain, ct);
+ if (!dmarcValid && configuration.GetValue<bool>("DmarcSettings:RequireDmarc"))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:DmarcFailed"], ct);
+ return;
+ }
+
+ // Sign with DKIM
+ //string selector = configuration[$"SmtpSettings:Dkim:{mailFromDomain}:Selector"] ?? "default";
+ //string signedEml = dkimSigner.SignEmail(emlContent.ToString(), selector, mailFromDomain);
+
+ string signedEml = emlContent.ToString();
+
+ // Deliver .eml to each recipient's INBOX under {MailboxSettings:StoragePath}/{domain}/{user}/INBOX/
+ string storagePath = configuration["MailboxSettings:StoragePath"] ?? throw new InvalidOperationException("MailboxSettings:StoragePath not configured");
+ string messageId = Guid.NewGuid().ToString();
+ foreach (string recipient in rcptTo)
+ {
+ string domain = ExtractDomain(recipient);
+ string user = ExtractUser(recipient);
+ string inboxPath = Path.Combine(storagePath, domain, user, "INBOX");
+ Directory.CreateDirectory(inboxPath);
+ string fileName = Path.Combine(inboxPath, $"{messageId}.eml");
+ await File.WriteAllTextAsync(fileName, signedEml, ct);
+ logger.LogInformation("Received email from {MailFrom} to {Recipient} saved as {FileName}", mailFrom, recipient, fileName);
+ }
+
+ await writer.WriteLineAsync(configuration["SmtpResponses:MessageAccepted"], ct);
+
+ var rcptDomains = rcptTo.Select(r => ExtractDomain(r)).Where(d => d.Length > 0);
+ metrics.MessageReceived(mailFromDomain, rcptDomains, signedEml.Length);
+
+ mailFrom = null;
+ rcptTo.Clear();
+ data.Clear();
+ }
+
+ // Extracts the domain from any address format:
+ // user@domain.com → domain.com
+ // <user@domain.com> → domain.com
+ // Display Name <user@domain.com> → domain.com
+ private static string ExtractDomain(string address)
+ {
+ int at = address.IndexOf('@');
+ if (at < 0)
+ return string.Empty;
+ string after = address[(at + 1)..];
+ int len = 0;
+ while (len < after.Length && (char.IsLetterOrDigit(after[len]) || after[len] == '.' || after[len] == '-'))
+ len++;
+ return after[..len];
+ }
+
+ // Extracts the local part (user) from any address format:
+ // user@domain.com → user
+ // <user@domain.com> → user
+ // Display Name <user@domain.com> → user
+ private static string ExtractUser(string address)
+ {
+ int at = address.IndexOf('@');
+ if (at < 0)
+ return string.Empty;
+ int start = address.LastIndexOf('<', at);
+ return start >= 0 ? address[(start + 1)..at] : address[..at];
+ }
+
+}
diff --git a/MailSharp.SMTP/Session/DataHeaderEnd.cs b/MailSharp.SMTP/Session/DataHeaderEnd.cs
new file mode 100644
index 0000000..5d46304
--- /dev/null
+++ b/MailSharp.SMTP/Session/DataHeaderEnd.cs
@@ -0,0 +1,43 @@
+using MailSharp.SMTP.Extensions;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle end of email headers
+ private async Task HandleHeaderEndAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.HeaderStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+
+ // Validate required headers
+ string[] headers = data.ToString().Split(new[] { "\r\n" }, StringSplitOptions.None);
+ bool hasFrom = headers.Any(h => h.StartsWith("From:", StringComparison.OrdinalIgnoreCase));
+ bool hasTo = headers.Any(h => h.StartsWith("To:", StringComparison.OrdinalIgnoreCase));
+ bool hasDate = headers.Any(h => h.StartsWith("Date:", StringComparison.OrdinalIgnoreCase));
+
+ if (!hasFrom || !hasTo || !hasDate)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:InvalidHeaders"], ct);
+ state = SmtpState.RcptToReceived;
+ return;
+ }
+
+ // Basic spam check
+ bool isSpam = headers.Any(h =>
+ h.StartsWith("Subject:", StringComparison.OrdinalIgnoreCase) &&
+ h.Contains("Buy now", StringComparison.OrdinalIgnoreCase));
+
+ if (isSpam)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:SpamDetected"], ct);
+ state = SmtpState.RcptToReceived;
+ return;
+ }
+
+ state = SmtpState.DataStarted;
+ }
+}
diff --git a/MailSharp.SMTP/Session/EHLO.cs b/MailSharp.SMTP/Session/EHLO.cs
new file mode 100644
index 0000000..3f22224
--- /dev/null
+++ b/MailSharp.SMTP/Session/EHLO.cs
@@ -0,0 +1,28 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Extensions;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle EHLO command
+ private async Task HandleEhloAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.Initial && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+ bool tlsActive = security == SecurityEnum.Tls || state == SmtpState.TlsStarted;
+ state = SmtpState.HeloReceived;
+ await writer.WriteLineAsync(configuration["SmtpResponses:EhloSupport"], ct);
+ bool authAllowed = ipGroup != null && (!ipGroup.Access.RequireSslTlsForAuth || tlsActive);
+ if (authAllowed)
+ await writer.WriteLineAsync(configuration["SmtpResponses:AuthSupport"], ct);
+
+ if (security == SecurityEnum.StartTlsOptional || security == SecurityEnum.StartTls)
+ await writer.WriteLineAsync(configuration["SmtpResponses:StartTlsSupport"], ct);
+
+ await writer.WriteLineAsync(string.Format(configuration["SmtpResponses:EhloSizeFormat"]!, configuration.GetValue<long>("SmtpSettings:MaxMessageSize")), ct);
+ }
+}
diff --git a/MailSharp.SMTP/Session/EXPN.cs b/MailSharp.SMTP/Session/EXPN.cs
new file mode 100644
index 0000000..d8addc1
--- /dev/null
+++ b/MailSharp.SMTP/Session/EXPN.cs
@@ -0,0 +1,54 @@
+using MailSharp.SMTP.Extensions;
+using System.Text.Json;
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle EXPN command
+ private async Task HandleExpnAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (!configuration.GetValue<bool>("SmtpSettings:EnableExpn"))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:ExpnDisabled"], ct);
+ return;
+ }
+ if (state != SmtpState.HeloReceived)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+ if (parts.Length < 2 || string.IsNullOrWhiteSpace(parts[1]))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:SyntaxError"], ct);
+ return;
+ }
+
+ string mailingList = parts[1];
+ try
+ {
+ string mailingListPath = configuration["SmtpSettings:MailingListPath"] ?? throw new InvalidOperationException("MailingListPath not configured");
+ if (!File.Exists(mailingListPath))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:ExpnFailed"], ct);
+ return;
+ }
+
+ string json = await File.ReadAllTextAsync(mailingListPath, ct);
+ var lists = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(json) ?? throw new InvalidOperationException("Invalid mailing list format");
+
+ if (lists.TryGetValue(mailingList, out var members))
+ {
+ await writer.WriteLineAsync($"250 {string.Join(", ", members)}", ct);
+ }
+ else
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:ExpnFailed"], ct);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError("Error expanding mailing list {0}: {1}", mailingList, ex.Message);
+ await writer.WriteLineAsync(configuration["SmtpResponses:ExpnFailed"], ct);
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.SMTP/Session/HELO.cs b/MailSharp.SMTP/Session/HELO.cs
new file mode 100644
index 0000000..73f0a36
--- /dev/null
+++ b/MailSharp.SMTP/Session/HELO.cs
@@ -0,0 +1,19 @@
+using MailSharp.SMTP.Extensions;
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle HELO command
+
+ private async Task HandleHeloAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.Initial && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+ state = SmtpState.HeloReceived;
+ await writer.WriteLineAsync($"{configuration["SmtpResponses:Hello"]} {parts.ElementAtOrDefault(1) ?? "client"}", ct);
+ }
+
+}
diff --git a/MailSharp.SMTP/Session/HELP.cs b/MailSharp.SMTP/Session/HELP.cs
new file mode 100644
index 0000000..3a2c288
--- /dev/null
+++ b/MailSharp.SMTP/Session/HELP.cs
@@ -0,0 +1,12 @@
+using MailSharp.SMTP.Extensions;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle HELP command
+ private async Task HandleHelpAsync(string[] parts, string line, CancellationToken ct)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:Help"], ct);
+ }
+}
diff --git a/MailSharp.SMTP/Session/MAIL.cs b/MailSharp.SMTP/Session/MAIL.cs
new file mode 100644
index 0000000..f1d37a7
--- /dev/null
+++ b/MailSharp.SMTP/Session/MAIL.cs
@@ -0,0 +1,49 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Extensions;
+using System.Net;
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // In HandleMailAsync, add SPF check
+ private async Task HandleMailAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.HeloReceived && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+
+ if (security == SecurityEnum.StartTls && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:TlsRequired"], ct);
+ return;
+ }
+
+ if (!line.Contains("FROM:", StringComparison.OrdinalIgnoreCase))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:SyntaxError"], ct);
+ return;
+ }
+
+ string mailFromAddress = line.Substring(line.IndexOf("FROM:", StringComparison.OrdinalIgnoreCase) + 5).Trim();
+ string mailFromDomain = mailFromAddress.Substring(mailFromAddress.IndexOf('@') + 1);
+ string clientIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "Unknown";
+
+ if (configuration.GetValue<bool>("SmtpSettings:RequireDkim"))
+ {
+ bool spfValid = await spfChecker.CheckSpfAsync(clientIp, mailFromDomain, mailFromDomain);
+
+ if (!spfValid)
+ {
+ await writer.WriteLineAsync("550 SPF check failed", ct);
+ return;
+ }
+ }
+
+ mailFrom = mailFromAddress;
+ state = SmtpState.MailFromReceived;
+ await writer.WriteLineAsync(configuration["SmtpResponses:Ok"], ct);
+ }
+
+}
diff --git a/MailSharp.SMTP/Session/NOOP.cs b/MailSharp.SMTP/Session/NOOP.cs
new file mode 100644
index 0000000..5b11e18
--- /dev/null
+++ b/MailSharp.SMTP/Session/NOOP.cs
@@ -0,0 +1,11 @@
+using MailSharp.SMTP.Extensions;
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle NOOP command
+ private async Task HandleNoopAsync(string[] parts, string line, CancellationToken ct)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:Ok"], ct);
+ }
+}
diff --git a/MailSharp.SMTP/Session/QUIT.cs b/MailSharp.SMTP/Session/QUIT.cs
new file mode 100644
index 0000000..a06aa97
--- /dev/null
+++ b/MailSharp.SMTP/Session/QUIT.cs
@@ -0,0 +1,12 @@
+using MailSharp.SMTP.Extensions;
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle QUIT command
+ private async Task HandleQuitAsync(string[] parts, string line, CancellationToken ct)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:Quit"], ct);
+ }
+
+}
diff --git a/MailSharp.SMTP/Session/RCPT.cs b/MailSharp.SMTP/Session/RCPT.cs
new file mode 100644
index 0000000..0b07a50
--- /dev/null
+++ b/MailSharp.SMTP/Session/RCPT.cs
@@ -0,0 +1,33 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Extensions;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle RCPT command
+ private async Task HandleRcptAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.MailFromReceived)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+
+ if (security == SecurityEnum.StartTls && state != SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:TlsRequired"], ct);
+ return;
+ }
+
+ if (!line.Contains("TO:", StringComparison.OrdinalIgnoreCase))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:SyntaxError"], ct);
+ return;
+ }
+ string recipient = line[(line.IndexOf("TO:", StringComparison.OrdinalIgnoreCase) + 3)..].Trim();
+ rcptTo.Add(recipient);
+ state = SmtpState.RcptToReceived;
+ await writer.WriteLineAsync(configuration["SmtpResponses:Ok"], ct);
+ }
+}
diff --git a/MailSharp.SMTP/Session/RSET.cs b/MailSharp.SMTP/Session/RSET.cs
new file mode 100644
index 0000000..3feed2f
--- /dev/null
+++ b/MailSharp.SMTP/Session/RSET.cs
@@ -0,0 +1,15 @@
+using MailSharp.SMTP.Extensions;
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle RSET command
+ private async Task HandleRsetAsync(string[] parts, string line, CancellationToken ct)
+ {
+ state = SmtpState.HeloReceived;
+ mailFrom = null;
+ rcptTo.Clear();
+ data.Clear();
+ await writer.WriteLineAsync(configuration["SmtpResponses:Ok"], ct);
+ }
+}
diff --git a/MailSharp.SMTP/Session/STARTTLS.cs b/MailSharp.SMTP/Session/STARTTLS.cs
new file mode 100644
index 0000000..e90fda8
--- /dev/null
+++ b/MailSharp.SMTP/Session/STARTTLS.cs
@@ -0,0 +1,51 @@
+using MailSharp.SMTP.Extensions;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle STARTTLS command
+ private async Task HandleStartTlsAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (state != SmtpState.HeloReceived)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+
+ if (state == SmtpState.TlsStarted)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:StartTlsInvalid"], ct);
+ return;
+ }
+
+ await writer.WriteLineAsync(configuration["SmtpResponses:StartTls"], ct);
+ try
+ {
+ string certPath = configuration["SmtpSettings:CertificatePath"] ?? throw new InvalidOperationException("CertificatePath not configured");
+ string certPassword = configuration["SmtpSettings:CertificatePassword"] ?? string.Empty;
+ X509Certificate2 certificate = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
+
+ SslStream sslStream = new(stream, false);
+ await sslStream.AuthenticateAsServerAsync(certificate, false, System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13, false);
+ this.stream = sslStream;
+ this.reader = new StreamReader(sslStream, Encoding.ASCII, false, -1, leaveOpen: true);
+ this.writer = new StreamWriter(sslStream, Encoding.ASCII, -1, leaveOpen: true) { AutoFlush = true };
+
+ // Reset session as per RFC 3207
+ state = SmtpState.Initial;
+ mailFrom = null;
+ rcptTo.Clear();
+ data.Clear();
+ }
+ catch(Exception exception)
+ {
+ logger.LogError(exception, "HandleStartTlsAsync failed");
+ await writer.WriteLineAsync(configuration["SmtpResponses:StartTlsFailed"], ct);
+ state = SmtpState.HeloReceived;
+ }
+ }
+}
diff --git a/MailSharp.SMTP/Session/SmtpSession.cs b/MailSharp.SMTP/Session/SmtpSession.cs
new file mode 100644
index 0000000..da739f8
--- /dev/null
+++ b/MailSharp.SMTP/Session/SmtpSession.cs
@@ -0,0 +1,241 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Extensions;
+using MailSharp.SMTP.Metrics;
+using MailSharp.SMTP.Server;
+using MailSharp.SMTP.Services;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+namespace MailSharp.SMTP.Session;
+
+public enum SmtpState
+{
+ Initial,
+ HeloReceived,
+ MailFromReceived,
+ RcptToReceived,
+ HeaderStarted,
+ DataStarted,
+ TlsStarted
+}
+
+public partial class SmtpSession
+{
+ private SmtpMetrics metrics;
+ private readonly TcpClient client;
+ private readonly IConfiguration configuration;
+ private readonly SecurityEnum security;
+ private readonly ILogger<SmtpSession> logger;
+ private readonly long sessionId;
+ private SmtpState state;
+ private string? mailFrom;
+ private readonly List<string> rcptTo = [];
+ private readonly StringBuilder data = new();
+ private StreamWriter writer;
+ private StreamReader reader;
+ private Stream stream;
+ private readonly Dictionary<string, Func<string[], string, CancellationToken, Task>> commandHandlers = [];
+ private static long nextSessionId = 0;
+ private IpGroup? ipGroup;
+ private readonly DkimSigner dkimSigner;
+ private readonly SpfChecker spfChecker;
+ private readonly DkimVerifier dkimVerifier;
+ private readonly DmarcChecker dmarcChecker;
+
+ public SmtpSession(
+ TcpClient client,
+ IConfiguration configuration,
+ SecurityEnum security,
+ DkimSigner dkimSigner,
+ SpfChecker spfChecker,
+ DkimVerifier dkimVerifier,
+ DmarcChecker dmarcChecker,
+ SmtpMetrics metrics,
+ ILogger<SmtpSession> logger)
+ {
+ this.metrics = metrics;
+ this.client = client;
+ this.configuration = configuration;
+ this.security = security;
+ this.dkimSigner = dkimSigner;
+ this.spfChecker = spfChecker;
+ this.dkimVerifier = dkimVerifier;
+ this.dmarcChecker = dmarcChecker;
+ this.logger = logger;
+ this.sessionId = Interlocked.Increment(ref nextSessionId);
+ this.state = security == SecurityEnum.Tls ? SmtpState.TlsStarted : SmtpState.Initial;
+ this.stream = client.GetStream();
+ if (security == SecurityEnum.Tls)
+ {
+ string certPath = configuration["SmtpSettings:CertificatePath"] ?? throw new InvalidOperationException("CertificatePath not configured");
+ string certPassword = configuration["SmtpSettings:CertificatePassword"] ?? string.Empty;
+ X509Certificate2 certificate = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
+ SslStream sslStream = new(stream, false);
+ sslStream.AuthenticateAsServer(certificate, false, System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13, false);
+ this.stream = sslStream;
+ }
+ this.reader = new StreamReader(stream, Encoding.ASCII);
+ this.writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true };
+ InitializeHandlers();
+ using (logger.BeginScope(new Dictionary<string, object> { ["SessionId"] = sessionId }))
+ {
+ var eventIdConfig = configuration.GetSection("SmtpEventIds:SessionStarted").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:SessionStarted");
+ logger.LogInformation(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:SessionStarted"], sessionId, client.Client.RemoteEndPoint);
+ }
+
+ metrics.IncrementConnections();
+ metrics.IncrementActive();
+ }
+
+ private void InitializeHandlers()
+ {
+ commandHandlers.Add("HELO", HandleHeloAsync);
+ commandHandlers.Add("EHLO", HandleEhloAsync);
+ commandHandlers.Add("MAIL", HandleMailAsync);
+ commandHandlers.Add("RCPT", HandleRcptAsync);
+ commandHandlers.Add("DATA", HandleDataAsync);
+ commandHandlers.Add("QUIT", HandleQuitAsync);
+ commandHandlers.Add("NOOP", HandleNoopAsync);
+ commandHandlers.Add("RSET", HandleRsetAsync);
+ commandHandlers.Add("VRFY", HandleVrfyAsync);
+ commandHandlers.Add("EXPN", HandleExpnAsync);
+ commandHandlers.Add("HELP", HandleHelpAsync);
+ commandHandlers.Add("AUTH", HandleAuthAsync);
+ commandHandlers.Add("STARTTLS", HandleStartTlsAsync);
+ }
+
+ public Task ProcessAsync(CancellationToken cancellationToken)
+ {
+ var task = ProcessInternalAsync(cancellationToken);
+
+ // Zorgt dat DecrementActive() ALTIJD wordt aangeroepen, zelfs bij exception of cancel
+ _ = task.ContinueWith(
+ t => metrics.DecrementActive(),
+ TaskScheduler.Default);
+
+ return task; // voor als je ooit wil awaiten (kan geen kwaad)
+ }
+
+ public async Task ProcessInternalAsync(CancellationToken cancellationToken)
+ {
+ using (client)
+ using (stream)
+ using (reader)
+ using (writer)
+ {
+ var clientEndPoint = (System.Net.IPEndPoint?)client.Client.RemoteEndPoint;
+ var groups = configuration.GetSection("IpGroups").Get<List<IpGroup>>() ?? [];
+ ipGroup = clientEndPoint != null ? IpGroupMatcher.Match(groups, clientEndPoint.Address) : null;
+
+ if (ipGroup == null || !ipGroup.Access.Smtp)
+ {
+ await writer.WriteLineAsync("554 No access from your IP address", cancellationToken);
+ return;
+ }
+
+ var hostname = configuration["SmtpSettings:LocalHostName"]?.Trim();
+ if (string.IsNullOrEmpty(hostname)) hostname = System.Net.Dns.GetHostName();
+ await writer.WriteLineAsync($"220 {hostname} ESMTP", cancellationToken);
+ int timeoutSeconds = configuration.GetValue<int>("SmtpSettings:CommandTimeoutSeconds");
+
+ using (logger.BeginScope(new Dictionary<string, object> { ["SessionId"] = sessionId }))
+ {
+ while (client.Connected && !cancellationToken.IsCancellationRequested)
+ {
+ using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
+
+ EventIdConfig eventIdConfig;
+ try
+ {
+ string? line = await reader.ReadLineAsync(linkedCts.Token);
+ if (line == null)
+ {
+ eventIdConfig = configuration.GetSection("SmtpEventIds:ClientDisconnected").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:ClientDisconnected");
+ logger.LogWarning(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:ClientDisconnected"], sessionId);
+ return;
+ }
+
+ if (state == SmtpState.HeaderStarted)
+ {
+ data.AppendLine(line);
+
+ if (line == string.Empty)
+ {
+ eventIdConfig = configuration.GetSection("SmtpEventIds:HeaderEnd").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:HeaderEnd");
+ logger.LogInformation(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:HeaderEnd"], sessionId);
+
+ await HandleHeaderEndAsync([], line, linkedCts.Token);
+ }
+ continue;
+ }
+
+ if (state == SmtpState.DataStarted)
+ {
+ if (line == ".")
+ {
+ eventIdConfig = configuration.GetSection("SmtpEventIds:DataEnd").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:DataEnd");
+ logger.LogInformation(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:DataEnd"], sessionId);
+
+ await HandleDataEndAsync([], line, linkedCts.Token);
+ }
+ else
+ data.AppendLine(line);
+ continue;
+ }
+
+ string[] parts = line.Split(' ');
+ string command = parts[0].ToUpper();
+ eventIdConfig = configuration.GetSection("SmtpEventIds:CommandReceived").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:CommandReceived");
+
+ logger.LogInformation(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:CommandReceived"], command, sessionId);
+
+ if (commandHandlers.TryGetValue(command, out var handler))
+ {
+ await handler(parts, line, linkedCts.Token);
+ if (command == "QUIT")
+ {
+ eventIdConfig = configuration.GetSection("SmtpEventIds:SessionEndedByQuit").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:SessionEndedByQuit");
+ logger.LogInformation(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:SessionEndedByQuit"], sessionId);
+ return;
+ }
+ }
+ else
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:CommandNotRecognized"], linkedCts.Token);
+ eventIdConfig = configuration.GetSection("SmtpEventIds:UnrecognizedCommand").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:UnrecognizedCommand");
+ logger.LogWarning(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:UnrecognizedCommand"], command, sessionId);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ await writer.WriteLineAsync(timeoutCts.Token.IsCancellationRequested
+ ? configuration["SmtpResponses:Timeout"]
+ : configuration["SmtpResponses:Shutdown"], linkedCts.Token);
+ eventIdConfig = configuration.GetSection("SmtpEventIds:SessionTerminated").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:SessionTerminated");
+ logger.LogWarning(new EventId(eventIdConfig.Id, eventIdConfig.Name), configuration["SmtpLogMessages:SessionTerminated"],
+ timeoutCts.Token.IsCancellationRequested ? "timeout" : "shutdown", sessionId);
+ return;
+ }
+ catch (Exception ex)
+ {
+ eventIdConfig = configuration.GetSection("SmtpEventIds:CommandProcessingError").Get<EventIdConfig>()
+ ?? throw new InvalidOperationException("Missing SmtpEventIds:CommandProcessingError");
+ logger.LogError(new EventId(eventIdConfig.Id, eventIdConfig.Name), ex, configuration["SmtpLogMessages:CommandProcessingError"], sessionId);
+ await writer.WriteLineAsync(configuration["SmtpResponses:CommandNotRecognized"], linkedCts.Token);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/MailSharp.SMTP/Session/VRFY.cs b/MailSharp.SMTP/Session/VRFY.cs
new file mode 100644
index 0000000..dd82ef8
--- /dev/null
+++ b/MailSharp.SMTP/Session/VRFY.cs
@@ -0,0 +1,57 @@
+using MailSharp.Common;
+using MailSharp.SMTP.Extensions;
+using System.Text.Json;
+
+namespace MailSharp.SMTP.Session;
+
+public partial class SmtpSession
+{
+ // Handle VRFY command
+ private async Task HandleVrfyAsync(string[] parts, string line, CancellationToken ct)
+ {
+ if (!configuration.GetValue<bool>("SmtpSettings:EnableVrfy"))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:VrfyDisabled"], ct);
+ return;
+ }
+ if (state != SmtpState.HeloReceived)
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:BadSequence"], ct);
+ return;
+ }
+ if (parts.Length < 2 || string.IsNullOrWhiteSpace(parts[1]))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:SyntaxError"], ct);
+ return;
+ }
+
+ string username = parts[1];
+ try
+ {
+ string userStorePath = configuration["SmtpSettings:UserStorePath"] ?? throw new InvalidOperationException("UserStorePath not configured");
+ if (!File.Exists(userStorePath))
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:VrfyFailed"], ct);
+ return;
+ }
+
+ string json = await File.ReadAllTextAsync(userStorePath, ct);
+ var users = JsonSerializer.Deserialize<List<UserConfig>>(json) ?? throw new InvalidOperationException("Invalid user store format");
+ var user = users.FirstOrDefault(u => u.Username == username);
+
+ if (user != null)
+ {
+ await writer.WriteLineAsync($"{configuration["SmtpResponses:VrfySuccess"]} {username}", ct);
+ }
+ else
+ {
+ await writer.WriteLineAsync(configuration["SmtpResponses:VrfyFailed"], ct);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError("Error verifying user {0}: {1}", username, ex.Message);
+ await writer.WriteLineAsync(configuration["SmtpResponses:VrfyFailed"], ct);
+ }
+ }
+}
diff --git a/MailSharp.TestFormApp/Form1.Designer.cs b/MailSharp.TestFormApp/Form1.Designer.cs
new file mode 100644
index 0000000..791a142
--- /dev/null
+++ b/MailSharp.TestFormApp/Form1.Designer.cs
@@ -0,0 +1,384 @@
+namespace MailSharp.TestFormApp
+{
+ partial class Form1
+ {
+ /// <summary>
+ /// Required designer variable.
+ /// </summary>
+ private System.ComponentModel.IContainer components = null;
+
+ /// <summary>
+ /// Clean up any resources being used.
+ /// </summary>
+ /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ /// <summary>
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ /// </summary>
+ private void InitializeComponent()
+ {
+ label1 = new Label();
+ txtServer = new TextBox();
+ txtUserid = new TextBox();
+ label2 = new Label();
+ txtPassword = new TextBox();
+ label3 = new Label();
+ txtFrom = new TextBox();
+ label4 = new Label();
+ txtTo = new TextBox();
+ label5 = new Label();
+ groupBox1 = new GroupBox();
+ rad3 = new RadioButton();
+ rad2 = new RadioButton();
+ rad1 = new RadioButton();
+ txtSubject = new TextBox();
+ label6 = new Label();
+ txtBody = new TextBox();
+ label7 = new Label();
+ chkEnableSSL = new CheckBox();
+ grpCredentials = new GroupBox();
+ chkUseCredentials = new CheckBox();
+ txtLog = new TextBox();
+ grpEmail = new GroupBox();
+ chkHtml = new CheckBox();
+ button1 = new Button();
+ button2 = new Button();
+ groupBox1.SuspendLayout();
+ grpCredentials.SuspendLayout();
+ grpEmail.SuspendLayout();
+ SuspendLayout();
+ //
+ // label1
+ //
+ label1.AutoSize = true;
+ label1.Location = new Point(19, 37);
+ label1.Name = "label1";
+ label1.Size = new Size(39, 15);
+ label1.TabIndex = 0;
+ label1.Text = "Server";
+ //
+ // txtServer
+ //
+ txtServer.Location = new Point(64, 34);
+ txtServer.Name = "txtServer";
+ txtServer.Size = new Size(120, 23);
+ txtServer.TabIndex = 1;
+ txtServer.Text = "127.0.0.1";
+ //
+ // txtUserid
+ //
+ txtUserid.Location = new Point(67, 22);
+ txtUserid.Name = "txtUserid";
+ txtUserid.Size = new Size(120, 23);
+ txtUserid.TabIndex = 3;
+ txtUserid.Text = "testuser";
+ //
+ // label2
+ //
+ label2.AutoSize = true;
+ label2.Location = new Point(22, 25);
+ label2.Name = "label2";
+ label2.Size = new Size(39, 15);
+ label2.TabIndex = 2;
+ label2.Text = "userid";
+ //
+ // txtPassword
+ //
+ txtPassword.Location = new Point(67, 51);
+ txtPassword.Name = "txtPassword";
+ txtPassword.Size = new Size(120, 23);
+ txtPassword.TabIndex = 5;
+ txtPassword.Text = "testpassword";
+ //
+ // label3
+ //
+ label3.AutoSize = true;
+ label3.Location = new Point(4, 54);
+ label3.Name = "label3";
+ label3.Size = new Size(57, 15);
+ label3.TabIndex = 4;
+ label3.Text = "password";
+ //
+ // txtFrom
+ //
+ txtFrom.Location = new Point(64, 218);
+ txtFrom.Name = "txtFrom";
+ txtFrom.Size = new Size(151, 23);
+ txtFrom.TabIndex = 7;
+ txtFrom.Text = "alphons@heijden.com";
+ //
+ // label4
+ //
+ label4.AutoSize = true;
+ label4.Location = new Point(19, 221);
+ label4.Name = "label4";
+ label4.Size = new Size(35, 15);
+ label4.TabIndex = 6;
+ label4.Text = "From";
+ //
+ // txtTo
+ //
+ txtTo.Location = new Point(64, 247);
+ txtTo.Name = "txtTo";
+ txtTo.Size = new Size(151, 23);
+ txtTo.TabIndex = 9;
+ txtTo.Text = "alphons@heijden.com";
+ //
+ // label5
+ //
+ label5.AutoSize = true;
+ label5.Location = new Point(38, 250);
+ label5.Name = "label5";
+ label5.Size = new Size(19, 15);
+ label5.TabIndex = 8;
+ label5.Text = "To";
+ //
+ // groupBox1
+ //
+ groupBox1.Controls.Add(rad3);
+ groupBox1.Controls.Add(rad2);
+ groupBox1.Controls.Add(rad1);
+ groupBox1.Location = new Point(200, 31);
+ groupBox1.Name = "groupBox1";
+ groupBox1.Size = new Size(165, 59);
+ groupBox1.TabIndex = 10;
+ groupBox1.TabStop = false;
+ groupBox1.Text = "port";
+ //
+ // rad3
+ //
+ rad3.AutoSize = true;
+ rad3.Location = new Point(112, 24);
+ rad3.Name = "rad3";
+ rad3.Size = new Size(43, 19);
+ rad3.TabIndex = 2;
+ rad3.TabStop = true;
+ rad3.Text = "587";
+ rad3.UseVisualStyleBackColor = true;
+ //
+ // rad2
+ //
+ rad2.AutoSize = true;
+ rad2.Location = new Point(63, 24);
+ rad2.Name = "rad2";
+ rad2.Size = new Size(43, 19);
+ rad2.TabIndex = 1;
+ rad2.TabStop = true;
+ rad2.Text = "465";
+ rad2.UseVisualStyleBackColor = true;
+ //
+ // rad1
+ //
+ rad1.AutoSize = true;
+ rad1.Checked = true;
+ rad1.Location = new Point(14, 24);
+ rad1.Name = "rad1";
+ rad1.Size = new Size(37, 19);
+ rad1.TabIndex = 0;
+ rad1.TabStop = true;
+ rad1.Text = "25";
+ rad1.UseVisualStyleBackColor = true;
+ //
+ // txtSubject
+ //
+ txtSubject.Location = new Point(64, 276);
+ txtSubject.Name = "txtSubject";
+ txtSubject.Size = new Size(212, 23);
+ txtSubject.TabIndex = 12;
+ txtSubject.Text = "Test Subject";
+ //
+ // label6
+ //
+ label6.AutoSize = true;
+ label6.Location = new Point(12, 279);
+ label6.Name = "label6";
+ label6.Size = new Size(46, 15);
+ label6.TabIndex = 11;
+ label6.Text = "Subject";
+ //
+ // txtBody
+ //
+ txtBody.AcceptsReturn = true;
+ txtBody.AcceptsTab = true;
+ txtBody.Location = new Point(64, 305);
+ txtBody.Multiline = true;
+ txtBody.Name = "txtBody";
+ txtBody.ScrollBars = ScrollBars.Both;
+ txtBody.Size = new Size(301, 97);
+ txtBody.TabIndex = 14;
+ txtBody.Text = "dit is een test\\r\\nklopt ook\\r\\n";
+ //
+ // label7
+ //
+ label7.AutoSize = true;
+ label7.Location = new Point(24, 308);
+ label7.Name = "label7";
+ label7.Size = new Size(34, 15);
+ label7.TabIndex = 13;
+ label7.Text = "Body";
+ //
+ // chkEnableSSL
+ //
+ chkEnableSSL.AutoSize = true;
+ chkEnableSSL.Location = new Point(64, 71);
+ chkEnableSSL.Name = "chkEnableSSL";
+ chkEnableSSL.Size = new Size(67, 19);
+ chkEnableSSL.TabIndex = 18;
+ chkEnableSSL.Text = "SSL/TLS";
+ chkEnableSSL.UseVisualStyleBackColor = true;
+ //
+ // grpCredentials
+ //
+ grpCredentials.Controls.Add(txtPassword);
+ grpCredentials.Controls.Add(label3);
+ grpCredentials.Controls.Add(txtUserid);
+ grpCredentials.Controls.Add(label2);
+ grpCredentials.Enabled = false;
+ grpCredentials.Location = new Point(64, 121);
+ grpCredentials.Name = "grpCredentials";
+ grpCredentials.Size = new Size(200, 81);
+ grpCredentials.TabIndex = 19;
+ grpCredentials.TabStop = false;
+ grpCredentials.Text = "credentials";
+ //
+ // chkUseCredentials
+ //
+ chkUseCredentials.AutoSize = true;
+ chkUseCredentials.Location = new Point(64, 96);
+ chkUseCredentials.Name = "chkUseCredentials";
+ chkUseCredentials.Size = new Size(104, 19);
+ chkUseCredentials.TabIndex = 20;
+ chkUseCredentials.Text = "use credentials";
+ chkUseCredentials.UseVisualStyleBackColor = true;
+ chkUseCredentials.CheckedChanged += UseCrendentials_CheckedChanged;
+ //
+ // txtLog
+ //
+ txtLog.AcceptsReturn = true;
+ txtLog.AcceptsTab = true;
+ txtLog.Location = new Point(411, 12);
+ txtLog.Multiline = true;
+ txtLog.Name = "txtLog";
+ txtLog.ReadOnly = true;
+ txtLog.ScrollBars = ScrollBars.Both;
+ txtLog.Size = new Size(377, 458);
+ txtLog.TabIndex = 21;
+ //
+ // grpEmail
+ //
+ grpEmail.Controls.Add(chkHtml);
+ grpEmail.Controls.Add(txtServer);
+ grpEmail.Controls.Add(label1);
+ grpEmail.Controls.Add(chkUseCredentials);
+ grpEmail.Controls.Add(label5);
+ grpEmail.Controls.Add(label4);
+ grpEmail.Controls.Add(txtTo);
+ grpEmail.Controls.Add(grpCredentials);
+ grpEmail.Controls.Add(groupBox1);
+ grpEmail.Controls.Add(txtFrom);
+ grpEmail.Controls.Add(label6);
+ grpEmail.Controls.Add(chkEnableSSL);
+ grpEmail.Controls.Add(txtSubject);
+ grpEmail.Controls.Add(label7);
+ grpEmail.Controls.Add(txtBody);
+ grpEmail.Location = new Point(12, 12);
+ grpEmail.Name = "grpEmail";
+ grpEmail.Size = new Size(382, 419);
+ grpEmail.TabIndex = 22;
+ grpEmail.TabStop = false;
+ grpEmail.Text = "email";
+ //
+ // chkHtml
+ //
+ chkHtml.AutoSize = true;
+ chkHtml.Location = new Point(307, 280);
+ chkHtml.Name = "chkHtml";
+ chkHtml.Size = new Size(58, 19);
+ chkHtml.TabIndex = 21;
+ chkHtml.Text = "HTML";
+ chkHtml.UseVisualStyleBackColor = true;
+ //
+ // button1
+ //
+ button1.Location = new Point(275, 437);
+ button1.Name = "button1";
+ button1.Size = new Size(75, 23);
+ button1.TabIndex = 23;
+ button1.Text = "Ok";
+ button1.UseVisualStyleBackColor = true;
+ button1.Click += SendEmail_Click;
+ //
+ // button2
+ //
+ button2.Enabled = false;
+ button2.Location = new Point(188, 437);
+ button2.Name = "button2";
+ button2.Size = new Size(75, 23);
+ button2.TabIndex = 24;
+ button2.Text = "Cancel";
+ button2.UseVisualStyleBackColor = true;
+ button2.Click += Cancel_Click;
+ //
+ // Form1
+ //
+ AutoScaleDimensions = new SizeF(7F, 15F);
+ AutoScaleMode = AutoScaleMode.Font;
+ ClientSize = new Size(800, 509);
+ Controls.Add(button2);
+ Controls.Add(button1);
+ Controls.Add(grpEmail);
+ Controls.Add(txtLog);
+ Name = "Form1";
+ Text = "Email Test Client";
+ groupBox1.ResumeLayout(false);
+ groupBox1.PerformLayout();
+ grpCredentials.ResumeLayout(false);
+ grpCredentials.PerformLayout();
+ grpEmail.ResumeLayout(false);
+ grpEmail.PerformLayout();
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private Label label1;
+ private TextBox txtServer;
+ private TextBox txtUserid;
+ private Label label2;
+ private TextBox txtPassword;
+ private Label label3;
+ private TextBox txtFrom;
+ private Label label4;
+ private TextBox txtTo;
+ private Label label5;
+ private GroupBox groupBox1;
+ private RadioButton rad3;
+ private RadioButton rad2;
+ private RadioButton rad1;
+ private TextBox txtSubject;
+ private Label label6;
+ private TextBox txtBody;
+ private Label label7;
+ private CheckBox chkStartTls;
+ private CheckBox chkEnableSSL;
+ private GroupBox grpCredentials;
+ private CheckBox chkUseCredentials;
+ private TextBox txtLog;
+ private GroupBox grpEmail;
+ private Button button1;
+ private Button button2;
+ private CheckBox chkHtml;
+ }
+}
diff --git a/MailSharp.TestFormApp/Form1.cs b/MailSharp.TestFormApp/Form1.cs
new file mode 100644
index 0000000..92f174f
--- /dev/null
+++ b/MailSharp.TestFormApp/Form1.cs
@@ -0,0 +1,95 @@
+using System.Net;
+using System.Net.Mail;
+using static System.Windows.Forms.VisualStyles.VisualStyleElement.StartPanel;
+using static System.Windows.Forms.VisualStyles.VisualStyleElement.Tab;
+
+namespace MailSharp.TestFormApp
+{
+ public partial class Form1 : Form
+ {
+ private CancellationTokenSource? cts;
+ public Form1()
+ {
+ InitializeComponent();
+ }
+
+ private void UseCrendentials_CheckedChanged(object sender, EventArgs e)
+ {
+ this.grpCredentials.Enabled = this.chkUseCredentials.Checked;
+ }
+
+ private async void SendEmail_Click(object sender, EventArgs e)
+ {
+ this.button1.Enabled = false;
+ this.button2.Enabled = true;
+ this.grpEmail.Enabled = false;
+
+ cts = new CancellationTokenSource();
+
+ try
+ {
+ await SendEmailAsync(cts.Token);
+
+ }
+ catch(OperationCanceledException)
+ {
+ //MessageBox.Show("Email sending was cancelled.");
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"An error occurred: {ex.Message}");
+ }
+ finally
+ {
+ this.button1.Enabled = true;
+ this.button2.Enabled = false;
+ this.grpEmail.Enabled = true;
+ }
+ }
+
+ private void Cancel_Click(object sender, EventArgs e)
+ {
+ if (this.cts is not null && !this.cts.IsCancellationRequested)
+ {
+ this.cts.Cancel();
+ }
+ }
+
+ private async Task SendEmailAsync(CancellationToken ct)
+ {
+ int smtpPort = 25;
+ if(rad1.Checked)
+ {
+ smtpPort = 25;
+ }
+ else if(rad2.Checked)
+ {
+ smtpPort = 465;
+ }
+ else if(rad3.Checked)
+ {
+ smtpPort = 587;
+ }
+
+ using SmtpClient client = new(this.txtServer.Text, smtpPort);
+
+ if(chkUseCredentials.Checked)
+ client.Credentials = new NetworkCredential(this.txtUserid.Text, this.txtPassword.Text);
+
+ if(this.chkEnableSSL.Checked)
+ client.EnableSsl = true;
+
+ MailMessage message = new()
+ {
+ From = new MailAddress(this.txtFrom.Text),
+ Subject = this.txtSubject.Text,
+ Body = this.txtBody.Text,
+ IsBodyHtml = false
+ };
+ message.To.Add(this.txtTo.Text);
+
+ await client.SendMailAsync(message, ct);
+ }
+
+ }
+}
diff --git a/MailSharp.TestFormApp/Form1.resx b/MailSharp.TestFormApp/Form1.resx
new file mode 100644
index 0000000..8b2ff64
--- /dev/null
+++ b/MailSharp.TestFormApp/Form1.resx
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+ <!--
+ Microsoft ResX Schema
+
+ Version 2.0
+
+ The primary goals of this format is to allow a simple XML format
+ that is mostly human readable. The generation and parsing of the
+ various data types are done through the TypeConverter classes
+ associated with the data types.
+
+ Example:
+
+ ... ado.net/XML headers & schema ...
+ <resheader name="resmimetype">text/microsoft-resx</resheader>
+ <resheader name="version">2.0</resheader>
+ <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+ <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+ <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+ <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+ <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+ <value>[base64 mime encoded serialized .NET Framework object]</value>
+ </data>
+ <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+ <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+ <comment>This is a comment</comment>
+ </data>
+
+ There are any number of "resheader" rows that contain simple
+ name/value pairs.
+
+ Each data row contains a name, and value. The row also contains a
+ type or mimetype. Type corresponds to a .NET class that support
+ text/value conversion through the TypeConverter architecture.
+ Classes that don't support this are serialized and stored with the
+ mimetype set.
+
+ The mimetype is used for serialized objects, and tells the
+ ResXResourceReader how to depersist the object. This is currently not
+ extensible. For a given mimetype the value must be set accordingly:
+
+ Note - application/x-microsoft.net.object.binary.base64 is the format
+ that the ResXResourceWriter will generate, however the reader can
+ read any of the formats listed below.
+
+ mimetype: application/x-microsoft.net.object.binary.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.soap.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.bytearray.base64
+ value : The object must be serialized into a byte array
+ : using a System.ComponentModel.TypeConverter
+ : and then encoded with base64 encoding.
+ -->
+ <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+ <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+ <xsd:element name="root" msdata:IsDataSet="true">
+ <xsd:complexType>
+ <xsd:choice maxOccurs="unbounded">
+ <xsd:element name="metadata">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" />
+ </xsd:sequence>
+ <xsd:attribute name="name" use="required" type="xsd:string" />
+ <xsd:attribute name="type" type="xsd:string" />
+ <xsd:attribute name="mimetype" type="xsd:string" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="assembly">
+ <xsd:complexType>
+ <xsd:attribute name="alias" type="xsd:string" />
+ <xsd:attribute name="name" type="xsd:string" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="data">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+ <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+ <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="resheader">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" />
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:choice>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ <resheader name="resmimetype">
+ <value>text/microsoft-resx</value>
+ </resheader>
+ <resheader name="version">
+ <value>2.0</value>
+ </resheader>
+ <resheader name="reader">
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <resheader name="writer">
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+</root>
\ No newline at end of file
diff --git a/MailSharp.TestFormApp/MailSharp.TestFormApp.csproj b/MailSharp.TestFormApp/MailSharp.TestFormApp.csproj
new file mode 100644
index 0000000..5151c0a
--- /dev/null
+++ b/MailSharp.TestFormApp/MailSharp.TestFormApp.csproj
@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>WinExe</OutputType>
+ <TargetFramework>net10.0-windows</TargetFramework>
+ <Nullable>enable</Nullable>
+ <UseWindowsForms>true</UseWindowsForms>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+</Project>
\ No newline at end of file
diff --git a/MailSharp.TestFormApp/Program.cs b/MailSharp.TestFormApp/Program.cs
new file mode 100644
index 0000000..7c7a6da
--- /dev/null
+++ b/MailSharp.TestFormApp/Program.cs
@@ -0,0 +1,17 @@
+namespace MailSharp.TestFormApp
+{
+ internal static class Program
+ {
+ /// <summary>
+ /// The main entry point for the application.
+ /// </summary>
+ [STAThread]
+ static void Main()
+ {
+ // To customize application configuration such as set high DPI settings or default font,
+ // see https://aka.ms/applicationconfiguration.
+ ApplicationConfiguration.Initialize();
+ Application.Run(new Form1());
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.WebManager/.config/dotnet-tools.json b/MailSharp.WebManager/.config/dotnet-tools.json
new file mode 100644
index 0000000..d4937e0
--- /dev/null
+++ b/MailSharp.WebManager/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "9.0.10",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.WebManager/Controllers/ConfigController.cs b/MailSharp.WebManager/Controllers/ConfigController.cs
new file mode 100644
index 0000000..b2ed0c0
--- /dev/null
+++ b/MailSharp.WebManager/Controllers/ConfigController.cs
@@ -0,0 +1,104 @@
+using MailSharp.WebManager.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.WebManager.Controllers;
+
+[Authorize(Roles = "Administrator")]
+[Route("~/api/[controller]")]
+[ApiController]
+public class ConfigController(ConfigService configService) : ControllerBase
+{
+ [HttpGet]
+ public IActionResult GetAll() => Ok(new
+ {
+ smtp = configService.GetSmtp(),
+ pop3 = configService.GetPop3(),
+ imap = configService.GetImap(),
+ dmarc = configService.GetDmarc(),
+ mailbox = configService.GetMailbox(),
+ ipGroups = configService.GetIpGroups(),
+ general = configService.GetGeneral()
+ });
+
+ // ── SMTP ────────────────────────────────────────────────────
+ [HttpPost("smtp/general")]
+ public IActionResult SaveSmtpGeneral([FromBody] SmtpGeneralDto dto)
+ { configService.SaveSmtpGeneral(dto); return Ok(); }
+
+ [HttpPost("smtp/connections")]
+ public IActionResult SaveSmtpConnections([FromBody] SmtpConnectionsDto dto)
+ { configService.SaveSmtpConnections(dto); return Ok(); }
+
+ [HttpPost("smtp/delivery")]
+ public IActionResult SaveSmtpDelivery([FromBody] SmtpDeliveryDto dto)
+ { configService.SaveSmtpDelivery(dto); return Ok(); }
+
+ [HttpPost("smtp/relay")]
+ public IActionResult SaveSmtpRelay([FromBody] SmtpRelayDto dto)
+ { configService.SaveSmtpRelay(dto); return Ok(); }
+
+ [HttpPost("smtp/security")]
+ public IActionResult SaveSmtpSecurity([FromBody] SmtpSecurityDto dto)
+ { configService.SaveSmtpSecurity(dto); return Ok(); }
+
+ [HttpPost("smtp/limits")]
+ public IActionResult SaveSmtpLimits([FromBody] SmtpLimitsDto dto)
+ { configService.SaveSmtpLimits(dto); return Ok(); }
+
+ // ── POP3 ────────────────────────────────────────────────────
+ [HttpPost("pop3/general")]
+ public IActionResult SavePop3General([FromBody] Pop3GeneralDto dto)
+ { configService.SavePop3General(dto); return Ok(); }
+
+ [HttpPost("pop3/connections")]
+ public IActionResult SavePop3Connections([FromBody] Pop3ConnectionsDto dto)
+ { configService.SavePop3Connections(dto); return Ok(); }
+
+ // ── IMAP ────────────────────────────────────────────────────
+ [HttpPost("imap/general")]
+ public IActionResult SaveImapGeneral([FromBody] ImapGeneralDto dto)
+ { configService.SaveImapGeneral(dto); return Ok(); }
+
+ [HttpPost("imap/connections")]
+ public IActionResult SaveImapConnections([FromBody] ImapConnectionsDto dto)
+ { configService.SaveImapConnections(dto); return Ok(); }
+
+ [HttpPost("imap/folders")]
+ public IActionResult SaveImapFolders([FromBody] ImapFoldersDto dto)
+ { configService.SaveImapFolders(dto); return Ok(); }
+
+ [HttpPost("imap/advanced")]
+ public IActionResult SaveImapAdvanced([FromBody] ImapAdvancedDto dto)
+ { configService.SaveImapAdvanced(dto); return Ok(); }
+
+ // ── Shared ──────────────────────────────────────────────────
+ [HttpPost("dmarc")]
+ public IActionResult SaveDmarc([FromBody] DmarcConfigDto dto)
+ { configService.SaveDmarc(dto); return Ok(); }
+
+ [HttpPost("mailbox")]
+ public IActionResult SaveMailbox([FromBody] MailboxConfigDto dto)
+ { configService.SaveMailbox(dto); return Ok(); }
+
+ [HttpGet("general")]
+ public IActionResult GetGeneral() => Ok(configService.GetGeneral());
+
+ [HttpPost("general")]
+ public IActionResult SaveGeneral([FromBody] GeneralSettingsDto dto)
+ { configService.SaveGeneral(dto); return Ok(); }
+
+ [HttpGet("maintenance-users")]
+ public IActionResult GetMaintenanceUsers() => Ok(configService.GetMaintenanceUsers());
+
+ [HttpPost("maintenance-users")]
+ public IActionResult SaveMaintenanceUsers([FromBody] List<MaintenanceUserDto> dto)
+ { configService.SaveMaintenanceUsers(dto); return Ok(); }
+
+ [HttpGet("ipgroups")]
+ public IActionResult GetIpGroups() => Ok(configService.GetIpGroups());
+
+ [HttpPost("ipgroups")]
+ public IActionResult SaveIpGroups([FromBody] List<IpGroupDto> dto)
+ { configService.SaveIpGroups(dto); return Ok(); }
+}
diff --git a/MailSharp.WebManager/Controllers/DomainController.cs b/MailSharp.WebManager/Controllers/DomainController.cs
new file mode 100644
index 0000000..9d6717d
--- /dev/null
+++ b/MailSharp.WebManager/Controllers/DomainController.cs
@@ -0,0 +1,160 @@
+using MailSharp.WebManager.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.WebManager.Controllers;
+
+[Authorize(Roles = "Administrator")]
+[Route("~/api/[controller]")]
+[ApiController]
+public class DomainController(DomainService domainService, IConfiguration configuration) : ControllerBase
+{
+ [HttpGet]
+ public IActionResult GetAll() => Ok(domainService.GetAll());
+
+ [HttpGet("{id}")]
+ public IActionResult GetById(string id)
+ {
+ var d = domainService.GetById(id);
+ return d is null ? NotFound() : Ok(d);
+ }
+
+ [HttpPost]
+ public IActionResult Create([FromBody] CreateDomainDto dto)
+ {
+ if (string.IsNullOrWhiteSpace(dto.Name))
+ return BadRequest("Name is required.");
+ return Ok(domainService.Create(dto.Name));
+ }
+
+ [HttpDelete("{id}")]
+ public IActionResult Delete(string id) =>
+ domainService.Delete(id) ? Ok() : NotFound();
+
+ // ── General ────────────────────────────────────────────────
+
+ [HttpPut("{id}/general")]
+ public IActionResult UpdateGeneral(string id, [FromBody] GeneralDto dto)
+ {
+ if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name is required.");
+ return domainService.PatchGeneral(id, dto.Name, dto.Enabled, dto.CatchAll ?? "") ? Ok() : NotFound();
+ }
+
+ // ── Limits ─────────────────────────────────────────────────
+
+ [HttpPut("{id}/limits")]
+ public IActionResult UpdateLimits(string id, [FromBody] DomainLimits dto) =>
+ domainService.PatchLimits(id, dto) ? Ok() : NotFound();
+
+ // ── DKIM ───────────────────────────────────────────────────
+
+ [HttpPut("{id}/dkim")]
+ public IActionResult UpdateDkim(string id, [FromBody] DkimConfig dto) =>
+ domainService.PatchDkim(id, dto) ? Ok() : NotFound();
+
+ // ── Domain aliases ─────────────────────────────────────────
+
+ [HttpPost("{id}/aliases")]
+ public IActionResult AddAlias(string id, [FromBody] AliasValueDto dto)
+ {
+ if (string.IsNullOrWhiteSpace(dto.Value)) return BadRequest("Alias is required.");
+ return domainService.AddAlias(id, dto.Value) ? Ok() : NotFound();
+ }
+
+ [HttpDelete("{id}/aliases/{alias}")]
+ public IActionResult RemoveAlias(string id, string alias) =>
+ domainService.RemoveAlias(id, alias) ? Ok() : NotFound();
+
+ // ── Users ──────────────────────────────────────────────────
+
+ [HttpPost("{id}/users")]
+ public IActionResult AddUser(string id, [FromBody] DomainUser dto)
+ {
+ if (string.IsNullOrWhiteSpace(dto.Username)) return BadRequest("Username is required.");
+ if (string.IsNullOrWhiteSpace(dto.Password)) return BadRequest("Password is required.");
+ dto.Id = Guid.NewGuid().ToString();
+ return domainService.AddUser(id, dto) ? Ok(dto) : NotFound();
+ }
+
+ [HttpPut("{id}/users/{userId}")]
+ public IActionResult UpdateUser(string id, string userId, [FromBody] DomainUser dto) =>
+ domainService.UpdateUser(id, userId, dto) ? Ok() : NotFound();
+
+ [HttpDelete("{id}/users/{userId}")]
+ public IActionResult DeleteUser(string id, string userId) =>
+ domainService.DeleteUser(id, userId) ? Ok() : NotFound();
+
+ // ── User aliases ───────────────────────────────────────────
+
+ [HttpPost("{id}/useraliases")]
+ public IActionResult AddUserAlias(string id, [FromBody] UserAlias dto)
+ {
+ if (string.IsNullOrWhiteSpace(dto.Alias)) return BadRequest("Alias is required.");
+ if (string.IsNullOrWhiteSpace(dto.Target)) return BadRequest("Target is required.");
+ return domainService.AddUserAlias(id, dto) ? Ok() : NotFound();
+ }
+
+ [HttpDelete("{id}/useraliases/{alias}")]
+ public IActionResult RemoveUserAlias(string id, string alias) =>
+ domainService.RemoveUserAlias(id, alias) ? Ok() : NotFound();
+
+ // ── Email lists ────────────────────────────────────────────
+
+ [HttpPost("{id}/lists")]
+ public IActionResult AddList(string id, [FromBody] ListNameDto dto)
+ {
+ if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name is required.");
+ var list = new EmailList { Name = dto.Name.Trim().ToLowerInvariant() };
+ return domainService.AddEmailList(id, list) ? Ok(list) : NotFound();
+ }
+
+ [HttpDelete("{id}/lists/{listId}")]
+ public IActionResult RemoveList(string id, string listId) =>
+ domainService.RemoveEmailList(id, listId) ? Ok() : NotFound();
+
+ [HttpPost("{id}/lists/{listId}/members")]
+ public IActionResult AddMember(string id, string listId, [FromBody] AliasValueDto dto)
+ {
+ if (string.IsNullOrWhiteSpace(dto.Value)) return BadRequest("Member is required.");
+ return domainService.AddListMember(id, listId, dto.Value) ? Ok() : NotFound();
+ }
+
+ [HttpDelete("{id}/lists/{listId}/members/{member}")]
+ public IActionResult RemoveMember(string id, string listId, string member) =>
+ domainService.RemoveListMember(id, listId, member) ? Ok() : NotFound();
+
+ // ── User stats ─────────────────────────────────────────────
+
+ [HttpGet("{id}/userstats")]
+ public IActionResult UserStats(string id)
+ {
+ var domain = domainService.GetById(id);
+ if (domain is null) return NotFound();
+
+ var storagePath = configuration["MailboxSettings:StoragePath"];
+ var stats = domain.Users.Select(u =>
+ {
+ var address = $"{u.Username}@{domain.Name}";
+ var mailbox = string.IsNullOrEmpty(storagePath) ? null : Path.Combine(storagePath, address);
+ var sizeMb = 0.0;
+ DateTime? lastActivity = null;
+
+ if (mailbox != null && Directory.Exists(mailbox))
+ {
+ var files = Directory.GetFiles(mailbox, "*.eml", SearchOption.AllDirectories);
+ sizeMb = files.Sum(f => new FileInfo(f).Length) / 1_048_576.0;
+ if (files.Length > 0)
+ lastActivity = files.Select(f => System.IO.File.GetLastWriteTimeUtc(f)).Max();
+ }
+
+ return new { userId = u.Id, sizeMb = Math.Round(sizeMb, 2), lastActivity };
+ });
+
+ return Ok(stats);
+ }
+}
+
+public class CreateDomainDto { public string Name { get; set; } = string.Empty; }
+public class GeneralDto { public string Name { get; set; } = string.Empty; public bool Enabled { get; set; } public string? CatchAll { get; set; } }
+public class AliasValueDto { public string Value { get; set; } = string.Empty; }
+public class ListNameDto { public string Name { get; set; } = string.Empty; }
diff --git a/MailSharp.WebManager/Controllers/StatusController.cs b/MailSharp.WebManager/Controllers/StatusController.cs
new file mode 100644
index 0000000..2c49af3
--- /dev/null
+++ b/MailSharp.WebManager/Controllers/StatusController.cs
@@ -0,0 +1,133 @@
+using MailSharp.IMAP.Metrics;
+using MailSharp.POP3.Metrics;
+using MailSharp.SMTP.Metrics;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MailSharp.WebManager.Controllers;
+
+[Authorize(Roles = "Administrator")]
+[Route("~/api/[controller]")]
+[ApiController]
+public class StatusController(SmtpMetrics smtpMetrics, ImapMetrics imapMetrics, Pop3Metrics pop3Metrics) : ControllerBase
+{
+ [HttpGet("all")]
+ public IActionResult StatusAll() => Ok(new
+ {
+ smtp = smtpMetrics.IsRunning,
+ imap = imapMetrics.IsRunning,
+ pop3 = pop3Metrics.IsRunning
+ });
+
+ [HttpGet("smtp")]
+ public IActionResult SmtpStatus()
+ {
+ var topSenders = smtpMetrics.TopSenderDomains
+ .OrderByDescending(x => x.Value).Take(10)
+ .Select(x => new { domain = x.Key, count = x.Value });
+
+ var topRecipients = smtpMetrics.TopRecipientDomains
+ .OrderByDescending(x => x.Value).Take(10)
+ .Select(x => new { domain = x.Key, count = x.Value });
+
+ return Ok(new
+ {
+ isRunning = smtpMetrics.IsRunning,
+ startTime = smtpMetrics.StartTime,
+ uptimeSeconds = smtpMetrics.UptimeSeconds,
+ connections = new
+ {
+ total = smtpMetrics.TotalConnections,
+ active = smtpMetrics.ActiveSessions
+ },
+ messages = new
+ {
+ received = smtpMetrics.MessagesReceived,
+ relayed = smtpMetrics.MessagesRelayed,
+ bytesReceived = smtpMetrics.BytesReceived,
+ lastReceivedAt = smtpMetrics.LastMessageReceivedAt
+ },
+ rejections = new
+ {
+ total = smtpMetrics.MessagesRejectedTotal,
+ spf = smtpMetrics.MessagesRejectedSpf,
+ dkim = smtpMetrics.MessagesRejectedDkim,
+ dmarc = smtpMetrics.MessagesRejectedDmarc
+ },
+ auth = new
+ {
+ success = smtpMetrics.AuthSuccess,
+ failed = smtpMetrics.AuthFailed
+ },
+ topSenders,
+ topRecipients
+ });
+ }
+
+ [HttpGet("imap")]
+ public IActionResult ImapStatus()
+ {
+ var topUsers = imapMetrics.TopUsers
+ .OrderByDescending(x => x.Value).Take(10)
+ .Select(x => new { user = x.Key, count = x.Value });
+
+ return Ok(new
+ {
+ isRunning = imapMetrics.IsRunning,
+ startTime = imapMetrics.StartTime,
+ uptimeSeconds = imapMetrics.UptimeSeconds,
+ connections = new
+ {
+ total = imapMetrics.TotalConnections,
+ active = imapMetrics.ActiveSessions
+ },
+ auth = new
+ {
+ success = imapMetrics.LoginSuccess,
+ failed = imapMetrics.LoginFailed
+ },
+ messages = new
+ {
+ fetched = imapMetrics.MessagesFetched,
+ bytesSent = imapMetrics.BytesSent,
+ lastActivityAt = imapMetrics.LastActivityAt
+ },
+ commands = imapMetrics.CommandsProcessed,
+ folderSelects = imapMetrics.FolderSelects,
+ topUsers
+ });
+ }
+
+ [HttpGet("pop3")]
+ public IActionResult Pop3Status()
+ {
+ var topUsers = pop3Metrics.TopUsers
+ .OrderByDescending(x => x.Value).Take(10)
+ .Select(x => new { user = x.Key, count = x.Value });
+
+ return Ok(new
+ {
+ isRunning = pop3Metrics.IsRunning,
+ startTime = pop3Metrics.StartTime,
+ uptimeSeconds = pop3Metrics.UptimeSeconds,
+ connections = new
+ {
+ total = pop3Metrics.TotalConnections,
+ active = pop3Metrics.ActiveSessions
+ },
+ auth = new
+ {
+ success = pop3Metrics.LoginSuccess,
+ failed = pop3Metrics.LoginFailed
+ },
+ messages = new
+ {
+ retrieved = pop3Metrics.MessagesRetrieved,
+ deleted = pop3Metrics.MessagesDeleted,
+ bytesSent = pop3Metrics.BytesSent,
+ lastActivityAt = pop3Metrics.LastActivityAt
+ },
+ topUsers
+ });
+ }
+}
diff --git a/MailSharp.WebManager/Extensions/DefaultExtensions.cs b/MailSharp.WebManager/Extensions/DefaultExtensions.cs
new file mode 100644
index 0000000..5d58ce2
--- /dev/null
+++ b/MailSharp.WebManager/Extensions/DefaultExtensions.cs
@@ -0,0 +1,67 @@
+using MailSharp.Common.Extensions;
+using MailSharp.IMAP.Extensions;
+using MailSharp.POP3.Extensions;
+using MailSharp.SMTP.Extensions;
+using MailSharp.WebManager.Services;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Mvc.Razor;
+
+namespace MailSharp.WebManager.Extensions;
+
+public static class DefaultExtensions
+{
+ // Adds SMTP server services to the specified IServiceCollection
+ public static IServiceCollection AddRazorUnderRoot(this IServiceCollection services)
+ {
+ services.AddRazorPages(o => o.RootDirectory = "/wwwroot");
+
+ services.Configure<RazorViewEngineOptions>(options =>
+ {
+ options.ViewLocationFormats.Clear();
+ options.ViewLocationFormats.Add("/wwwroot/{0}.cshtml");
+ options.ViewLocationFormats.Add("/wwwroot/MasterPages/{0}.cshtml");
+
+ options.PageViewLocationFormats.Clear();
+ options.PageViewLocationFormats.Add("/wwwroot/{0}.cshtml");
+ options.PageViewLocationFormats.Add("/wwwroot/MasterPages/{0}.cshtml");
+ });
+
+ return services;
+ }
+
+ public static IServiceCollection AddAuthenticationAndAddAuthorization(this IServiceCollection services)
+ {
+ services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddCookie(options =>
+ {
+ options.LoginPath = "/Account/Login";
+ options.AccessDeniedPath = "/Account/AccessDenied";
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
+ options.Cookie.SameSite = SameSiteMode.Strict;
+ });
+
+ services.AddAuthorization();
+
+ return services;
+ }
+
+
+ public static IServiceCollection AddMailSharpServices(this IServiceCollection services)
+ {
+ services.AddCommonServices();
+ services.AddSmtpServices();
+ services.AddPop3Services();
+ services.AddImapServices();
+ services.AddScoped<ConfigService>();
+ services.AddSingleton<DomainService>();
+ return services;
+ }
+
+ public static IApplicationBuilder UseAuthenticationAndAddAuthorization(this IApplicationBuilder app)
+ {
+ app.UseAuthentication();
+ app.UseAuthorization();
+ return app;
+ }
+}
\ No newline at end of file
diff --git a/MailSharp.WebManager/MailSharp.WebManager.csproj b/MailSharp.WebManager/MailSharp.WebManager.csproj
new file mode 100644
index 0000000..72dd20d
--- /dev/null
+++ b/MailSharp.WebManager/MailSharp.WebManager.csproj
@@ -0,0 +1,37 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Content Remove="wwwroot\Account\Login.cshtml.cs" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Compile Include="wwwroot\Account\Login.cshtml.cs" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\MailSharp.IMAP\MailSharp.IMAP.csproj" />
+ <ProjectReference Include="..\MailSharp.POP3\MailSharp.POP3.csproj" />
+ <ProjectReference Include="..\MailSharp.SMTP\MailSharp.SMTP.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Update="mailsharp.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="Services\" />
+ </ItemGroup>
+
+</Project>
diff --git a/MailSharp.WebManager/Program.cs b/MailSharp.WebManager/Program.cs
new file mode 100644
index 0000000..f9f8a8b
--- /dev/null
+++ b/MailSharp.WebManager/Program.cs
@@ -0,0 +1,48 @@
+using MailSharp.WebManager.Extensions;
+using MailSharp.WebManager.Services;
+
+var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+{
+ ContentRootPath = AppContext.BaseDirectory
+});
+
+builder.Configuration.AddJsonFile(
+ Path.Combine(AppContext.BaseDirectory, "mailsharp.json"),
+ optional: true,
+ reloadOnChange: true);
+
+builder.Services.AddMvcCore();
+
+builder.Services.AddRazorUnderRoot();
+
+builder.Services.AddControllersWithViews()
+ .AddJsonOptions(o => o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase);
+
+builder.Services.AddAuthenticationAndAddAuthorization();
+
+builder.Host.UseWindowsService();
+
+builder.Services.AddLogging(logging => logging.AddConsole());
+
+builder.Services.AddMailSharpServices();
+
+var app = builder.Build();
+
+// Seed the override file from appsettings.json defaults for any section that
+// is missing or was previously saved with empty values, then reload so the
+// seeded values are visible immediately (reloadOnChange is async).
+using (var scope = app.Services.CreateScope())
+ scope.ServiceProvider.GetRequiredService<ConfigService>().EnsureOverrideInitialized();
+(app.Configuration as IConfigurationRoot)?.Reload();
+
+app.UseDefaultFiles();
+
+app.UseStaticFiles();
+
+app.UseAuthenticationAndAddAuthorization();
+
+app.MapDefaultControllerRoute();
+
+app.MapRazorPages();
+
+await app.RunAsync();
diff --git a/MailSharp.WebManager/Properties/launchSettings.json b/MailSharp.WebManager/Properties/launchSettings.json
new file mode 100644
index 0000000..680a30a
--- /dev/null
+++ b/MailSharp.WebManager/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:13273",
+ "sslPort": 44320
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5045",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7202;http://localhost:5045",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/MailSharp.WebManager/Services/ConfigService.cs b/MailSharp.WebManager/Services/ConfigService.cs
new file mode 100644
index 0000000..d5c5a9b
--- /dev/null
+++ b/MailSharp.WebManager/Services/ConfigService.cs
@@ -0,0 +1,456 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace MailSharp.WebManager.Services;
+
+public class ConfigService(IWebHostEnvironment env)
+{
+ private static readonly JsonSerializerOptions Pretty = new() { WriteIndented = true };
+
+ private string OverridePath => Path.Combine(env.ContentRootPath, "mailsharp.json");
+
+ // ── Read ────────────────────────────────────────────────
+
+ public SmtpConfigDto GetSmtp() => MakeSmtpDto(BuildMerged());
+ public Pop3ConfigDto GetPop3() => MakePop3Dto(BuildMerged());
+ public ImapConfigDto GetImap() => MakeImapDto(BuildMerged());
+ public DmarcConfigDto GetDmarc() => MakeDmarcDto(BuildMerged());
+ public MailboxConfigDto GetMailbox() => MakeMailboxDto(BuildMerged());
+ public List<IpGroupDto> GetIpGroups() => MakeIpGroupsDto(BuildMerged());
+ public List<MaintenanceUserDto> GetMaintenanceUsers() => MakeMaintenanceUsersDto(BuildMerged());
+ public GeneralSettingsDto GetGeneral() => MakeGeneralDto(BuildMerged());
+
+ private IConfiguration BuildMerged()
+ {
+ return new ConfigurationBuilder()
+ .SetBasePath(env.ContentRootPath)
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddJsonFile("mailsharp.json", optional: true)
+ .Build();
+ }
+
+ private static SmtpConfigDto MakeSmtpDto(IConfiguration c) => new()
+ {
+ Enabled = c.GetValue<bool?>("SmtpSettings:Enabled") ?? true,
+ EmlStoragePath = c["SmtpSettings:EmlStoragePath"] ?? string.Empty,
+ CommandTimeoutSeconds = c.GetValue<int>("SmtpSettings:CommandTimeoutSeconds"),
+ BackLog = c.GetValue<int>("SmtpSettings:BackLog"),
+ EnableVrfy = c.GetValue<bool>("SmtpSettings:EnableVrfy"),
+ EnableExpn = c.GetValue<bool>("SmtpSettings:EnableExpn"),
+ RequireDkim = c.GetValue<bool>("SmtpSettings:RequireDkim"),
+ CertificatePath = c["SmtpSettings:CertificatePath"] ?? string.Empty,
+ CertificatePassword = c["SmtpSettings:CertificatePassword"] ?? string.Empty,
+ DnsResolvers = c.GetSection("SmtpSettings:DnsResolvers").Get<List<string>>() ?? [],
+ MaxConnections = c.GetValue<int>("SmtpSettings:MaxConnections"),
+ MaxMessageSizeKb = c.GetValue<int>("SmtpSettings:MaxMessageSizeKb"),
+ RetryCount = c.GetValue<int>("SmtpSettings:RetryCount"),
+ RetryIntervalMinutes = c.GetValue<int>("SmtpSettings:RetryIntervalMinutes"),
+ LocalHostName = c["SmtpSettings:LocalHostName"] ?? string.Empty,
+ RelayEnabled = c.GetValue<bool>("SmtpSettings:RelayEnabled"),
+ RelayHost = c["SmtpSettings:RelayHost"] ?? string.Empty,
+ RelayPort = c.GetValue<int>("SmtpSettings:RelayPort"),
+ RelayRequiresAuth = c.GetValue<bool>("SmtpSettings:RelayRequiresAuth"),
+ RelayUsername = c["SmtpSettings:RelayUsername"] ?? string.Empty,
+ RelayPassword = c["SmtpSettings:RelayPassword"] ?? string.Empty,
+ RelayConnectionSecurity = c["SmtpSettings:RelayConnectionSecurity"] ?? "None",
+ AllowPlainTextAuth = c.GetValue<bool>("SmtpSettings:AllowPlainTextAuth"),
+ AllowEmptySender = c.GetValue<bool>("SmtpSettings:AllowEmptySender"),
+ AllowBadLineEndings = c.GetValue<bool>("SmtpSettings:AllowBadLineEndings"),
+ DisconnectOnTooManyInvalidCommands = c.GetValue<bool>("SmtpSettings:DisconnectOnTooManyInvalidCommands"),
+ MaxInvalidCommands = c.GetValue<int>("SmtpSettings:MaxInvalidCommands"),
+ MaxRecipientsPerBatch = c.GetValue<int>("SmtpSettings:MaxRecipientsPerBatch"),
+ AddDeliveredToHeader = c.GetValue<bool>("SmtpSettings:AddDeliveredToHeader"),
+ RuleLoopLimit = c.GetValue<int>("SmtpSettings:RuleLoopLimit"),
+ MaxRecipientHosts = c.GetValue<int>("SmtpSettings:MaxRecipientHosts"),
+ RelayQueuePath = c["SmtpSettings:RelayQueuePath"] ?? string.Empty,
+ Ports = c.GetSection("SmtpSettings:Ports").Get<List<PortConfigDto>>() ?? []
+ };
+
+ private static Pop3ConfigDto MakePop3Dto(IConfiguration c) => new()
+ {
+ Enabled = c.GetValue<bool?>("Pop3Settings:Enabled") ?? true,
+ CertificatePath = c["Pop3Settings:CertificatePath"] ?? string.Empty,
+ CertificatePassword = c["Pop3Settings:CertificatePassword"] ?? string.Empty,
+ Ports = c.GetSection("Pop3Settings:Ports").Get<List<PortConfigDto>>() ?? [],
+ MaxConnections = c.GetValue<int>("Pop3Settings:MaxConnections"),
+ WelcomeMessage = c["Pop3Settings:WelcomeMessage"] ?? string.Empty
+ };
+
+ private static ImapConfigDto MakeImapDto(IConfiguration c) => new()
+ {
+ Enabled = c.GetValue<bool?>("ImapSettings:Enabled") ?? true,
+ CertificatePath = c["ImapSettings:CertificatePath"] ?? string.Empty,
+ CertificatePassword = c["ImapSettings:CertificatePassword"] ?? string.Empty,
+ Ports = c.GetSection("ImapSettings:Ports").Get<List<PortConfigDto>>() ?? [],
+ MaxConnections = c.GetValue<int>("ImapSettings:MaxConnections"),
+ WelcomeMessage = c["ImapSettings:WelcomeMessage"] ?? string.Empty,
+ PublicFolderName = c["ImapSettings:PublicFolderName"] ?? "# Public",
+ EnableSort = c.GetValue<bool>("ImapSettings:EnableSort"),
+ EnableQuota = c.GetValue<bool>("ImapSettings:EnableQuota"),
+ EnableIdle = c.GetValue<bool>("ImapSettings:EnableIdle"),
+ EnableAcl = c.GetValue<bool>("ImapSettings:EnableAcl"),
+ HierarchyDelimiter = c["ImapSettings:HierarchyDelimiter"] ?? "."
+ };
+
+ private static DmarcConfigDto MakeDmarcDto(IConfiguration c) => new()
+ {
+ FailOpen = c.GetValue<bool>("DmarcSettings:FailOpen"),
+ RequireDmarc = c.GetValue<bool>("DmarcSettings:RequireDmarc")
+ };
+
+ private static MailboxConfigDto MakeMailboxDto(IConfiguration c) => new()
+ {
+ StoragePath = c["MailboxSettings:StoragePath"] ?? string.Empty
+ };
+
+ private static List<IpGroupDto> MakeIpGroupsDto(IConfiguration c) =>
+ c.GetSection("IpGroups").Get<List<IpGroupDto>>() ?? [];
+
+ private static List<MaintenanceUserDto> MakeMaintenanceUsersDto(IConfiguration c) =>
+ c.GetSection("MaintenanceUsers").Get<List<MaintenanceUserDto>>() ?? [];
+
+ private static GeneralSettingsDto MakeGeneralDto(IConfiguration c) => new()
+ {
+ EmlStoragePath = c["SmtpSettings:EmlStoragePath"] ?? string.Empty,
+ CommandTimeoutSeconds = c.GetValue<int>("SmtpSettings:CommandTimeoutSeconds"),
+ BackLog = c.GetValue<int>("SmtpSettings:BackLog"),
+ DnsResolvers = c.GetSection("SmtpSettings:DnsResolvers").Get<List<string>>() ?? []
+ };
+
+ // ── Write ───────────────────────────────────────────────
+
+ public void SaveSmtpGeneral(SmtpGeneralDto dto) => PatchFields("SmtpSettings", dto);
+ public void SaveSmtpConnections(SmtpConnectionsDto dto) => PatchFields("SmtpSettings", dto);
+ public void SaveSmtpDelivery(SmtpDeliveryDto dto) => PatchFields("SmtpSettings", dto);
+ public void SaveSmtpRelay(SmtpRelayDto dto) => PatchFields("SmtpSettings", dto);
+ public void SaveSmtpSecurity(SmtpSecurityDto dto) => PatchFields("SmtpSettings", dto);
+ public void SaveSmtpLimits(SmtpLimitsDto dto) => PatchFields("SmtpSettings", dto);
+ public void SavePop3General(Pop3GeneralDto dto) => PatchFields("Pop3Settings", dto);
+ public void SavePop3Connections(Pop3ConnectionsDto dto) => PatchFields("Pop3Settings", dto);
+ public void SaveImapGeneral(ImapGeneralDto dto) => PatchFields("ImapSettings", dto);
+ public void SaveImapConnections(ImapConnectionsDto dto) => PatchFields("ImapSettings", dto);
+ public void SaveImapFolders(ImapFoldersDto dto) => PatchFields("ImapSettings", dto);
+ public void SaveImapAdvanced(ImapAdvancedDto dto) => PatchFields("ImapSettings", dto);
+ public void SaveDmarc(DmarcConfigDto dto) => PatchFields("DmarcSettings", dto);
+ public void SaveMailbox(MailboxConfigDto dto) => PatchFields("MailboxSettings", dto);
+ public void SaveIpGroups(List<IpGroupDto> dto) => PatchSection("IpGroups", dto);
+ public void SaveMaintenanceUsers(List<MaintenanceUserDto> dto) => PatchSection("MaintenanceUsers", dto);
+ public void SaveGeneral(GeneralSettingsDto dto) => PatchFields("SmtpSettings", dto);
+
+ // Merges only the fields present in dto into the existing section (no data loss).
+ private void PatchFields(string section, object dto)
+ {
+ var root = ReadOverride();
+ var existing = root[section]?.AsObject() ?? new JsonObject();
+ var patch = JsonNode.Parse(JsonSerializer.Serialize(dto, Pretty))!.AsObject();
+ foreach (var (key, value) in patch)
+ existing[key] = value?.DeepClone();
+ root[section] = existing;
+ File.WriteAllText(OverridePath, root.ToJsonString(Pretty));
+ }
+
+ // Replaces the entire section (used for arrays like IpGroups/MaintenanceUsers).
+ private void PatchSection(string key, object dto)
+ {
+ var root = ReadOverride();
+ root[key] = JsonNode.Parse(JsonSerializer.Serialize(dto, Pretty))!;
+ File.WriteAllText(OverridePath, root.ToJsonString(Pretty));
+ }
+
+ // ── Startup initialisation ──────────────────────────────
+
+ public void EnsureOverrideInitialized()
+ {
+ var root = ReadOverride();
+ bool dirty = false;
+
+ // Build an appsettings-only config so we always get the canonical defaults,
+ // even when the override file already exists but contains empty strings from
+ // a previous failed save.
+ var src = new ConfigurationBuilder()
+ .SetBasePath(env.ContentRootPath)
+ .AddJsonFile("appsettings.json", optional: false)
+ .Build();
+
+ void TrySeed(string section, string? probe, Func<IConfiguration, object> factory)
+ {
+ bool empty = probe is null
+ ? !root.ContainsKey(section)
+ : string.IsNullOrEmpty(root[section]?[probe]?.GetValue<string>());
+ if (empty)
+ {
+ root[section] = JsonNode.Parse(JsonSerializer.Serialize(factory(src), Pretty))!;
+ dirty = true;
+ }
+ }
+
+ TrySeed("SmtpSettings", "EmlStoragePath", c => MakeSmtpDto(c));
+ TrySeed("Pop3Settings", "CertificatePath", c => MakePop3Dto(c));
+ TrySeed("ImapSettings", "CertificatePath", c => MakeImapDto(c));
+ TrySeed("DmarcSettings", null, c => MakeDmarcDto(c));
+ TrySeed("MailboxSettings", "StoragePath", c => MakeMailboxDto(c));
+
+ if (!root.ContainsKey("MaintenanceUsers"))
+ {
+ var users = MakeMaintenanceUsersDto(src);
+ root["MaintenanceUsers"] = JsonNode.Parse(JsonSerializer.Serialize(users, Pretty))!;
+ dirty = true;
+ }
+
+ if (!root.ContainsKey("IpGroups"))
+ {
+ var groups = MakeIpGroupsDto(src);
+ root["IpGroups"] = JsonNode.Parse(JsonSerializer.Serialize(groups, Pretty))!;
+ dirty = true;
+ }
+
+ if (dirty) File.WriteAllText(OverridePath, root.ToJsonString(Pretty));
+ }
+
+ private JsonObject ReadOverride()
+ {
+ if (!File.Exists(OverridePath))
+ return [];
+ try { return JsonNode.Parse(File.ReadAllText(OverridePath))?.AsObject() ?? []; }
+ catch { return []; }
+ }
+}
+
+// ── DTOs ────────────────────────────────────────────────────
+
+public class PortConfigDto
+{
+ public string Host { get; set; } = string.Empty;
+ public int Port { get; set; }
+ public string Security { get; set; } = string.Empty;
+ public bool Enabled { get; set; } = true;
+}
+
+public class SmtpConfigDto
+{
+ public bool Enabled { get; set; } = true;
+ // General
+ public string EmlStoragePath { get; set; } = string.Empty;
+ public int CommandTimeoutSeconds { get; set; }
+ public int BackLog { get; set; }
+ public string CertificatePath { get; set; } = string.Empty;
+ public string CertificatePassword { get; set; } = string.Empty;
+ public List<string> DnsResolvers { get; set; } = [];
+ // Connections
+ public int MaxConnections { get; set; }
+ public int MaxMessageSizeKb { get; set; }
+ // Delivery
+ public int RetryCount { get; set; }
+ public int RetryIntervalMinutes { get; set; }
+ public string LocalHostName { get; set; } = string.Empty;
+ // Relay
+ public bool RelayEnabled { get; set; }
+ public string RelayHost { get; set; } = string.Empty;
+ public int RelayPort { get; set; }
+ public bool RelayRequiresAuth { get; set; }
+ public string RelayUsername { get; set; } = string.Empty;
+ public string RelayPassword { get; set; } = string.Empty;
+ public string RelayConnectionSecurity { get; set; } = "None";
+ public string RelayQueuePath { get; set; } = string.Empty;
+ // RFC compliance
+ public bool AllowPlainTextAuth { get; set; }
+ public bool AllowEmptySender { get; set; }
+ public bool AllowBadLineEndings { get; set; }
+ public bool DisconnectOnTooManyInvalidCommands { get; set; }
+ public int MaxInvalidCommands { get; set; }
+ // Advanced
+ public int MaxRecipientsPerBatch { get; set; }
+ public bool AddDeliveredToHeader { get; set; }
+ public int RuleLoopLimit { get; set; }
+ public int MaxRecipientHosts { get; set; }
+ // Auth / DKIM
+ public bool EnableVrfy { get; set; }
+ public bool EnableExpn { get; set; }
+ public bool RequireDkim { get; set; }
+ public List<PortConfigDto> Ports { get; set; } = [];
+}
+
+public class Pop3ConfigDto
+{
+ public bool Enabled { get; set; } = true;
+ public string CertificatePath { get; set; } = string.Empty;
+ public string CertificatePassword { get; set; } = string.Empty;
+ public List<PortConfigDto> Ports { get; set; } = [];
+ public int MaxConnections { get; set; }
+ public string WelcomeMessage { get; set; } = string.Empty;
+}
+
+public class ImapConfigDto
+{
+ public bool Enabled { get; set; } = true;
+ public string CertificatePath { get; set; } = string.Empty;
+ public string CertificatePassword { get; set; } = string.Empty;
+ public List<PortConfigDto> Ports { get; set; } = [];
+ public int MaxConnections { get; set; }
+ public string WelcomeMessage { get; set; } = string.Empty;
+ public string PublicFolderName { get; set; } = "# Public";
+ public bool EnableSort { get; set; }
+ public bool EnableQuota { get; set; }
+ public bool EnableIdle { get; set; }
+ public bool EnableAcl { get; set; }
+ public string HierarchyDelimiter { get; set; } = ".";
+}
+
+public class DmarcConfigDto
+{
+ public bool FailOpen { get; set; }
+ public bool RequireDmarc { get; set; }
+}
+
+public class MailboxConfigDto
+{
+ public string StoragePath { get; set; } = string.Empty;
+}
+
+public class IpAccessDto
+{
+ public bool Smtp { get; set; }
+ public bool Pop3 { get; set; }
+ public bool Imap { get; set; }
+ public bool AntiSpam { get; set; }
+ public bool AntiVirus { get; set; }
+ public bool RequireSslTlsForAuth { get; set; }
+}
+
+public class EmailFlowDto
+{
+ public bool Allowed { get; set; }
+ public bool RequireAuth { get; set; }
+}
+
+public class EmailFlowsDto
+{
+ public EmailFlowDto LocalToLocal { get; set; } = new();
+ public EmailFlowDto LocalToExternal { get; set; } = new();
+ public EmailFlowDto ExternalToLocal { get; set; } = new();
+ public EmailFlowDto ExternalToExternal { get; set; } = new();
+}
+
+public class GeneralSettingsDto
+{
+ public string EmlStoragePath { get; set; } = string.Empty;
+ public int CommandTimeoutSeconds { get; set; }
+ public int BackLog { get; set; }
+ public List<string> DnsResolvers { get; set; } = [];
+}
+
+public class MaintenanceUserDto
+{
+ public string UserName { get; set; } = string.Empty;
+ public string Password { get; set; } = string.Empty;
+ public string Role { get; set; } = "Unknown";
+ public int ExpireDays { get; set; } = 365;
+ public bool Enabled { get; set; } = true;
+}
+
+public class IpGroupDto
+{
+ public string Name { get; set; } = string.Empty;
+ public int Priority { get; set; }
+ public string Cidr { get; set; } = string.Empty;
+ public string? Expires { get; set; }
+ public IpAccessDto Access { get; set; } = new();
+ public EmailFlowsDto EmailFlows { get; set; } = new();
+}
+
+// ── Per-section write DTOs ───────────────────────────────────
+
+public class SmtpGeneralDto
+{
+ public bool Enabled { get; set; } = true;
+ public string LocalHostName { get; set; } = string.Empty;
+}
+
+public class SmtpConnectionsDto
+{
+ public int MaxConnections { get; set; }
+ public int MaxMessageSizeKb { get; set; }
+ public string CertificatePath { get; set; } = string.Empty;
+ public string CertificatePassword { get; set; } = string.Empty;
+ public List<PortConfigDto> Ports { get; set; } = [];
+}
+
+public class SmtpDeliveryDto
+{
+ public int RetryCount { get; set; }
+ public int RetryIntervalMinutes { get; set; }
+}
+
+public class SmtpRelayDto
+{
+ public bool RelayEnabled { get; set; }
+ public bool AddDeliveredToHeader { get; set; }
+ public string RelayHost { get; set; } = string.Empty;
+ public int RelayPort { get; set; }
+ public string RelayQueuePath { get; set; } = string.Empty;
+ public string RelayConnectionSecurity { get; set; } = "None";
+ public bool RelayRequiresAuth { get; set; }
+ public string RelayUsername { get; set; } = string.Empty;
+ public string RelayPassword { get; set; } = string.Empty;
+}
+
+public class SmtpSecurityDto
+{
+ public bool AllowPlainTextAuth { get; set; }
+ public bool AllowEmptySender { get; set; }
+ public bool AllowBadLineEndings { get; set; }
+ public bool DisconnectOnTooManyInvalidCommands { get; set; }
+ public int MaxInvalidCommands { get; set; }
+ public bool EnableVrfy { get; set; }
+ public bool EnableExpn { get; set; }
+ public bool RequireDkim { get; set; }
+}
+
+public class SmtpLimitsDto
+{
+ public int MaxRecipientsPerBatch { get; set; }
+ public int RuleLoopLimit { get; set; }
+ public int MaxRecipientHosts { get; set; }
+}
+
+public class Pop3GeneralDto
+{
+ public bool Enabled { get; set; } = true;
+ public string WelcomeMessage { get; set; } = string.Empty;
+}
+
+public class Pop3ConnectionsDto
+{
+ public int MaxConnections { get; set; }
+ public string CertificatePath { get; set; } = string.Empty;
+ public string CertificatePassword { get; set; } = string.Empty;
+ public List<PortConfigDto> Ports { get; set; } = [];
+}
+
+public class ImapGeneralDto
+{
+ public bool Enabled { get; set; } = true;
+ public string WelcomeMessage { get; set; } = string.Empty;
+}
+
+public class ImapConnectionsDto
+{
+ public int MaxConnections { get; set; }
+ public string CertificatePath { get; set; } = string.Empty;
+ public string CertificatePassword { get; set; } = string.Empty;
+ public List<PortConfigDto> Ports { get; set; } = [];
+}
+
+public class ImapFoldersDto
+{
+ public string PublicFolderName { get; set; } = "# Public";
+ public string HierarchyDelimiter { get; set; } = ".";
+}
+
+public class ImapAdvancedDto
+{
+ public bool EnableSort { get; set; }
+ public bool EnableQuota { get; set; }
+ public bool EnableIdle { get; set; }
+ public bool EnableAcl { get; set; }
+}
diff --git a/MailSharp.WebManager/Services/DomainService.cs b/MailSharp.WebManager/Services/DomainService.cs
new file mode 100644
index 0000000..2537af6
--- /dev/null
+++ b/MailSharp.WebManager/Services/DomainService.cs
@@ -0,0 +1,322 @@
+using System.Text.Json;
+
+namespace MailSharp.WebManager.Services;
+
+public class DomainConfig
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Name { get; set; } = string.Empty;
+ public bool Enabled { get; set; } = true;
+ public List<string> Aliases { get; set; } = [];
+ public List<DomainUser> Users { get; set; } = [];
+ public List<UserAlias> UserAliases { get; set; } = [];
+ public List<EmailList> EmailLists { get; set; } = [];
+ public DomainLimits Limits { get; set; } = new();
+ public DkimConfig Dkim { get; set; } = new();
+ public string CatchAll { get; set; } = string.Empty;
+}
+
+public class DomainUser
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Username { get; set; } = string.Empty; // local part only
+ public string Password { get; set; } = string.Empty;
+ public long MaxSizeMb { get; set; }
+}
+
+public class UserAlias
+{
+ public string Alias { get; set; } = string.Empty;
+ public string Target { get; set; } = string.Empty;
+}
+
+public class EmailList
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Name { get; set; } = string.Empty;
+ public List<string> Members { get; set; } = [];
+}
+
+public class DomainLimits
+{
+ public long MaxMailboxSizeKb { get; set; }
+ public long AllocatedSizeKb { get; set; }
+ public long MaxMessageSizeKb { get; set; }
+ public long MaxAccountsSizeKb { get; set; }
+}
+
+public class DkimConfig
+{
+ public bool Enabled { get; set; }
+ public string PrivateKeyFile{ get; set; } = string.Empty;
+ public string Selector { get; set; } = string.Empty;
+ public string HeaderMethod { get; set; } = "Relaxed";
+ public string BodyMethod { get; set; } = "Relaxed";
+ public string Algorithm { get; set; } = "SHA256";
+}
+
+public class DomainService(IWebHostEnvironment env)
+{
+ private static readonly JsonSerializerOptions Pretty = new() { WriteIndented = true };
+ private static readonly SemaphoreSlim _lock = new(1, 1);
+
+ private string FilePath => Path.Combine(env.ContentRootPath, "domains.json");
+
+ public List<DomainConfig> GetAll()
+ {
+ if (!File.Exists(FilePath)) return [];
+ try { return JsonSerializer.Deserialize<List<DomainConfig>>(File.ReadAllText(FilePath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; }
+ catch { return []; }
+ }
+
+ public DomainConfig? GetById(string id) => GetAll().FirstOrDefault(d => d.Id == id);
+
+ public DomainConfig Create(string name)
+ {
+ var domain = new DomainConfig { Name = Norm(name) };
+ Mutate(list => list.Add(domain));
+ return domain;
+ }
+
+ public bool Update(string id, DomainConfig patch)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.Name = Norm(patch.Name);
+ d.Enabled = patch.Enabled;
+ d.Aliases = patch.Aliases.Select(Norm).Where(a => a.Length > 0).Distinct().ToList();
+ d.Users = patch.Users;
+ d.UserAliases = patch.UserAliases;
+ d.EmailLists = patch.EmailLists;
+ d.Limits = patch.Limits;
+ d.Dkim = patch.Dkim;
+ d.CatchAll = patch.CatchAll.Trim();
+ found = true;
+ });
+ return found;
+ }
+
+ public bool Delete(string id)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ list.Remove(d);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool PatchGeneral(string id, string name, bool enabled, string catchAll)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.Name = Norm(name);
+ d.Enabled = enabled;
+ d.CatchAll = catchAll.Trim();
+ found = true;
+ });
+ return found;
+ }
+
+ public bool PatchLimits(string id, DomainLimits limits)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.Limits = limits;
+ found = true;
+ });
+ return found;
+ }
+
+ public bool PatchDkim(string id, DkimConfig dkim)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.Dkim = dkim;
+ found = true;
+ });
+ return found;
+ }
+
+ public bool AddAlias(string id, string alias)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ var n = Norm(alias);
+ if (!d.Aliases.Contains(n)) d.Aliases.Add(n);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool RemoveAlias(string id, string alias)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.Aliases.Remove(Norm(alias));
+ found = true;
+ });
+ return found;
+ }
+
+ public bool AddUser(string id, DomainUser user)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.Users.Add(user);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool UpdateUser(string id, string userId, DomainUser patch)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ var u = d?.Users.FirstOrDefault(x => x.Id == userId);
+ if (u is null) return;
+ u.Username = patch.Username.Trim().ToLowerInvariant();
+ u.Password = patch.Password;
+ u.MaxSizeMb = patch.MaxSizeMb;
+ found = true;
+ });
+ return found;
+ }
+
+ public bool DeleteUser(string id, string userId)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ var u = d.Users.FirstOrDefault(x => x.Id == userId);
+ if (u is null) return;
+ d.Users.Remove(u);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool AddUserAlias(string id, UserAlias alias)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.UserAliases.RemoveAll(a => a.Alias == alias.Alias);
+ d.UserAliases.Add(alias);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool RemoveUserAlias(string id, string alias)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.UserAliases.RemoveAll(a => a.Alias == alias);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool AddEmailList(string id, EmailList emailList)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.EmailLists.Add(emailList);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool RemoveEmailList(string id, string listId)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ if (d is null) return;
+ d.EmailLists.RemoveAll(l => l.Id == listId);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool AddListMember(string id, string listId, string member)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ var l = d?.EmailLists.FirstOrDefault(x => x.Id == listId);
+ if (l is null) return;
+ var m = member.Trim().ToLowerInvariant();
+ if (!l.Members.Contains(m)) l.Members.Add(m);
+ found = true;
+ });
+ return found;
+ }
+
+ public bool RemoveListMember(string id, string listId, string member)
+ {
+ bool found = false;
+ Mutate(list =>
+ {
+ var d = list.FirstOrDefault(x => x.Id == id);
+ var l = d?.EmailLists.FirstOrDefault(x => x.Id == listId);
+ if (l is null) return;
+ l.Members.Remove(member.Trim().ToLowerInvariant());
+ found = true;
+ });
+ return found;
+ }
+
+ private static string Norm(string s) => s.Trim().ToLowerInvariant();
+
+ private void Mutate(Action<List<DomainConfig>> action)
+ {
+ _lock.Wait();
+ try
+ {
+ var list = GetAll();
+ action(list);
+ File.WriteAllText(FilePath, JsonSerializer.Serialize(list, Pretty));
+ }
+ finally { _lock.Release(); }
+ }
+}
diff --git a/MailSharp.WebManager/_install.cmd b/MailSharp.WebManager/_install.cmd
new file mode 100644
index 0000000..68d466a
--- /dev/null
+++ b/MailSharp.WebManager/_install.cmd
@@ -0,0 +1,55 @@
+@echo off
+setlocal
+
+net session >nul 2>&1
+if %ERRORLEVEL% neq 0 (
+ echo ERROR: Run this script as Administrator.
+ pause
+ exit /b 1
+)
+
+set SERVICE_NAME=MailSharp
+set EXE_PATH=%~dp0MailSharp.WebManager.exe
+
+if not exist "%EXE_PATH%" (
+ echo ERROR: Executable not found: %EXE_PATH%
+ pause
+ exit /b 1
+)
+
+echo Stopping existing service (if running)...
+sc stop %SERVICE_NAME% >nul 2>&1
+timeout /t 3 /nobreak >nul
+
+echo Removing existing service (if present)...
+sc delete %SERVICE_NAME% >nul 2>&1
+timeout /t 2 /nobreak >nul
+
+echo Creating service...
+sc create %SERVICE_NAME% binPath= "\"%EXE_PATH%\"" DisplayName= "MailSharp" start= delayed-auto
+if %ERRORLEVEL% neq 0 (
+ echo ERROR: Failed to create service.
+ pause
+ exit /b 1
+)
+
+echo Configuring service dependencies...
+sc config %SERVICE_NAME% depend= Eventlog/RPCSS/Dhcp
+
+echo Configuring failure actions (restart after 60s, three times)...
+sc failure %SERVICE_NAME% reset= 86400 actions= restart/60000/restart/60000/restart/60000
+
+echo Setting service start timeout...
+REG ADD "HKLM\SYSTEM\CurrentControlSet\Control" /v ServicesPipeTimeout /t REG_DWORD /d 120000 /f >nul
+
+echo Starting service...
+sc start %SERVICE_NAME%
+if %ERRORLEVEL% neq 0 (
+ echo WARNING: Service created but failed to start. Check Event Viewer for details.
+ pause
+ exit /b 1
+)
+
+echo.
+echo Service "%SERVICE_NAME%" installed and started successfully.
+pause
diff --git a/MailSharp.WebManager/_uninstall.cmd b/MailSharp.WebManager/_uninstall.cmd
new file mode 100644
index 0000000..485c33f
--- /dev/null
+++ b/MailSharp.WebManager/_uninstall.cmd
@@ -0,0 +1,7 @@
+@echo off
+
+sc stop MailSharp
+
+sc delete MailSharp
+
+pause
diff --git a/MailSharp.WebManager/appsettings.Development.json b/MailSharp.WebManager/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/MailSharp.WebManager/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/MailSharp.WebManager/appsettings.json b/MailSharp.WebManager/appsettings.json
new file mode 100644
index 0000000..9c6b49c
--- /dev/null
+++ b/MailSharp.WebManager/appsettings.json
@@ -0,0 +1,333 @@
+{
+ "SmtpResponses": {
+ "Ready": "220 SMTP Server Ready",
+ "CommandNotRecognized": "500 Command not recognized",
+ "BadSequence": "503 Bad sequence of commands",
+ "SyntaxError": "501 Syntax error in parameters",
+ "Hello": "250 Hello",
+ "EhloSupport": "250-SMTP Server supports EHLO",
+ "EhloSizeFormat": "250 SIZE {0}",
+ "Ok": "250 OK",
+ "HeaderStart": "354 Start mail input; end with <CRLF>.<CRLF>",
+ "MessageAccepted": "250 OK: Message accepted for delivery",
+ "Quit": "221 Bye",
+ "Timeout": "421 Timeout waiting for command",
+ "Shutdown": "421 Service not available, closing transmission channel",
+ "VrfySuccess": "252 Cannot verify user",
+ "VrfyDisabled": "502 VRFY command disabled",
+ "ExpnDisabled": "502 EXPN command disabled",
+ "Help": "214 Help: Supported commands: HELO, EHLO, MAIL, RCPT, DATA, QUIT, NOOP, RSET, VRFY, EXPN, HELP, AUTH, STARTTLS",
+ "AuthLoginUsernamePrompt": "334 VXNlcm5hbWU6",
+ "AuthLoginPasswordPrompt": "334 UGFzc3dvcmQ6",
+ "AuthSuccess": "235 Authentication succeeded",
+ "AuthFailed": "535 Authentication failed",
+ "StartTls": "220 Ready to start TLS",
+ "StartTlsFailed": "454 TLS not available",
+ "StartTlsInvalid": "502 TLS already active",
+ "CramMd5Challenge": "334 {0}",
+ "AuthSupport": "250-AUTH PLAIN CRAM-MD5 LOGIN",
+ "StartTlsSupport": "250-STARTTLS",
+ "DkimFailed": "550 DKIM verification failed",
+ "DmarcFailed": "550 DMARC policy check failed",
+ "InvalidHeaders": "554 Invalid headers: From, To, and Date required",
+ "SpamDetected": "554 Message rejected: Suspected spam",
+ "VrfyFailed": "550 User not found"
+ },
+ "SmtpLogMessages": {
+ "ServerStarted": "SMTP Server started on {0} security {1}",
+ "ServerStartFailed": "Failed to start listener on port {0}",
+ "ClientAccepted": "Accepted client connection from {0}",
+ "ListenerStopped": "Listener on {0} stopped due to cancellation",
+ "ClientAcceptError": "Error accepting client on {0}",
+ "SessionStarted": "New SMTP session started for session {0} from {1}",
+ "CommandReceived": "Received command {0} for session {1}",
+ "HeaderEnd": "Header end for session {0}",
+ "DataEnd": "Data end for session {0}",
+ "ClientDisconnected": "Session {0} disconnected",
+ "SessionEndedByQuit": "Session {0} ended by QUIT command",
+ "UnrecognizedCommand": "Unrecognized command {0} for session {1}",
+ "SessionTerminated": "Session {1} terminated due to {0}",
+ "CommandProcessingError": "Error processing command for session {0}",
+ "ServiceStarting": "Starting SMTP server service",
+ "ServiceStopping": "Stopping SMTP server service",
+ "ServiceError": "Error running SMTP server"
+ },
+ "Pop3LogMessages": {
+ "ServerStarted": "POP3 server started on {0} security {1}",
+ "ServerStartFailed": "Failed to start POP3 server on port {0}: {1}",
+ "ClientAccepted": "Accepted POP3 client connection from {0}",
+ "ListenerStopped": "POP3 listener stopped on {0}",
+ "ClientAcceptError": "Error accepting POP3 client on {0}: {1}",
+ "SessionError": "Error in POP3 session for client {0}: {1}",
+ "ServiceStarting": "POP3 service starting",
+ "ServiceError": "Error in POP3 service: {0}",
+ "ServiceStopping": "POP3 service stopping",
+ "TlsInitialized": "TLS initialized for POP3 session from {0}",
+ "TlsInitializationFailed": "Failed to initialize TLS for POP3 session from {0}: {1}"
+ },
+ "ImapLogMessages": {
+ "ServerStarted": "IMAP server started on {0} security {1}",
+ "ServerStartFailed": "Failed to start IMAP server on port {0}: {1}",
+ "ClientAccepted": "Accepted IMAP client connection from {0}",
+ "ListenerStopped": "IMAP listener stopped on {0}",
+ "ClientAcceptError": "Error accepting IMAP client on {0}: {1}",
+ "SessionError": "Error in IMAP session for client {0}: {1}",
+ "ServiceStarting": "IMAP service starting",
+ "ServiceError": "Error in IMAP service: {0}",
+ "ServiceStopping": "IMAP service stopping",
+ "TlsInitialized": "TLS initialized for IMAP session from {0}",
+ "TlsInitializationFailed": "Failed to initialize TLS for IMAP session from {0}: {1}"
+ },
+ "AuthLogMessages": {
+ "AuthenticationAttempt": "Authentication attempt for user {0}",
+ "AuthenticationSuccess": "Authentication successful for user {0}",
+ "AuthenticationFailed": "Authentication failed for user {0}"
+ },
+ "MailboxLogMessages": {
+ "MessagesRequested": "Messages requested for user {0}",
+ "MessageDeletion": "Message {1} deletion requested for user {0}",
+ "MessagesRetrievalFailed": "Failed to retrieve messages for user {0}: {1}",
+ "MessageNotFound": "Message {1} not found for user {0}",
+ "MessageDeletionFailed": "Failed to delete message {1} for user {0}: {2}",
+ "FolderCreation": "Creating folder {1} for user {0}",
+ "FolderExists": "Folder {1} already exists for user {0}",
+ "FolderCreationFailed": "Failed to create folder {1} for user {0}: {2}",
+ "FolderDeletion": "Deleting folder {1} for user {0}",
+ "FolderNotFound": "Folder {1} not found for user {0}",
+ "FolderDeletionFailed": "Failed to delete folder {1} for user {0}: {2}",
+ "FolderList": "Listing folders for user {0}",
+ "FolderListFailed": "Failed to list folders for user {0}: {1}",
+ "FlagUpdate": "Updating flags for message {1} in folder {2} for user {0}",
+ "FlagUpdateFailed": "Failed to update flags for message {1} in folder {2} for user {0}: {3}",
+ "MessageFetch": "Fetching message {1} in folder {2} for user {0}",
+ "MessageFetchFailed": "Failed to fetch message {1} in folder {2} for user {0}: {3}"
+ },
+ "DmarcLogMessages": {
+ "DmarcCheck": "DMARC check initiated for domain {0}",
+ "NoDmarcRecord": "No DMARC record found for domain {0}",
+ "InvalidDmarcRecord": "Invalid DMARC record for domain {0}",
+ "MissingPolicy": "DMARC record missing policy for domain {0}",
+ "PolicyFailed": "DMARC policy {0} failed for domain {1}",
+ "UnknownPolicy": "Unknown DMARC policy {0} for domain {1}"
+ },
+ "SmtpEventIds": {
+ "SessionStarted": {
+ "Id": 1000,
+ "Name": "SessionStarted"
+ },
+ "CommandReceived": {
+ "Id": 1001,
+ "Name": "CommandReceived"
+ },
+ "HeaderEnd": {
+ "Id": 1002,
+ "Name": "HeaderEnd"
+ },
+ "DataEnd": {
+ "Id": 1003,
+ "Name": "DataEnd"
+ },
+ "ClientDisconnected": {
+ "Id": 104,
+ "Name": "ClientDisconnected"
+ },
+ "SessionEndedByQuit": {
+ "Id": 1005,
+ "Name": "SessionEndedByQuit"
+ },
+ "UnrecognizedCommand": {
+ "Id": 1006,
+ "Name": "UnrecognizedCommand"
+ },
+ "SessionTerminated": {
+ "Id": 1007,
+ "Name": "SessionTerminated"
+ },
+ "CommandProcessingError": {
+ "Id": 1008,
+ "Name": "CommandProcessingError"
+ },
+ "ServiceStarting": {
+ "Id": 2000,
+ "Name": "ServiceStarting"
+ },
+ "ServiceStopping": {
+ "Id": 2001,
+ "Name": "ServiceStopping"
+ },
+ "ServiceError": {
+ "Id": 2002,
+ "Name": "ServiceError"
+ },
+ "ServerStarted": {
+ "Id": 3000,
+ "Name": "ServerStarted"
+ },
+ "ServerStartFailed": {
+ "Id": 3001,
+ "Name": "ServerStartFailed"
+ },
+ "ClientAccepted": {
+ "Id": 3002,
+ "Name": "ClientAccepted"
+ },
+ "ListenerStopped": {
+ "Id": 3003,
+ "Name": "ListenerStopped"
+ },
+ "ClientAcceptError": {
+ "Id": 3004,
+ "Name": "ClientAcceptError"
+ }
+ },
+ "Pop3EventIds": {
+ "ServerStarted": {
+ "Id": 1001,
+ "Name": "ServerStarted"
+ },
+ "ServerStartFailed": {
+ "Id": 1002,
+ "Name": "ServerStartFailed"
+ },
+ "ClientAccepted": {
+ "Id": 1003,
+ "Name": "ClientAccepted"
+ },
+ "ListenerStopped": {
+ "Id": 1004,
+ "Name": "ListenerStopped"
+ },
+ "ClientAcceptError": {
+ "Id": 1005,
+ "Name": "ClientAcceptError"
+ },
+ "SessionError": {
+ "Id": 1006,
+ "Name": "SessionError"
+ },
+ "ServiceStarting": {
+ "Id": 1007,
+ "Name": "ServiceStarting"
+ },
+ "ServiceError": {
+ "Id": 1008,
+ "Name": "ServiceError"
+ },
+ "ServiceStopping": {
+ "Id": 1009,
+ "Name": "ServiceStopping"
+ },
+ "TlsInitialized": {
+ "Id": 1010,
+ "Name": "TlsInitialized"
+ },
+ "TlsInitializationFailed": {
+ "Id": 1011,
+ "Name": "TlsInitializationFailed"
+ }
+ },
+ "ImapEventIds": {
+ "ServerStarted": {
+ "Id": 2001,
+ "Name": "ServerStarted"
+ },
+ "ServerStartFailed": {
+ "Id": 2002,
+ "Name": "ServerStartFailed"
+ },
+ "ClientAccepted": {
+ "Id": 2003,
+ "Name": "ClientAccepted"
+ },
+ "ListenerStopped": {
+ "Id": 2004,
+ "Name": "ListenerStopped"
+ },
+ "ClientAcceptError": {
+ "Id": 2005,
+ "Name": "ClientAcceptError"
+ },
+ "SessionError": {
+ "Id": 2006,
+ "Name": "SessionError"
+ },
+ "ServiceStarting": {
+ "Id": 2007,
+ "Name": "ServiceStarting"
+ },
+ "ServiceError": {
+ "Id": 2008,
+ "Name": "ServiceError"
+ },
+ "ServiceStopping": {
+ "Id": 2009,
+ "Name": "ServiceStopping"
+ },
+ "TlsInitialized": {
+ "Id": 2010,
+ "Name": "TlsInitialized"
+ },
+ "TlsInitializationFailed": {
+ "Id": 2011,
+ "Name": "TlsInitializationFailed"
+ }
+ },
+ "AuthEventIds": {
+ "AuthenticationAttempt": {
+ "Id": 3001,
+ "Name": "AuthenticationAttempt"
+ },
+ "AuthenticationSuccess": {
+ "Id": 3002,
+ "Name": "AuthenticationSuccess"
+ },
+ "AuthenticationFailed": {
+ "Id": 3003,
+ "Name": "AuthenticationFailed"
+ }
+ },
+ "MailboxEventIds": {
+ "MessagesRequested": {
+ "Id": 4001,
+ "Name": "MessagesRequested"
+ },
+ "MessageDeletion": {
+ "Id": 4002,
+ "Name": "MessageDeletion"
+ },
+ "FolderCreation": {
+ "Id": 4003,
+ "Name": "FolderCreation"
+ },
+ "FolderDeletion": {
+ "Id": 4004,
+ "Name": "FolderDeletion"
+ },
+ "FolderList": {
+ "Id": 4005,
+ "Name": "FolderList"
+ },
+ "FlagUpdate": {
+ "Id": 4006,
+ "Name": "FlagUpdate"
+ },
+ "MessageFetch": {
+ "Id": 4007,
+ "Name": "MessageFetch"
+ }
+ },
+ "DmarcEventIds": {
+ "DmarcCheck": {
+ "Id": 5001,
+ "Name": "DmarcCheck"
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "MailSharp.Smtp": "Debug",
+ "Microsoft.AspNetCore": "None",
+ "Microsoft.Hosting": "None"
+ }
+ }
+}
diff --git a/MailSharp.WebManager/mailsharp.json b/MailSharp.WebManager/mailsharp.json
new file mode 100644
index 0000000..6101190
--- /dev/null
+++ b/MailSharp.WebManager/mailsharp.json
@@ -0,0 +1,208 @@
+{
+ "IpGroups": [
+ {
+ "Name": "LocalNetwork",
+ "Priority": 1,
+ "Cidr": "127.0.0.1/32",
+ "Expires": null,
+ "Access": {
+ "Smtp": true,
+ "Pop3": true,
+ "Imap": true,
+ "AntiSpam": false,
+ "AntiVirus": false,
+ "RequireSslTlsForAuth": false
+ },
+ "EmailFlows": {
+ "LocalToLocal": {
+ "Allowed": true,
+ "RequireAuth": false
+ },
+ "LocalToExternal": {
+ "Allowed": true,
+ "RequireAuth": false
+ },
+ "ExternalToLocal": {
+ "Allowed": true,
+ "RequireAuth": false
+ },
+ "ExternalToExternal": {
+ "Allowed": true,
+ "RequireAuth": false
+ }
+ }
+ },
+ {
+ "Name": "TrustedLan",
+ "Priority": 2,
+ "Cidr": "192.168.0.0/16",
+ "Expires": "2026-01-01T00:00:00Z",
+ "Access": {
+ "Smtp": true,
+ "Pop3": true,
+ "Imap": true,
+ "AntiSpam": true,
+ "AntiVirus": true,
+ "RequireSslTlsForAuth": true
+ },
+ "EmailFlows": {
+ "LocalToLocal": {
+ "Allowed": true,
+ "RequireAuth": false
+ },
+ "LocalToExternal": {
+ "Allowed": true,
+ "RequireAuth": false
+ },
+ "ExternalToLocal": {
+ "Allowed": true,
+ "RequireAuth": false
+ },
+ "ExternalToExternal": {
+ "Allowed": true,
+ "RequireAuth": true
+ }
+ }
+ },
+ {
+ "Name": "Remote",
+ "Priority": 3,
+ "Cidr": "0.0.0.0/0",
+ "Expires": null,
+ "Access": {
+ "Smtp": true,
+ "Pop3": false,
+ "Imap": true,
+ "AntiSpam": true,
+ "AntiVirus": true,
+ "RequireSslTlsForAuth": true
+ },
+ "EmailFlows": {
+ "LocalToLocal": {
+ "Allowed": false,
+ "RequireAuth": true
+ },
+ "LocalToExternal": {
+ "Allowed": false,
+ "RequireAuth": true
+ },
+ "ExternalToLocal": {
+ "Allowed": true,
+ "RequireAuth": false
+ },
+ "ExternalToExternal": {
+ "Allowed": false,
+ "RequireAuth": true
+ }
+ }
+ }
+ ],
+ "MaintenanceUsers": [
+ {
+ "UserName": "admin",
+ "Password": "admin",
+ "Role": "Administrator",
+ "ExpireDays": 365
+ }
+ ],
+ "SmtpSettings": {
+ "Ports": [
+ {
+ "Host": "0.0.0.0",
+ "Port": 25,
+ "Security": "StartTlsOptional"
+ },
+ {
+ "Host": "0.0.0.0",
+ "Port": 587,
+ "Security": "StartTls"
+ },
+ {
+ "Host": "0.0.0.0",
+ "Port": 465,
+ "Security": "Tls"
+ }
+ ],
+ "BackLog": 100,
+ "MaxMessageSize": 10485760,
+ "CommandTimeoutSeconds": 60,
+ "EmlStoragePath": "Emails",
+ "EnableVrfy": false,
+ "EnableExpn": false,
+ "CertificatePath": "certificate.pfx",
+ "CertificatePassword": "PFXPassword",
+ "RequireDkim": false,
+ "DnsResolvers": [ "8.8.8.8", "8.8.4.4" ],
+ "MaxConnections": 0,
+ "MaxMessageSizeKb": 20480,
+ "RetryCount": 4,
+ "RetryIntervalMinutes": 60,
+ "LocalHostName": "",
+ "RelayEnabled": false,
+ "RelayHost": "",
+ "RelayPort": 25,
+ "RelayRequiresAuth": false,
+ "RelayUsername": "",
+ "RelayPassword": "",
+ "RelayConnectionSecurity": "None",
+ "RelayQueuePath": "RelayQueue",
+ "AllowPlainTextAuth": false,
+ "AllowEmptySender": true,
+ "AllowBadLineEndings": true,
+ "DisconnectOnTooManyInvalidCommands": false,
+ "MaxInvalidCommands": 100,
+ "MaxRecipientsPerBatch": 100,
+ "AddDeliveredToHeader": false,
+ "RuleLoopLimit": 5,
+ "MaxRecipientHosts": 15
+ },
+ "Pop3Settings": {
+ "Ports": [
+ {
+ "Host": "0.0.0.0",
+ "Port": 110,
+ "Security": "None"
+ },
+ {
+ "Host": "0.0.0.0",
+ "Port": 995,
+ "Security": "Tls"
+ }
+ ],
+ "CertificatePath": "certificate.pfx",
+ "CertificatePassword": "yourpassword",
+ "MaxConnections": 0,
+ "WelcomeMessage": ""
+ },
+ "ImapSettings": {
+ "Ports": [
+ {
+ "Host": "0.0.0.0",
+ "Port": 143,
+ "Security": "StartTls"
+ },
+ {
+ "Host": "0.0.0.0",
+ "Port": 993,
+ "Security": "Tls"
+ }
+ ],
+ "CertificatePath": "certificate.pfx",
+ "CertificatePassword": "yourpassword",
+ "MaxConnections": 0,
+ "WelcomeMessage": "",
+ "PublicFolderName": "# Public",
+ "EnableSort": true,
+ "EnableQuota": true,
+ "EnableIdle": true,
+ "EnableAcl": true,
+ "HierarchyDelimiter": "."
+ },
+ "DmarcSettings": {
+ "FailOpen": true,
+ "RequireDmarc": false
+ },
+ "MailboxSettings": {
+ "StoragePath": "Mailboxes"
+ }
+}
diff --git a/MailSharp.WebManager/wwwroot/Account/AccessDenied.cshtml b/MailSharp.WebManager/wwwroot/Account/AccessDenied.cshtml
new file mode 100644
index 0000000..4cf040a
--- /dev/null
+++ b/MailSharp.WebManager/wwwroot/Account/AccessDenied.cshtml
@@ -0,0 +1,23 @@
+@model dynamic
+@{
+ ViewData["Title"] = "Access Denied – MailSharp";
+}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>@ViewData["Title"]</title>
+ <link rel="stylesheet" href="~/styles/mailsharp.css">
+</head>
+<body>
+ <div class="auth-page">
+ <div class="auth-card denied-card">
+ <div class="denied-card__icon">🔒</div>
+ <div class="denied-card__title">Access Denied</div>
+ <div class="denied-card__text">You do not have permission to access this resource.</div>
+ <a href="/" class="btn-primary" style="display:block;text-align:center;">Return to Dashboard</a>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/MailSharp.WebManager/wwwroot/Account/Login.cshtml b/MailSharp.WebManager/wwwroot/Account/Login.cshtml
new file mode 100644
index 0000000..6ea9f19
--- /dev/null
+++ b/MailSharp.WebManager/wwwroot/Account/Login.cshtml
@@ -0,0 +1,38 @@
+@{
+ ViewData["Title"] = "Login – MailSharp";
+}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>@ViewData["Title"]</title>
+ <link rel="stylesheet" href="~/styles/mailsharp.css">
+</head>
+<body>
+ <div class="auth-page">
+ <div class="auth-card">
+ <div class="auth-card__logo">MS</div>
+ <div class="auth-card__title">MailSharp Manager</div>
+ <div class="auth-card__sub">Sign in to your account</div>
+
+ @if (!string.IsNullOrEmpty(TempData["ErrorMessage"] as string))
+ {
+ <div class="auth-error">@TempData["ErrorMessage"]</div>
+ }
+
+ <form method="post" action="/Account/Login">
+ <div class="form-group">
+ <label for="username">Username</label>
+ <input type="text" id="username" name="username" autocomplete="username" required autofocus>
+ </div>
+ <div class="form-group">
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" autocomplete="current-password" required>
+ </div>
+ <button type="submit" class="btn-primary">Sign in</button>
+ </form>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/MailSharp.WebManager/wwwroot/Account/Login.cshtml.cs b/MailSharp.WebManager/wwwroot/Account/Login.cshtml.cs
new file mode 100644
index 0000000..67f44ee
--- /dev/null
+++ b/MailSharp.WebManager/wwwroot/Account/Login.cshtml.cs
@@ -0,0 +1,70 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Mvc;
+using System.Security.Claims;
+
+namespace MailSharp.WebManager.wwwroot.Account;
+
+public class LoginController(IConfiguration configuration) : Controller
+{
+
+ [HttpGet("~/Account/Login")]
+ public IActionResult Index() => View("/wwwroot/Account/Login.cshtml");
+
+ [HttpGet("~/Account/AccessDenied")]
+ public IActionResult AccessDenied() => View("/wwwroot/Account/AccessDenied.cshtml");
+
+ [HttpGet("~/Account/Logout")]
+ public async Task<IActionResult> Logout()
+ {
+ await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+
+ return RedirectToAction("Index", "Login");
+ }
+
+ // User configuration model for appsettings.json
+ public class UserConfig
+ {
+ public string? UserName { get; set; }
+ public string? Password { get; set; }
+ public string? Role { get; set; }
+ public int ExpireDays { get; set; } = 365;
+ }
+
+ [HttpPost("~/Account/Login")]
+ public async Task<IActionResult> Login()
+ {
+ var username = Request.Form["username"];
+ var password = Request.Form["password"];
+
+ // Load users from appsettings.json
+ var users = configuration.GetSection("MaintenanceUsers").Get<List<UserConfig>>();
+ var user = users?.FirstOrDefault(u => u.UserName == username && u.Password == password);
+
+ TempData["ErrorMessage"] = "";
+
+ if (user != null)
+ {
+ // Add claims including role
+ List<Claim> claims =
+ [
+ new Claim(ClaimTypes.Name, username!),
+ new Claim("CustomClaim", "UserRole"),
+ new Claim(ClaimTypes.Role, user.Role ?? "User") // Add role claim
+ ];
+ var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
+ var authProperties = new AuthenticationProperties
+ {
+ IsPersistent = true,
+ ExpiresUtc = DateTimeOffset.UtcNow.AddDays(user.ExpireDays)
+ };
+
+ await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), authProperties);
+
+ return Redirect("/");
+ }
+ TempData["ErrorMessage"] = "Invalid login attempt";
+
+ return RedirectToAction("Index", "Login");
+ }
+}
diff --git a/MailSharp.WebManager/wwwroot/Default.js b/MailSharp.WebManager/wwwroot/Default.js
new file mode 100644
index 0000000..a3f64b1
--- /dev/null
+++ b/MailSharp.WebManager/wwwroot/Default.js
@@ -0,0 +1,1986 @@
+// ── Helpers ────────────────────────────────────────────────
+
+function $id(id) { return document.getElementById(id); }
+function $$(sel) { return Array.from(document.querySelectorAll(sel)); }
+
+function esc(str)
+{
+ return String(str ?? '').replace(/[&<>"']/g, c =>
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
+
+async function apiFetch(url, data, method)
+{
+ let opts;
+ if (method === 'DELETE')
+ opts = { method: 'DELETE' };
+ else if (data != null)
+ opts = { method: method ?? 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) };
+ else
+ opts = { method: method ?? 'GET' };
+ const res = await fetch(url, opts);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const ct = res.headers.get('content-type') ?? '';
+ if (res.status === 204 || !ct.includes('application/json')) return null;
+ return res.json();
+}
+
+// ── Init ───────────────────────────────────────────────────
+
+document.addEventListener('DOMContentLoaded', () =>
+{
+ initTheme();
+
+ if (!$id('tab-smtp')) return;
+
+ initTabs();
+ refreshAll();
+ setInterval(refreshAll, 5000);
+ initDomains();
+});
+
+// ── Theme ──────────────────────────────────────────────────
+
+function initTheme()
+{
+ const dark = localStorage.getItem('theme') === 'dark';
+ applyTheme(dark);
+ const btn = $id('theme-toggle');
+ if (btn) btn.addEventListener('click', () =>
+ {
+ const isDark = document.body.classList.toggle('dark');
+ localStorage.setItem('theme', isDark ? 'dark' : 'light');
+ btn.textContent = isDark ? '☾' : '☀';
+ });
+}
+
+function applyTheme(dark)
+{
+ document.body.classList.toggle('dark', dark);
+ const btn = $id('theme-toggle');
+ if (btn) btn.textContent = dark ? '☾' : '☀';
+}
+
+// ── Tabs ───────────────────────────────────────────────────
+
+function initTabs()
+{
+ $$('.tab-btn').forEach(btn => btn.addEventListener('click', function ()
+ {
+ $$('.tab-btn').forEach(b => b.classList.remove('active'));
+ $$('.tab-panel').forEach(p => p.classList.remove('active'));
+ this.classList.add('active');
+ $id(this.dataset.panel).classList.add('active');
+ }));
+
+ $$('.inline-settings__toggle').forEach(btn => btn.addEventListener('click', function ()
+ {
+ const body = $id(this.dataset.target);
+ const wrap = this.closest('.inline-settings');
+ const opening = !wrap.classList.contains('open');
+ wrap.classList.toggle('open');
+ if (opening && body && (body.innerHTML.trim() === '' || !_cfg))
+ loadInlineSettings(this.dataset.target);
+ }));
+}
+
+// ── Status refresh ─────────────────────────────────────────
+
+async function refreshAll()
+{
+ await Promise.all([refreshServices(), refreshSmtp(), refreshImap(), refreshPop3()]);
+ pulse();
+}
+
+async function refreshServices()
+{
+ try
+ {
+ const [d, cfg] = await Promise.all([apiFetch('/api/status/all'), fetchConfig()]);
+ applyServiceDot('tab-smtp', d.smtp, cfg.smtp?.enabled ?? true);
+ applyServiceDot('tab-imap', d.imap, cfg.imap?.enabled ?? true);
+ applyServiceDot('tab-pop3', d.pop3, cfg.pop3?.enabled ?? true);
+ const running = [d.smtp, d.imap, d.pop3].filter(Boolean).length;
+ $id('header-status').textContent = `${running}/3 running`;
+ }
+ catch { $id('header-status').textContent = 'unreachable'; }
+}
+
+function applyServiceDot(tabId, running, enabled)
+{
+ const el = $id(tabId);
+ if (!el) return;
+ el.classList.remove('svc-up', 'svc-down', 'svc-disabled');
+ if (!enabled) el.classList.add('svc-disabled');
+ else if (running) el.classList.add('svc-up');
+ else el.classList.add('svc-down');
+}
+
+async function refreshSmtp()
+{
+ try
+ {
+ const d = await apiFetch('/api/status/smtp');
+ set('smtp-total-conn', d.connections.total);
+ set('smtp-active', d.connections.active);
+ set('smtp-recv', d.messages.received);
+ set('smtp-last-msg', d.messages.lastReceivedAt ? timeSince(d.messages.lastReceivedAt) : 'never');
+ set('smtp-relayed', d.messages.relayed);
+ set('smtp-bytes', formatBytes(d.messages.bytesReceived));
+ set('smtp-uptime', formatUptime(d.uptimeSeconds));
+ set('smtp-started', d.startTime ? 'since ' + fmtDate(d.startTime) : '');
+ set('smtp-auth-ok', d.auth.success);
+ set('smtp-auth-fail', d.auth.failed);
+ const total = Math.max(d.rejections.total, 1);
+ set('smtp-rej-spf', d.rejections.spf);
+ set('smtp-rej-dkim', d.rejections.dkim);
+ set('smtp-rej-dmarc', d.rejections.dmarc);
+ setBar('bar-spf', d.rejections.spf, total);
+ setBar('bar-dkim', d.rejections.dkim, total);
+ setBar('bar-dmarc', d.rejections.dmarc, total);
+ renderUserTable('smtp-tbl-senders', d.topSenders, 'domain');
+ renderUserTable('smtp-tbl-recipients', d.topRecipients, 'domain');
+ }
+ catch (e) { console.error('SMTP refresh failed', e); }
+}
+
+async function refreshImap()
+{
+ try
+ {
+ const d = await apiFetch('/api/status/imap');
+ set('imap-total-conn', d.connections.total);
+ set('imap-active', d.connections.active);
+ set('imap-fetched', d.messages.fetched);
+ set('imap-last-activity', d.messages.lastActivityAt ? timeSince(d.messages.lastActivityAt) : 'never');
+ set('imap-bytes', formatBytes(d.messages.bytesSent));
+ set('imap-commands', d.commands);
+ set('imap-folder-selects', d.folderSelects);
+ set('imap-uptime', formatUptime(d.uptimeSeconds));
+ set('imap-started', d.startTime ? 'since ' + fmtDate(d.startTime) : '');
+ set('imap-auth-ok', d.auth.success);
+ set('imap-auth-fail', d.auth.failed);
+ renderUserTable('imap-tbl-users', d.topUsers, 'user');
+ }
+ catch (e) { console.error('IMAP refresh failed', e); }
+}
+
+async function refreshPop3()
+{
+ try
+ {
+ const d = await apiFetch('/api/status/pop3');
+ set('pop3-total-conn', d.connections.total);
+ set('pop3-active', d.connections.active);
+ set('pop3-retrieved', d.messages.retrieved);
+ set('pop3-last-activity', d.messages.lastActivityAt ? timeSince(d.messages.lastActivityAt) : 'never');
+ set('pop3-deleted', d.messages.deleted);
+ set('pop3-bytes', formatBytes(d.messages.bytesSent));
+ set('pop3-uptime', formatUptime(d.uptimeSeconds));
+ set('pop3-started', d.startTime ? 'since ' + fmtDate(d.startTime) : '');
+ set('pop3-auth-ok', d.auth.success);
+ set('pop3-auth-fail', d.auth.failed);
+ renderUserTable('pop3-tbl-users', d.topUsers, 'user');
+ }
+ catch (e) { console.error('POP3 refresh failed', e); }
+}
+
+// ── Settings load ──────────────────────────────────────────
+
+let _cfg = null;
+
+async function fetchConfig()
+{
+ if (!_cfg) _cfg = await apiFetch('/api/config');
+ return _cfg;
+}
+
+function wireSettingsContainer(el)
+{
+ el.addEventListener('change', function (e)
+ {
+ const t = e.target;
+ if (t.id === 'smtp-enabled') { saveServiceEnabled('smtp', t.checked); return; }
+ if (t.id === 'pop3-enabled') { saveServiceEnabled('pop3', t.checked); return; }
+ if (t.id === 'imap-enabled') { saveServiceEnabled('imap', t.checked); return; }
+ if (t.classList.contains('port-enabled')) {
+ const row = t.closest('tr');
+ if (row) row.classList.toggle('port-row-disabled', !t.checked);
+ }
+ });
+
+ el.addEventListener('click', function (e)
+ {
+ if (e.target.classList.contains('cfg-subtab-btn'))
+ {
+ const nav = e.target.closest('.cfg-subtab-nav');
+ const panels = nav?.nextElementSibling;
+ nav?.querySelectorAll('.cfg-subtab-btn').forEach(b => b.classList.remove('active'));
+ e.target.classList.add('active');
+ panels?.querySelectorAll('.cfg-subtab-panel').forEach(p => p.hidden = true);
+ const panel = panels?.querySelector(`[data-cfg-panel="${e.target.dataset.cfgTab}"]`);
+ if (panel) panel.hidden = false;
+ return;
+ }
+ if (e.target.classList.contains('btn-add-port'))
+ {
+ const tbody = $id(e.target.dataset.prefix + '-ports-tbody');
+ if (tbody) tbody.insertAdjacentHTML('beforeend', portRow({}));
+ }
+ if (e.target.classList.contains('btn-remove-port'))
+ {
+ e.target.closest('tr').remove();
+ }
+ if (e.target.classList.contains('btn-save'))
+ {
+ const key = e.target.dataset.save;
+ const msg = e.target.nextElementSibling;
+ e.target.disabled = true;
+ saveConfig(key)
+ .then(() => { _cfg = null; showMsg(msg, true, 'Saved'); })
+ .catch(err => showMsg(msg, false, err.message || 'Error'))
+ .finally(() => { e.target.disabled = false; });
+ }
+ });
+}
+
+async function loadInlineSettings(bodyId)
+{
+ const el = $id(bodyId);
+ if (!el) return;
+ el.innerHTML = '<p class="cfg-loading">Loading…</p>';
+ try
+ {
+ const cfg = await fetchConfig();
+ if (bodyId === 'cfg-smtp-body') el.innerHTML = buildCfgSmtp(cfg.smtp, cfg.dmarc);
+ else if (bodyId === 'cfg-imap-body') el.innerHTML = buildCfgImap(cfg.imap);
+ else if (bodyId === 'cfg-pop3-body') el.innerHTML = buildCfgPop3(cfg.pop3);
+ else if (bodyId === 'cfg-general-body') el.innerHTML = buildCfgGeneral(cfg.general);
+ wireSettingsContainer(el);
+ }
+ catch (e) { el.innerHTML = `<p style="color:var(--red)">${esc(e.message)}</p>`; }
+}
+
+async function saveConfig(key)
+{
+ switch (key)
+ {
+ case 'smtp-general': return apiFetch('/api/config/smtp/general', collectSmtpGeneral());
+ case 'smtp-connections': return apiFetch('/api/config/smtp/connections', collectSmtpConnections());
+ case 'smtp-delivery': return apiFetch('/api/config/smtp/delivery', collectSmtpDelivery());
+ case 'smtp-relay': return apiFetch('/api/config/smtp/relay', collectSmtpRelay());
+ case 'smtp-security': return apiFetch('/api/config/smtp/security', collectSmtpSecurity());
+ case 'smtp-limits': return apiFetch('/api/config/smtp/limits', collectSmtpLimits());
+ case 'pop3-general': return apiFetch('/api/config/pop3/general', collectPop3General());
+ case 'pop3-connections': return apiFetch('/api/config/pop3/connections', collectPop3Connections());
+ case 'imap-general': return apiFetch('/api/config/imap/general', collectImapGeneral());
+ case 'imap-connections': return apiFetch('/api/config/imap/connections', collectImapConnections());
+ case 'imap-folders': return apiFetch('/api/config/imap/folders', collectImapFolders());
+ case 'imap-advanced': return apiFetch('/api/config/imap/advanced', collectImapAdvanced());
+ case 'dmarc': return apiFetch('/api/config/dmarc', collectDmarc());
+ case 'general': return apiFetch('/api/config/general', collectGeneral());
+ }
+}
+
+async function saveServiceEnabled(svc, enabled)
+{
+ _cfg = null;
+ try
+ {
+ await saveConfig(`${svc}-general`);
+ const el = $id(`tab-${svc}`);
+ if (el)
+ {
+ el.classList.remove('svc-up', 'svc-down', 'svc-disabled');
+ el.classList.add(enabled ? 'svc-up' : 'svc-disabled');
+ }
+ setTimeout(refreshServices, 800);
+ }
+ catch (e) { console.error(`Failed to save ${svc} enabled:`, e); }
+}
+
+// ── Settings subtab builder ────────────────────────────────
+
+function buildCfgSubtabs(tabs)
+{
+ const nav = tabs.map((t, i) =>
+ `<button class="cfg-subtab-btn${i === 0 ? ' active' : ''}" data-cfg-tab="${t.key}">${t.label}</button>`
+ ).join('');
+
+ const panels = tabs.map((t, i) =>
+ `<div class="cfg-subtab-panel" data-cfg-panel="${t.key}"${i !== 0 ? ' hidden' : ''}>${t.html}</div>`
+ ).join('');
+
+ return `<div class="cfg-subtab-nav">${nav}</div><div class="cfg-subtab-panels">${panels}</div>`;
+}
+
+// ── SMTP panel ─────────────────────────────────────────────
+
+function buildCfgSmtp(s, dmarc)
+{
+ const secOpts = ['None', 'StartTls', 'Tls'].map(v =>
+ `<option value="${v}"${s.relayConnectionSecurity === v ? ' selected' : ''}>${v}</option>`
+ ).join('');
+
+ const tabs = [
+ {
+ key: 'general', label: 'General',
+ html: `
+ <div class="toggle-list">
+ ${toggle('smtp-enabled', 'Enable SMTP service', 'Start the SMTP server on startup', s.enabled ?? true)}
+ </div>
+ <div class="form-grid">
+ ${field('smtp-localhost', 'Server hostname (greeting)', s.localHostName)}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="smtp-general">Save</button>
+ <span class="save-msg" id="msg-smtp"></span>
+ </div>`
+ },
+ {
+ key: 'connections', label: 'Connections',
+ html: `
+ <div class="form-grid">
+ ${field('smtp-maxconn', 'Max connections (0 = unlimited)', s.maxConnections, 'number')}
+ ${field('smtp-maxmsg', 'Max message size (KB)', s.maxMessageSizeKb, 'number')}
+ ${field('smtp-certpath', 'Certificate path', s.certificatePath)}
+ ${field('smtp-certpass', 'Certificate password', s.certificatePassword, 'password')}
+ </div>
+ <p class="cfg-restart-note" style="margin:12px 0">&#9888; restart required after port changes</p>
+ ${buildPorts('smtp', s.ports)}
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="smtp-connections">Save</button>
+ <span class="save-msg" id="msg-smtp-conn"></span>
+ </div>`
+ },
+ {
+ key: 'delivery', label: 'Delivery',
+ html: `
+ <div class="form-grid">
+ ${field('smtp-retries', 'Number of retries', s.retryCount, 'number')}
+ ${field('smtp-retryinterval', 'Minutes between every retry', s.retryIntervalMinutes, 'number')}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="smtp-delivery">Save</button>
+ <span class="save-msg" id="msg-smtp-del"></span>
+ </div>`
+ },
+ {
+ key: 'relayer', label: 'Relayer',
+ html: `
+ <div class="toggle-list">
+ ${toggle('smtp-relay', 'Enable relay', 'Forward outgoing mail to an external SMTP server', s.relayEnabled)}
+ ${toggle('smtp-deliveredto', 'Add Delivered-To header', 'Insert Delivered-To header in relayed messages', s.addDeliveredToHeader)}
+ </div>
+ <div class="form-grid">
+ ${field('smtp-relayhost', 'Remote host name', s.relayHost)}
+ ${field('smtp-relayport', 'Remote TCP/IP port', s.relayPort, 'number')}
+ ${field('smtp-relayqueue', 'Relay queue path', s.relayQueuePath)}
+ <div class="form-field">
+ <label for="smtp-relaysec">Connection security</label>
+ <select id="smtp-relaysec">${secOpts}</select>
+ </div>
+ </div>
+ <div class="toggle-list">
+ ${toggle('smtp-relayauth', 'Server requires authentication', 'Authenticate when relaying', s.relayRequiresAuth)}
+ </div>
+ <div class="form-grid">
+ ${field('smtp-relayuser', 'User name', s.relayUsername)}
+ ${field('smtp-relaypass', 'Password', s.relayPassword, 'password')}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="smtp-relay">Save</button>
+ <span class="save-msg" id="msg-smtp-rel"></span>
+ </div>`
+ },
+ {
+ key: 'security', label: 'Security',
+ html: `
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
+ <div class="ipg-group">
+ <div class="ipg-group__legend">RFC Compliance</div>
+ <div class="toggle-list">
+ ${toggle('smtp-plainauth', 'Allow plain text authentication', 'Allow AUTH PLAIN and AUTH LOGIN', s.allowPlainTextAuth)}
+ ${toggle('smtp-emptysender', 'Allow empty sender address', 'Accept MAIL FROM:<>', s.allowEmptySender)}
+ ${toggle('smtp-badlineends', 'Allow incorrectly formatted line endings', 'Accept bare CR or LF in message data', s.allowBadLineEndings)}
+ ${toggle('smtp-discbadcmds', 'Disconnect after too many invalid commands', 'Close connection on repeated bad commands', s.disconnectOnTooManyInvalidCommands)}
+ </div>
+ <div class="form-grid" style="margin-top:12px">
+ ${field('smtp-maxbadcmds', 'Maximum invalid commands', s.maxInvalidCommands, 'number')}
+ </div>
+ </div>
+ <div class="ipg-group">
+ <div class="ipg-group__legend">Auth / DKIM</div>
+ <div class="toggle-list">
+ ${toggle('smtp-vrfy', 'Enable VRFY', 'Allow address verification', s.enableVrfy)}
+ ${toggle('smtp-expn', 'Enable EXPN', 'Allow mailing list expansion', s.enableExpn)}
+ ${toggle('smtp-dkim', 'Require DKIM', 'Reject mail without valid DKIM',s.requireDkim)}
+ </div>
+ </div>
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="smtp-security">Save</button>
+ <span class="save-msg" id="msg-smtp-sec"></span>
+ </div>`
+ },
+ {
+ key: 'limits', label: 'Limits',
+ html: `
+ <div class="form-grid">
+ ${field('smtp-maxrecipients', 'Max recipients per batch', s.maxRecipientsPerBatch, 'number')}
+ ${field('smtp-ruleloop', 'Rule loop limit', s.ruleLoopLimit, 'number')}
+ ${field('smtp-maxrechosts', 'Max recipient hosts', s.maxRecipientHosts, 'number')}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="smtp-limits">Save</button>
+ <span class="save-msg" id="msg-smtp-lim"></span>
+ </div>`
+ },
+ {
+ key: 'dmarc', label: 'DMARC',
+ html: `
+ <div class="toggle-list">
+ ${toggle('dmarc-failopen', 'Fail open', 'Accept mail when DMARC lookup fails', dmarc.failOpen)}
+ ${toggle('dmarc-require', 'Require DMARC', 'Reject mail that fails DMARC policy', dmarc.requireDmarc)}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="dmarc">Save</button>
+ <span class="save-msg" id="msg-dmarc"></span>
+ </div>`
+ }
+ ];
+
+ return buildCfgSubtabs(tabs);
+}
+
+// ── POP3 panel ─────────────────────────────────────────────
+
+function buildCfgPop3(s)
+{
+ const tabs = [
+ {
+ key: 'general', label: 'General',
+ html: `
+ <div class="toggle-list">
+ ${toggle('pop3-enabled', 'Enable POP3 service', 'Start the POP3 server on startup', s.enabled ?? true)}
+ </div>
+ <div class="form-grid">
+ ${field('pop3-welcome', 'Welcome message', s.welcomeMessage)}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="pop3-general">Save</button>
+ <span class="save-msg" id="msg-pop3"></span>
+ </div>`
+ },
+ {
+ key: 'connections', label: 'Connections',
+ html: `
+ <div class="form-grid">
+ ${field('pop3-maxconn', 'Max connections (0 = unlimited)', s.maxConnections, 'number')}
+ ${field('pop3-certpath', 'Certificate path', s.certificatePath)}
+ ${field('pop3-certpass', 'Certificate password', s.certificatePassword, 'password')}
+ </div>
+ <p class="cfg-restart-note" style="margin:12px 0">&#9888; restart required after port changes</p>
+ ${buildPorts('pop3', s.ports)}
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="pop3-connections">Save</button>
+ <span class="save-msg" id="msg-pop3-conn"></span>
+ </div>`
+ }
+ ];
+
+ return buildCfgSubtabs(tabs);
+}
+
+// ── IMAP panel ─────────────────────────────────────────────
+
+function buildCfgImap(s)
+{
+ const delimOpts = ['.', '/', '\\'].map(d =>
+ `<option value="${d}"${s.hierarchyDelimiter === d ? ' selected' : ''}>${d}</option>`
+ ).join('');
+
+ const tabs = [
+ {
+ key: 'general', label: 'General',
+ html: `
+ <div class="toggle-list">
+ ${toggle('imap-enabled', 'Enable IMAP service', 'Start the IMAP server on startup', s.enabled ?? true)}
+ </div>
+ <div class="form-grid">
+ ${field('imap-welcome', 'Welcome message', s.welcomeMessage)}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="imap-general">Save</button>
+ <span class="save-msg" id="msg-imap"></span>
+ </div>`
+ },
+ {
+ key: 'connections', label: 'Connections',
+ html: `
+ <div class="form-grid">
+ ${field('imap-maxconn', 'Max connections (0 = unlimited)', s.maxConnections, 'number')}
+ ${field('imap-certpath', 'Certificate path', s.certificatePath)}
+ ${field('imap-certpass', 'Certificate password', s.certificatePassword, 'password')}
+ </div>
+ <p class="cfg-restart-note" style="margin:12px 0">&#9888; restart required after port changes</p>
+ ${buildPorts('imap', s.ports)}
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="imap-connections">Save</button>
+ <span class="save-msg" id="msg-imap-conn"></span>
+ </div>`
+ },
+ {
+ key: 'folders', label: 'Folders',
+ html: `
+ <div class="form-grid">
+ ${field('imap-publicfolder', 'Public folder name', s.publicFolderName)}
+ </div>
+ <div class="form-field" style="margin-top:12px">
+ <label for="imap-delim">Hierarchy delimiter</label>
+ <select id="imap-delim">${delimOpts}</select>
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="imap-folders">Save</button>
+ <span class="save-msg" id="msg-imap-fold"></span>
+ </div>`
+ },
+ {
+ key: 'advanced', label: 'Advanced',
+ html: `
+ <div class="toggle-list">
+ ${toggle('imap-sort', 'IMAP Sort', 'Enable SORT extension', s.enableSort)}
+ ${toggle('imap-quota', 'IMAP Quota', 'Enable QUOTA extension', s.enableQuota)}
+ ${toggle('imap-idle', 'IMAP IDLE', 'Enable IDLE extension', s.enableIdle)}
+ ${toggle('imap-acl', 'IMAP ACL', 'Enable ACL extension', s.enableAcl)}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="imap-advanced">Save</button>
+ <span class="save-msg" id="msg-imap-adv"></span>
+ </div>`
+ }
+ ];
+
+ return buildCfgSubtabs(tabs);
+}
+
+// ── Ports editor ───────────────────────────────────────────
+
+const SEC_OPTIONS = [
+ { value: 'None', label: 'None — plain' },
+ { value: 'StartTlsOptional', label: 'STARTTLS optional' },
+ { value: 'StartTls', label: 'STARTTLS required' },
+ { value: 'Tls', label: 'TLS (implicit)' }
+];
+
+function portRow(p)
+{
+ const opts = SEC_OPTIONS.map(o =>
+ `<option value="${o.value}"${(p.security ?? 'None') === o.value ? ' selected' : ''}>${o.label}</option>`
+ ).join('');
+ const enabled = p.enabled ?? true;
+ return `<tr class="${enabled ? '' : 'port-row-disabled'}">
+ <td><input type="text" class="port-host" value="${esc(p.host ?? '0.0.0.0')}"></td>
+ <td><input type="number" class="port-port" value="${p.port ?? ''}" min="1" max="65535"></td>
+ <td><select class="port-sec">${opts}</select></td>
+ <td><label class="port-toggle-wrap" title="${enabled ? 'Disable' : 'Enable'} this port">
+ <input type="checkbox" class="port-enabled" ${enabled ? 'checked' : ''}>
+ <span class="port-toggle-track"></span>
+ </label></td>
+ <td><button type="button" class="btn-remove-port" title="Remove">&#x2715;</button></td>
+ </tr>`;
+}
+
+function buildPorts(prefix, ports)
+{
+ const rows = (ports || []).map(p => portRow(p)).join('');
+ return `
+ <table class="ports-table">
+ <thead><tr><th>Host / IP</th><th>Port</th><th>Security</th><th></th><th></th></tr></thead>
+ <tbody id="${prefix}-ports-tbody">${rows}</tbody>
+ </table>
+ <div class="ports-actions">
+ <button type="button" class="btn-add-port" data-prefix="${prefix}">+ Add port</button>
+ </div>`;
+}
+
+// ── Collectors ─────────────────────────────────────────────
+
+// ── Per-section collectors ─────────────────────────────────
+
+function collectSmtpGeneral()
+{
+ return { enabled: chk('smtp-enabled'), localHostName: val('smtp-localhost') };
+}
+
+function collectSmtpConnections()
+{
+ return {
+ maxConnections: int('smtp-maxconn'),
+ maxMessageSizeKb: int('smtp-maxmsg'),
+ certificatePath: val('smtp-certpath'),
+ certificatePassword: val('smtp-certpass'),
+ ports: collectPorts('smtp')
+ };
+}
+
+function collectSmtpDelivery()
+{
+ return {
+ retryCount: int('smtp-retries'),
+ retryIntervalMinutes: int('smtp-retryinterval')
+ };
+}
+
+function collectSmtpRelay()
+{
+ return {
+ relayEnabled: chk('smtp-relay'),
+ addDeliveredToHeader: chk('smtp-deliveredto'),
+ relayHost: val('smtp-relayhost'),
+ relayPort: int('smtp-relayport'),
+ relayQueuePath: val('smtp-relayqueue'),
+ relayConnectionSecurity: val('smtp-relaysec'),
+ relayRequiresAuth: chk('smtp-relayauth'),
+ relayUsername: val('smtp-relayuser'),
+ relayPassword: val('smtp-relaypass')
+ };
+}
+
+function collectSmtpSecurity()
+{
+ return {
+ allowPlainTextAuth: chk('smtp-plainauth'),
+ allowEmptySender: chk('smtp-emptysender'),
+ allowBadLineEndings: chk('smtp-badlineends'),
+ disconnectOnTooManyInvalidCommands: chk('smtp-discbadcmds'),
+ maxInvalidCommands: int('smtp-maxbadcmds'),
+ enableVrfy: chk('smtp-vrfy'),
+ enableExpn: chk('smtp-expn'),
+ requireDkim: chk('smtp-dkim')
+ };
+}
+
+function collectSmtpLimits()
+{
+ return {
+ maxRecipientsPerBatch: int('smtp-maxrecipients'),
+ ruleLoopLimit: int('smtp-ruleloop'),
+ maxRecipientHosts: int('smtp-maxrechosts')
+ };
+}
+
+function collectPop3General()
+{
+ return { enabled: chk('pop3-enabled'), welcomeMessage: val('pop3-welcome') };
+}
+
+function collectPop3Connections()
+{
+ return {
+ maxConnections: int('pop3-maxconn'),
+ certificatePath: val('pop3-certpath'),
+ certificatePassword: val('pop3-certpass'),
+ ports: collectPorts('pop3')
+ };
+}
+
+function collectImapGeneral()
+{
+ return { enabled: chk('imap-enabled'), welcomeMessage: val('imap-welcome') };
+}
+
+function collectImapConnections()
+{
+ return {
+ maxConnections: int('imap-maxconn'),
+ certificatePath: val('imap-certpath'),
+ certificatePassword: val('imap-certpass'),
+ ports: collectPorts('imap')
+ };
+}
+
+function collectImapFolders()
+{
+ return {
+ publicFolderName: val('imap-publicfolder'),
+ hierarchyDelimiter: val('imap-delim')
+ };
+}
+
+function collectImapAdvanced()
+{
+ return {
+ enableSort: chk('imap-sort'),
+ enableQuota: chk('imap-quota'),
+ enableIdle: chk('imap-idle'),
+ enableAcl: chk('imap-acl')
+ };
+}
+
+function collectDmarc()
+{
+ return { failOpen: chk('dmarc-failopen'), requireDmarc: chk('dmarc-require') };
+}
+
+function collectPorts(prefix)
+{
+ const tbody = $id(`${prefix}-ports-tbody`);
+ if (!tbody) return [];
+ return Array.from(tbody.querySelectorAll('tr')).map(tr => ({
+ host: tr.querySelector('.port-host')?.value ?? '',
+ port: parseInt(tr.querySelector('.port-port')?.value) || 0,
+ security: tr.querySelector('.port-sec')?.value ?? 'None',
+ enabled: tr.querySelector('.port-enabled')?.checked ?? true
+ }));
+}
+
+// ── Form helpers ───────────────────────────────────────────
+
+function field(id, label, value, type = 'text')
+{
+ return `<div class="form-field">
+ <label for="${id}">${esc(label)}</label>
+ <input type="${type}" id="${id}" value="${esc(String(value ?? ''))}">
+ </div>`;
+}
+
+function textarea(id, label, value)
+{
+ return `<div class="form-field">
+ <label for="${id}">${esc(label)}</label>
+ <textarea id="${id}">${esc(value ?? '')}</textarea>
+ </div>`;
+}
+
+function toggle(id, label, sub, checked)
+{
+ return `<div class="toggle-row">
+ <div>
+ <div class="toggle-row__label">${esc(label)}</div>
+ <div class="toggle-row__sub">${esc(sub)}</div>
+ </div>
+ <label class="toggle">
+ <input type="checkbox" id="${id}"${checked ? ' checked' : ''}>
+ <span class="toggle-track"></span>
+ </label>
+ </div>`;
+}
+
+function showMsg(el, ok, text)
+{
+ if (!el) return;
+ el.textContent = text;
+ el.className = 'save-msg ' + (ok ? 'show-ok' : 'show-err');
+ setTimeout(() => { el.className = 'save-msg'; }, 3000);
+}
+
+function val(id) { const el = $id(id); return el ? el.value : ''; }
+function int(id) { return parseInt(val(id)) || 0; }
+function chk(id) { const el = $id(id); return el ? el.checked : false; }
+function lines(id) { return val(id).split('\n').map(s => s.trim()).filter(Boolean); }
+
+// ── Shared helpers ─────────────────────────────────────────
+
+
+function set(id, value)
+{
+ const el = $id(id);
+ if (el) el.textContent = (value != null) ? value : '—';
+}
+
+function setBar(id, n, total)
+{
+ const el = $id(id);
+ if (el) el.style.width = Math.min(100, Math.round((n / total) * 100)) + '%';
+}
+
+function renderUserTable(id, rows, key)
+{
+ const el = $id(id);
+ if (!el) return;
+ if (!rows || rows.length === 0) { el.innerHTML = '<div class="domain-card__empty">No data yet</div>'; return; }
+ let html = '<table>';
+ for (const r of rows) html += `<tr><td>${esc(r[key])}</td><td>${r.count}</td></tr>`;
+ html += '</table>';
+ el.innerHTML = html;
+}
+
+function pulse()
+{
+ const dot = $id('refresh-dot');
+ if (!dot) return;
+ dot.classList.remove('pulse');
+ void dot.offsetWidth;
+ dot.classList.add('pulse');
+ $id('refresh-time').textContent = new Date().toLocaleTimeString();
+}
+
+function formatBytes(b)
+{
+ if (b == null) return '—';
+ if (b < 1024) return b + ' B';
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
+ return (b / 1048576).toFixed(2) + ' MB';
+}
+
+function formatUptime(s)
+{
+ if (s == null) return '—';
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
+ if (d > 0) return `${d}d ${h}h ${m}m`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m ${s % 60}s`;
+}
+
+function timeSince(iso)
+{
+ const diff = Math.floor((Date.now() - new Date(iso)) / 1000);
+ if (diff < 60) return diff + 's ago';
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
+ return Math.floor(diff / 3600) + 'h ago';
+}
+
+function fmtDate(iso)
+{
+ return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+}
+
+// ── Domain manager ─────────────────────────────────────────
+
+let _domains = [];
+const _openSections = {}; // id → active sub-tab name
+
+const DOM_TABS = ['General', 'Users', 'User Aliases', 'Email Lists', 'Limits', 'DKIM', 'Domein-aliases'];
+
+function initDomains()
+{
+ const addBtn = $id('dom-add-btn');
+ const addInput = $id('dom-new-name');
+ if (!addBtn) return;
+
+ addBtn.addEventListener('click', async () =>
+ {
+ const name = addInput.value.trim();
+ if (!name) return;
+ try
+ {
+ await apiFetch('/api/domain', { name });
+ addInput.value = '';
+ await loadDomains();
+ showMsg($id('msg-domains'), true, 'Domain added');
+ }
+ catch (e) { showMsg($id('msg-domains'), false, e.message || 'Error'); }
+ });
+ addInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
+
+ // delegated events on #dom-list
+ const list = $id('dom-list');
+ if (!list) return;
+ list.addEventListener('click', domListClick);
+ list.addEventListener('change', domListChange);
+ list.addEventListener('keydown', domListKeydown);
+}
+
+function domListClick(e)
+{
+ const t = e.target;
+ const id = t.dataset.id;
+
+ if (t.dataset.action === 'delete-domain') { deleteDomain(id); return; }
+ if (t.dataset.action === 'toggle-expand') { toggleExpand(id ?? t.closest('.dom-card')?.id?.replace('dom-card-','')); return; }
+ if (t.dataset.action === 'sub-tab') { setSubTab(id, t.dataset.tab); return; }
+ if (t.dataset.action === 'save-general') { saveGeneral(id); return; }
+ if (t.dataset.action === 'save-limits') { saveLimits(id); return; }
+ if (t.dataset.action === 'save-dkim') { saveDkim(id); return; }
+ if (t.dataset.action === 'add-alias') { apiAddAlias(id); return; }
+ if (t.dataset.action === 'remove-alias') { apiRemoveAlias(id, t.dataset.value); return; }
+ if (t.dataset.action === 'add-ualias') { apiAddUserAlias(id); return; }
+ if (t.dataset.action === 'remove-ualias') { apiRemoveUserAlias(id, t.dataset.alias); return; }
+ if (t.dataset.action === 'add-list') { apiAddList(id); return; }
+ if (t.dataset.action === 'remove-list') { apiRemoveList(id, t.dataset.listid); return; }
+ if (t.dataset.action === 'add-list-member') { apiAddListMember(id, t.dataset.listid); return; }
+ if (t.dataset.action === 'remove-list-member') { apiRemoveListMember(id, t.dataset.listid, t.dataset.value); return; }
+ if (t.dataset.action === 'add-user') { apiAddUser(id); return; }
+ if (t.dataset.action === 'save-user') { apiSaveUser(id, t.dataset.userid); return; }
+ if (t.dataset.action === 'remove-user') { apiRemoveUser(id, t.dataset.userid); return; }
+}
+
+function domListChange(e)
+{
+ const t = e.target;
+ if (t.dataset.action === 'toggle-enabled')
+ {
+ const d = _domains.find(x => x.id === t.dataset.id);
+ if (!d) return;
+ d.enabled = t.checked;
+ const dot = $id(`dom-dot-${d.id}`);
+ if (dot)
+ {
+ dot.classList.toggle('dom-status--on', d.enabled);
+ dot.classList.toggle('dom-status--off', !d.enabled);
+ }
+ saveGeneral(d.id);
+ }
+}
+
+function domListKeydown(e)
+{
+ if (e.key !== 'Enter') return;
+ const t = e.target;
+ if (t.dataset.enter === 'add-alias') { apiAddAlias(t.dataset.id); return; }
+ if (t.dataset.enter === 'add-user') { apiAddUser(t.dataset.id); return; }
+ if (t.dataset.enter === 'add-ualias') { apiAddUserAlias(t.dataset.id); return; }
+ if (t.dataset.enter === 'add-list') { apiAddList(t.dataset.id); return; }
+ if (t.dataset.enter === 'add-lmember') { apiAddListMember(t.dataset.id, t.dataset.listid); return; }
+}
+
+// ── Load / render ──────────────────────────────────────────
+
+async function loadDomains()
+{
+ _domains = await apiFetch('/api/domain');
+ renderDomains();
+}
+
+function renderDomains()
+{
+ const el = $id('dom-list');
+ if (!el) return;
+ if (_domains.length === 0) { el.innerHTML = '<p class="dom-empty">No domains configured yet.</p>'; return; }
+ el.innerHTML = _domains.map(d => domainCard(d)).join('');
+}
+
+function domainCard(d)
+{
+ const open = !!_openSections[d.id];
+ const activeTab = _openSections[d.id] || 'Aliases';
+ const statusCls = d.enabled ? 'dom-status--on' : 'dom-status--off';
+
+ const tabBar = DOM_TABS.map(t =>
+ `<button class="dom-subtab-btn${t === activeTab ? ' active' : ''}" data-action="sub-tab" data-id="${esc(d.id)}" data-tab="${esc(t)}">${esc(t)}</button>`
+ ).join('');
+
+ const body = open ? `
+ <div class="dom-subtab-nav">${tabBar}</div>
+ <div class="dom-subtab-body">
+ ${buildSubTab(d, activeTab)}
+ </div>` : '';
+
+ return `
+ <div class="dom-card${open ? ' dom-card--open' : ''}" id="dom-card-${esc(d.id)}">
+ <div class="dom-card__header" data-action="toggle-expand" data-id="${esc(d.id)}">
+ <span class="dom-status-dot ${statusCls}" id="dom-dot-${esc(d.id)}"></span>
+ <span class="dom-name-label" data-action="toggle-expand" data-id="${esc(d.id)}" id="dom-label-${esc(d.id)}">${esc(d.name)}</span>
+ <label class="toggle" title="Enable / disable domain" onclick="event.stopPropagation()">
+ <input type="checkbox" data-action="toggle-enabled" data-id="${esc(d.id)}"${d.enabled ? ' checked' : ''}>
+ <span class="toggle-track"></span>
+ </label>
+ <button class="dom-delete-btn" data-action="delete-domain" data-id="${esc(d.id)}" title="Delete domain">&times;</button>
+ </div>
+ ${body}
+ </div>`;
+}
+
+function buildSubTab(d, tab)
+{
+ switch (tab)
+ {
+ case 'General': return buildGeneralTab(d);
+ case 'Users': return buildUsersTab(d);
+ case 'User Aliases': return buildUserAliasesTab(d);
+ case 'Email Lists': return buildEmailListsTab(d);
+ case 'Limits': return buildLimitsTab(d);
+ case 'DKIM': return buildDkimTab(d);
+ case 'Domein-aliases': return buildAliasesTab(d);
+ default: return '';
+ }
+}
+
+function tabFooter(domainId, action, label)
+{
+ return `<div class="dom-tab-footer">
+ <button class="btn-save" data-action="${action}" data-id="${esc(domainId)}">${label}</button>
+ <span class="save-msg" id="dom-msg-${esc(domainId)}-${action}"></span>
+ </div>`;
+}
+
+function tabMsg(domainId, action) { return $id(`dom-msg-${domainId}-${action}`); }
+
+// ── Sub-tab: General ───────────────────────────────────────
+
+function buildGeneralTab(d)
+{
+ return `
+ <div class="form-grid">
+ <div class="form-field">
+ <label for="gen-name-${esc(d.id)}">Domain name</label>
+ <input type="text" class="dom-input" id="gen-name-${esc(d.id)}" value="${esc(d.name)}" placeholder="domain.tld"
+ data-labelfor="${esc(d.id)}" oninput="domNameInput(this)">
+ </div>
+ <div class="form-field">
+ <label>Catch-all address</label>
+ <div class="dom-catchall-row">
+ <input type="text" class="dom-input" id="gen-catchall-${esc(d.id)}" value="${esc(d.catchAll ?? '')}" placeholder="username">
+ <span class="dom-catchall-suffix" id="gen-catchall-suffix-${esc(d.id)}">@${esc(d.name)}</span>
+ </div>
+ </div>
+ </div>
+ ${tabFooter(d.id, 'save-general', 'Save')}`;
+}
+
+// ── Sub-tab: Aliases ───────────────────────────────────────
+
+function buildAliasesTab(d)
+{
+ const tags = (d.aliases || []).map(a => `
+ <span class="dom-alias-tag">${esc(a)}
+ <button class="dom-alias-remove" data-action="remove-alias" data-id="${esc(d.id)}" data-value="${esc(a)}">&times;</button>
+ </span>`).join('');
+ return `
+ <p class="dom-hint">Other domain names that resolve to this domain.</p>
+ <div class="dom-alias-tags" id="dom-alias-tags-${esc(d.id)}">${tags || '<span class="dom-hint">None</span>'}</div>
+ <div class="dom-alias-add-row">
+ <input type="text" class="dom-input" id="dom-alias-input-${esc(d.id)}"
+ placeholder="alias.tld" data-enter="add-alias" data-id="${esc(d.id)}">
+ <button class="btn-add-port" data-action="add-alias" data-id="${esc(d.id)}">+ Add</button>
+ <span class="save-msg" id="dom-msg-${esc(d.id)}-alias"></span>
+ </div>`;
+}
+
+// ── Sub-tab: Users ─────────────────────────────────────────
+
+function buildUsersTab(d)
+{
+ const users = d.users || [];
+ const stats = d._stats || {};
+
+ const rows = users.map(u =>
+ {
+ const s = stats[u.id] || {};
+ const sizeMb = s.sizeMb != null ? s.sizeMb.toFixed(2) + ' MB' : '—';
+ const lastActive = s.lastActivity ? timeSince(s.lastActivity) : '—';
+ const usedPct = (u.maxSizeMb > 0 && s.sizeMb != null)
+ ? Math.min(100, Math.round((s.sizeMb / u.maxSizeMb) * 100)) : null;
+
+ const bar = usedPct != null ? `
+ <div class="usr-bar-track" title="${usedPct}%">
+ <div class="usr-bar-fill${usedPct >= 90 ? ' usr-bar-fill--warn' : ''}" style="width:${usedPct}%"></div>
+ </div>` : '';
+
+ return `
+ <tr class="usr-row" id="usr-row-${esc(u.id)}">
+ <td>
+ <div class="usr-address-cell">
+ <input type="text" class="dom-input usr-username" id="usr-name-${esc(u.id)}"
+ value="${esc(u.username)}" placeholder="username">
+ <span class="usr-domain-suffix">@${esc(d.name)}</span>
+ </div>
+ </td>
+ <td>
+ <input type="password" class="dom-input usr-password" id="usr-pass-${esc(u.id)}"
+ value="${esc(u.password)}" placeholder="••••••••" autocomplete="new-password">
+ </td>
+ <td>
+ <div class="usr-size-cell">
+ <input type="number" class="dom-input usr-maxsize" id="usr-max-${esc(u.id)}"
+ value="${u.maxSizeMb || 0}" min="0" placeholder="0">
+ ${bar}
+ </div>
+ </td>
+ <td class="usr-stat">${lastActive}</td>
+ <td class="usr-stat">${sizeMb}</td>
+ <td class="usr-actions">
+ <button class="btn-row-save" data-action="save-user" data-id="${esc(d.id)}" data-userid="${esc(u.id)}" title="Save">&#10003;</button>
+ <button class="dom-delete-btn" data-action="remove-user" data-id="${esc(d.id)}" data-userid="${esc(u.id)}" title="Delete">&times;</button>
+ <span class="save-msg" id="usr-msg-${esc(u.id)}"></span>
+ </td>
+ </tr>`;
+ }).join('');
+
+ return `
+ <div class="cfg-section-title">Accounts on ${esc(d.name)}</div>
+ <table class="dom-table usr-table">
+ <thead>
+ <tr>
+ <th>Address</th>
+ <th>Password</th>
+ <th>Max size (MB)</th>
+ <th>Last logon</th>
+ <th>Current size</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody id="usr-tbody-${esc(d.id)}">${rows}</tbody>
+ <tfoot>
+ <tr>
+ <td>
+ <div class="usr-address-cell">
+ <input type="text" class="dom-input" id="usr-new-name-${esc(d.id)}"
+ placeholder="username" data-enter="add-user" data-id="${esc(d.id)}">
+ <span class="usr-domain-suffix">@${esc(d.name)}</span>
+ </div>
+ </td>
+ <td>
+ <input type="password" class="dom-input" id="usr-new-pass-${esc(d.id)}"
+ placeholder="Password" autocomplete="new-password">
+ </td>
+ <td>
+ <input type="number" class="dom-input" id="usr-new-max-${esc(d.id)}"
+ placeholder="0" min="0">
+ </td>
+ <td colspan="2"></td>
+ <td>
+ <button class="btn-add-port usr-add-btn" data-action="add-user"
+ data-id="${esc(d.id)}" title="Add user">+</button>
+ </td>
+ </tr>
+ </tfoot>
+ </table>`;
+}
+
+// ── Sub-tab: User Aliases ──────────────────────────────────
+
+function buildUserAliasesTab(d)
+{
+ const domSuffix = `@${esc(d.name)}`;
+ const localPart = v => v ? v.replace(/@.*$/, '') : v;
+ const listId = `ua-users-${esc(d.id)}`;
+ const userOpts = (d.users || []).map(u => `<option value="${esc(u.username)}">`).join('');
+ const datalist = `<datalist id="${listId}">${userOpts}</datalist>`;
+
+ const targetInput = (id, value) =>
+ `<div class="ua-target-row">
+ <input type="text" class="dom-input" id="${id}" value="${esc(value)}" placeholder="username" list="${listId}">
+ <span class="ua-domain-suffix">${domSuffix}</span>
+ </div>`;
+
+ const rows = (d.userAliases || []).map(ua => `
+ <tr>
+ <td class="dom-input-cell">${esc(ua.alias)}</td>
+ <td style="padding:0 8px;color:var(--text-muted)">→</td>
+ <td class="dom-input-cell">${esc(localPart(ua.target))}<span class="ua-domain-suffix">${domSuffix}</span></td>
+ <td><button class="dom-delete-btn" data-action="remove-ualias" data-id="${esc(d.id)}" data-alias="${esc(ua.alias)}">&times;</button></td>
+ </tr>`).join('');
+
+ return `
+ ${datalist}
+ <p class="dom-hint">Map an alias address to another mailbox.</p>
+ <table class="dom-table" id="ua-table-${esc(d.id)}">
+ <thead><tr><th>Alias</th><th></th><th>Target</th><th></th></tr></thead>
+ <tbody>${rows || `<tr><td colspan="4" class="dom-hint" style="padding:8px">No aliases yet.</td></tr>`}</tbody>
+ </table>
+ <div class="ua-add-row">
+ <input type="text" class="dom-input" id="ua-new-alias-${esc(d.id)}" placeholder="alias"
+ data-enter="add-ualias" data-id="${esc(d.id)}">
+ <span style="color:var(--text-muted);text-align:center">→</span>
+ ${targetInput(`ua-new-target-${esc(d.id)}`, '')}
+ <button class="btn-add-port" data-action="add-ualias" data-id="${esc(d.id)}" title="Add">+</button>
+ </div>
+ <div style="margin-top:4px"><span class="save-msg" id="dom-msg-${esc(d.id)}-ualias"></span></div>`;
+}
+
+// ── Sub-tab: Email Lists ───────────────────────────────────
+
+function buildEmailListsTab(d)
+{
+ const lists = (d.emailLists || []).map(list => {
+ const memberTags = (list.members || []).map(m => `
+ <span class="dom-alias-tag">${esc(m)}
+ <button class="dom-alias-remove" data-action="remove-list-member"
+ data-id="${esc(d.id)}" data-listid="${esc(list.id)}" data-value="${esc(m)}">&times;</button>
+ </span>`).join('');
+ return `
+ <div class="dom-list-block">
+ <div class="dom-list-block__header">
+ <span class="dom-list-name">${esc(list.name)}@${esc(d.name)}</span>
+ <button class="dom-delete-btn" data-action="remove-list" data-id="${esc(d.id)}" data-listid="${esc(list.id)}">&times;</button>
+ </div>
+ <div class="dom-alias-tags">${memberTags || '<span class="dom-hint">No members</span>'}</div>
+ <div class="dom-alias-add-row">
+ <input type="text" class="dom-input" id="lm-input-${esc(list.id)}" placeholder="member@example.com"
+ data-enter="add-lmember" data-id="${esc(d.id)}" data-listid="${esc(list.id)}">
+ <button class="btn-add-port" data-action="add-list-member"
+ data-id="${esc(d.id)}" data-listid="${esc(list.id)}">+ Add member</button>
+ </div>
+ </div>`;
+ }).join('');
+
+ return `
+ <div class="cfg-section-title">Email lists / distribution groups</div>
+ ${lists || '<p class="dom-hint">No lists yet.</p>'}
+ <div class="dom-alias-add-row" style="margin-top:12px">
+ <input type="text" class="dom-input" id="list-new-${esc(d.id)}" placeholder="listname"
+ data-enter="add-list" data-id="${esc(d.id)}">
+ <span style="color:var(--text-muted);padding:0 4px 0 2px">@${esc(d.name)}</span>
+ <button class="btn-add-port" data-action="add-list" data-id="${esc(d.id)}">+ Create list</button>
+ </div>`;
+}
+
+// ── Sub-tab: Limits ────────────────────────────────────────
+
+function buildLimitsTab(d)
+{
+ const lim = d.limits || {};
+ return `
+ <div class="form-grid form-grid--narrow">
+ ${field(`lim-mailbox-${d.id}`, 'Max mailbox (KB)', lim.maxMailboxSizeKb ?? 0, 'number')}
+ ${field(`lim-allocated-${d.id}`,'Allocated (KB)', lim.allocatedSizeKb ?? 0, 'number')}
+ ${field(`lim-msgsize-${d.id}`, 'Max message (KB)', lim.maxMessageSizeKb ?? 0, 'number')}
+ ${field(`lim-accounts-${d.id}`, 'All accounts (KB)', lim.maxAccountsSizeKb ?? 0, 'number')}
+ </div>
+ <p class="dom-hint">0 = unlimited.</p>
+ ${tabFooter(d.id, 'save-limits', 'Save')}`;
+}
+
+function dkimKeyFilePicked(picker, textId)
+{
+ const file = picker.files[0];
+ if (!file) return;
+ const el = $id(textId);
+ if (el) el.value = file.name;
+}
+
+// ── Sub-tab: DKIM ──────────────────────────────────────────
+
+function buildDkimTab(d)
+{
+ const dk = d.dkim || {};
+ const hOpts = ['Simple', 'Relaxed'].map(v =>
+ `<option value="${v}"${(dk.headerMethod ?? 'Relaxed') === v ? ' selected' : ''}>${v}</option>`).join('');
+ const bOpts = ['Simple', 'Relaxed'].map(v =>
+ `<option value="${v}"${(dk.bodyMethod ?? 'Relaxed') === v ? ' selected' : ''}>${v}</option>`).join('');
+ const aOpts = ['SHA256', 'SHA1'].map(v =>
+ `<option value="${v}"${(dk.algorithm ?? 'SHA256') === v ? ' selected' : ''}>${v}</option>`).join('');
+
+ return `
+ <div class="toggle-list">
+ ${toggle(`dkim-enabled-${d.id}`, 'Enable DKIM signing', 'Sign outgoing messages with DKIM', dk.enabled ?? false)}
+ </div>
+ <div class="form-grid form-grid--dkim">
+ <div class="form-field">
+ <label for="dkim-keyfile-${esc(d.id)}">Private key file</label>
+ <div class="file-picker-row">
+ <input type="text" id="dkim-keyfile-${esc(d.id)}" value="${esc(dk.privateKeyFile ?? '')}" placeholder="path/to/private.key">
+ <input type="file" id="dkim-keyfile-picker-${esc(d.id)}" accept=".pem,.key,.p8,.der" style="display:none"
+ onchange="dkimKeyFilePicked(this, 'dkim-keyfile-${esc(d.id)}')">
+ <button type="button" class="btn-browse" onclick="$id('dkim-keyfile-picker-${esc(d.id)}').click()">Browse&hellip;</button>
+ </div>
+ </div>
+ <div class="form-field dkim-selector-field">
+ <label for="dkim-selector-${esc(d.id)}">Selector</label>
+ <input type="text" id="dkim-selector-${esc(d.id)}" value="${esc(dk.selector ?? '')}" placeholder="mail">
+ </div>
+ </div>
+ <div class="form-grid">
+ <div class="form-field">
+ <label for="dkim-hmethod-${esc(d.id)}">Header canonicalization</label>
+ <select id="dkim-hmethod-${esc(d.id)}">${hOpts}</select>
+ </div>
+ <div class="form-field">
+ <label for="dkim-bmethod-${esc(d.id)}">Body canonicalization</label>
+ <select id="dkim-bmethod-${esc(d.id)}">${bOpts}</select>
+ </div>
+ <div class="form-field">
+ <label for="dkim-algo-${esc(d.id)}">Signing algorithm</label>
+ <select id="dkim-algo-${esc(d.id)}">${aOpts}</select>
+ </div>
+ </div>
+ ${tabFooter(d.id, 'save-dkim', 'Save')}`;
+}
+
+function collectLimits(d)
+{
+ return {
+ maxMailboxSizeKb: parseInt($id(`lim-mailbox-${d.id}`)?.value) || 0,
+ allocatedSizeKb: parseInt($id(`lim-allocated-${d.id}`)?.value) || 0,
+ maxMessageSizeKb: parseInt($id(`lim-msgsize-${d.id}`)?.value) || 0,
+ maxAccountsSizeKb: parseInt($id(`lim-accounts-${d.id}`)?.value) || 0
+ };
+}
+
+function collectDkim(d)
+{
+ return {
+ enabled: $id(`dkim-enabled-${d.id}`)?.checked ?? false,
+ privateKeyFile: $id(`dkim-keyfile-${d.id}`)?.value.trim() ?? '',
+ selector: $id(`dkim-selector-${d.id}`)?.value.trim() ?? '',
+ headerMethod: $id(`dkim-hmethod-${d.id}`)?.value ?? 'Relaxed',
+ bodyMethod: $id(`dkim-bmethod-${d.id}`)?.value ?? 'Relaxed',
+ algorithm: $id(`dkim-algo-${d.id}`)?.value ?? 'SHA256'
+ };
+}
+
+function domNameInput(input)
+{
+ const id = input.dataset.labelfor;
+ const label = $id(`dom-label-${id}`);
+ const suffix = $id(`gen-catchall-suffix-${id}`);
+ if (label) label.textContent = input.value || input.placeholder;
+ if (suffix) suffix.textContent = `@${input.value || input.placeholder}`;
+}
+
+// ── Actions ────────────────────────────────────────────────
+
+function toggleExpand(id)
+{
+ if (_openSections[id]) { delete _openSections[id]; renderDomains(); return; }
+ _openSections[id] = 'General';
+ renderDomains();
+}
+
+function setSubTab(id, tab)
+{
+ _openSections[id] = tab;
+ renderDomains();
+ const d = _domains.find(x => x.id === id);
+ if (tab === 'Users' && d) loadUserStats(d);
+}
+
+async function loadUserStats(d)
+{
+ try
+ {
+ const stats = await apiFetch(`/api/domain/${d.id}/userstats`);
+ d._stats = {};
+ for (const s of stats) d._stats[s.userId] = s;
+ // only re-render if still on Users tab
+ if (_openSections[d.id] === 'Users') renderDomains();
+ }
+ catch { /* stats are optional */ }
+}
+
+async function saveGeneral(id)
+{
+ const d = _domains.find(x => x.id === id);
+ if (!d) return;
+ const name = $id(`gen-name-${id}`)?.value.trim() || d.name;
+ const catchAll = $id(`gen-catchall-${id}`)?.value.trim() ?? d.catchAll;
+ const msg = tabMsg(id, 'save-general');
+ try
+ {
+ await apiFetch(`/api/domain/${id}/general`, { name, enabled: d.enabled, catchAll }, 'PUT');
+ d.name = name;
+ d.catchAll = catchAll;
+ const label = $id(`dom-label-${id}`);
+ if (label) label.textContent = name;
+ showMsg(msg, true, 'Saved');
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+}
+
+async function saveLimits(id)
+{
+ const d = _domains.find(x => x.id === id);
+ if (!d) return;
+ const limits = collectLimits(d);
+ const msg = tabMsg(id, 'save-limits');
+ try
+ {
+ await apiFetch(`/api/domain/${id}/limits`, limits, 'PUT');
+ d.limits = limits;
+ showMsg(msg, true, 'Saved');
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+}
+
+async function saveDkim(id)
+{
+ const d = _domains.find(x => x.id === id);
+ if (!d) return;
+ const dkim = collectDkim(d);
+ const msg = tabMsg(id, 'save-dkim');
+ try
+ {
+ await apiFetch(`/api/domain/${id}/dkim`, dkim, 'PUT');
+ d.dkim = dkim;
+ showMsg(msg, true, 'Saved');
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+}
+
+async function deleteDomain(id)
+{
+ if (!confirm('Delete this domain and all its settings?')) return;
+ try
+ {
+ const res = await fetch(`/api/domain/${id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ delete _openSections[id];
+ await loadDomains();
+ showMsg($id('msg-domains'), true, 'Deleted');
+ }
+ catch (e) { showMsg($id('msg-domains'), false, e.message || 'Error'); }
+}
+
+// ── Per-item API helpers ───────────────────────────────────
+
+async function apiAddAlias(domainId)
+{
+ const d = _domains.find(x => x.id === domainId);
+ const input = $id(`dom-alias-input-${domainId}`);
+ const val = input?.value.trim().toLowerCase();
+ const msg = $id(`dom-msg-${domainId}-alias`);
+ if (!d || !val) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/aliases`, { value: val }, 'POST');
+ if (!d.aliases.includes(val)) d.aliases.push(val);
+ input.value = '';
+ renderDomains();
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+}
+
+async function apiRemoveAlias(domainId, alias)
+{
+ const d = _domains.find(x => x.id === domainId);
+ if (!d) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/aliases/${encodeURIComponent(alias)}`, null, 'DELETE');
+ d.aliases = d.aliases.filter(a => a !== alias);
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+async function apiAddUser(domainId)
+{
+ const d = _domains.find(x => x.id === domainId);
+ const username = $id(`usr-new-name-${domainId}`)?.value.trim().toLowerCase();
+ const password = $id(`usr-new-pass-${domainId}`)?.value;
+ const maxSizeMb = parseInt($id(`usr-new-max-${domainId}`)?.value) || 0;
+ if (!d || !username || !password) return;
+ try
+ {
+ const created = await apiFetch(`/api/domain/${domainId}/users`, { username, password, maxSizeMb }, 'POST');
+ d.users.push(created);
+ $id(`usr-new-name-${domainId}`).value = '';
+ $id(`usr-new-pass-${domainId}`).value = '';
+ $id(`usr-new-max-${domainId}`).value = '';
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+async function apiSaveUser(domainId, userId)
+{
+ const d = _domains.find(x => x.id === domainId);
+ const u = d?.users?.find(x => x.id === userId);
+ if (!u) return;
+ const username = $id(`usr-name-${userId}`)?.value.trim().toLowerCase() || u.username;
+ const password = $id(`usr-pass-${userId}`)?.value || u.password;
+ const maxSizeMb = parseInt($id(`usr-max-${userId}`)?.value) || 0;
+ const msg = $id(`usr-msg-${userId}`);
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/users/${userId}`, { id: userId, username, password, maxSizeMb }, 'PUT');
+ u.username = username;
+ u.password = password;
+ u.maxSizeMb = maxSizeMb;
+ showMsg(msg, true, 'Saved');
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+}
+
+async function apiRemoveUser(domainId, userId)
+{
+ if (!confirm('Remove this user?')) return;
+ const d = _domains.find(x => x.id === domainId);
+ if (!d) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/users/${userId}`, null, 'DELETE');
+ d.users = d.users.filter(u => u.id !== userId);
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+async function apiAddUserAlias(domainId)
+{
+ const d = _domains.find(x => x.id === domainId);
+ const alias = $id(`ua-new-alias-${domainId}`)?.value.trim();
+ const local = $id(`ua-new-target-${domainId}`)?.value.trim();
+ const target = local ? `${local}@${d?.name}` : '';
+ const msg = $id(`dom-msg-${domainId}-ualias`);
+ if (!d || !alias || !target) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/useraliases`, { alias, target }, 'POST');
+ d.userAliases = d.userAliases.filter(a => a.alias !== alias);
+ d.userAliases.push({ alias, target });
+ $id(`ua-new-alias-${domainId}`).value = '';
+ $id(`ua-new-target-${domainId}`).value = '';
+ renderDomains();
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+}
+
+async function apiRemoveUserAlias(domainId, alias)
+{
+ const d = _domains.find(x => x.id === domainId);
+ if (!d) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/useraliases/${encodeURIComponent(alias)}`, null, 'DELETE');
+ d.userAliases = d.userAliases.filter(a => a.alias !== alias);
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+async function apiAddList(domainId)
+{
+ const d = _domains.find(x => x.id === domainId);
+ const name = $id(`list-new-${domainId}`)?.value.trim().toLowerCase();
+ if (!d || !name) return;
+ try
+ {
+ const created = await apiFetch(`/api/domain/${domainId}/lists`, { name }, 'POST');
+ d.emailLists = d.emailLists || [];
+ d.emailLists.push(created);
+ $id(`list-new-${domainId}`).value = '';
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+async function apiRemoveList(domainId, listId)
+{
+ const d = _domains.find(x => x.id === domainId);
+ if (!d) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/lists/${listId}`, null, 'DELETE');
+ d.emailLists = d.emailLists.filter(l => l.id !== listId);
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+async function apiAddListMember(domainId, listId)
+{
+ const d = _domains.find(x => x.id === domainId);
+ const list = d?.emailLists?.find(l => l.id === listId);
+ const val = $id(`lm-input-${listId}`)?.value.trim().toLowerCase();
+ if (!list || !val) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/lists/${listId}/members`, { value: val }, 'POST');
+ if (!list.members.includes(val)) list.members.push(val);
+ $id(`lm-input-${listId}`).value = '';
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+async function apiRemoveListMember(domainId, listId, value)
+{
+ const d = _domains.find(x => x.id === domainId);
+ const list = d?.emailLists?.find(l => l.id === listId);
+ if (!list) return;
+ try
+ {
+ await apiFetch(`/api/domain/${domainId}/lists/${listId}/members/${encodeURIComponent(value)}`, null, 'DELETE');
+ list.members = list.members.filter(m => m !== value);
+ renderDomains();
+ }
+ catch (e) { console.error(e); }
+}
+
+// load domains when Domains tab becomes active
+document.addEventListener('DOMContentLoaded', () =>
+{
+ const domainsBtn = $id('tab-domains');
+ if (domainsBtn)
+ domainsBtn.addEventListener('click', () => { if (_domains.length === 0) loadDomains(); });
+});
+
+// load General Settings when tab becomes active
+document.addEventListener('DOMContentLoaded', () =>
+{
+ const btn = $id('tab-general');
+ if (btn) btn.addEventListener('click', async () =>
+ {
+ const el = $id('cfg-general-body');
+ if (!el || el.innerHTML.trim()) return;
+ el.innerHTML = '<p class="cfg-loading">Loading…</p>';
+ try
+ {
+ const cfg = await fetchConfig();
+ el.innerHTML = buildCfgGeneral(cfg.general);
+ wireSettingsContainer(el);
+ }
+ catch (e) { el.innerHTML = `<p style="color:var(--red)">${esc(e.message)}</p>`; }
+ });
+});
+
+// ── General Settings panel ─────────────────────────────────
+
+function buildCfgGeneral(g)
+{
+ g = g || {};
+ return `
+ <div class="form-grid">
+ ${field('gen-emlpath', 'Email storage path', g.emlStoragePath)}
+ ${field('gen-timeout', 'Command timeout (sec)', g.commandTimeoutSeconds, 'number')}
+ ${field('gen-backlog', 'Backlog', g.backLog, 'number')}
+ </div>
+ <div class="form-grid">
+ ${textarea('gen-dns', 'DNS Resolvers (one per line)', (g.dnsResolvers || []).join('\n'))}
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-save="general">Save General Settings</button>
+ <span class="save-msg" id="msg-general"></span>
+ </div>`;
+}
+
+function collectGeneral()
+{
+ return {
+ emlStoragePath: val('gen-emlpath'),
+ commandTimeoutSeconds: int('gen-timeout'),
+ backLog: int('gen-backlog'),
+ dnsResolvers: lines('gen-dns')
+ };
+}
+
+// ── Manager Users ──────────────────────────────────────────
+
+let _maintUsers = null;
+
+function initMaintUsers()
+{
+ const addBtn = $id('mu-add-btn');
+ const saveBtn = $id('mu-save-btn');
+ if (!addBtn) return;
+
+ addBtn.addEventListener('click', () =>
+ {
+ if (!_maintUsers) return;
+ _maintUsers.push({ userName: '', password: '', role: 'Unknown', expireDays: 365, enabled: true });
+ renderMaintUsers();
+ // focus new row username field
+ const rows = $id('mu-tbody')?.querySelectorAll('tr');
+ rows?.[rows.length - 1]?.querySelector('.mu-username')?.focus();
+ });
+
+ saveBtn.addEventListener('click', async () =>
+ {
+ const msg = $id('mu-save-msg');
+ saveBtn.disabled = true;
+ try
+ {
+ _maintUsers = collectMaintUsers();
+ await apiFetch('/api/config/maintenance-users', _maintUsers);
+ showMsg(msg, true, 'Saved');
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+ finally { saveBtn.disabled = false; }
+ });
+
+ $id('mu-tbody')?.addEventListener('click', e =>
+ {
+ if (e.target.classList.contains('mu-remove'))
+ {
+ e.target.closest('tr').remove();
+ }
+ });
+}
+
+async function loadMaintUsers()
+{
+ try
+ {
+ _maintUsers = await apiFetch('/api/config/maintenance-users');
+ renderMaintUsers();
+ }
+ catch (e) { showMsg($id('msg-maint-users'), false, e.message || 'Error'); }
+}
+
+function renderMaintUsers()
+{
+ const tbody = $id('mu-tbody');
+ if (!tbody) return;
+ tbody.innerHTML = (_maintUsers || []).map(u => muRow(u)).join('');
+}
+
+function muRow(u)
+{
+ const isAdmin = u.userName.toLowerCase() === 'admin';
+ const roleOpts = ['Unknown', 'Administrator', 'User'].map(r =>
+ `<option${u.role === r ? ' selected' : ''}>${r}</option>`).join('');
+ const enabled = u.enabled ?? true;
+
+ const toggleEl = isAdmin
+ ? `<label class="port-toggle-wrap" title="Admin is always enabled" style="opacity:.4;pointer-events:none">
+ <input type="checkbox" class="mu-enabled" checked disabled>
+ <span class="port-toggle-track"></span>
+ </label>`
+ : `<label class="port-toggle-wrap" title="${enabled ? 'Disable' : 'Enable'} user">
+ <input type="checkbox" class="mu-enabled"${enabled ? ' checked' : ''}>
+ <span class="port-toggle-track"></span>
+ </label>`;
+
+ const deleteEl = isAdmin
+ ? `<button type="button" class="dom-delete-btn" disabled title="Admin cannot be removed" style="opacity:.25;cursor:default">&times;</button>`
+ : `<button type="button" class="dom-delete-btn mu-remove" title="Remove">&times;</button>`;
+
+ return `<tr>
+ <td><input type="text" class="dom-input mu-username" value="${esc(u.userName)}" placeholder="username"${isAdmin ? ' readonly style="opacity:.6"' : ''}></td>
+ <td><input type="password" class="dom-input mu-password" value="${esc(u.password)}" placeholder="••••••••" autocomplete="new-password"></td>
+ <td><select class="dom-input mu-role">${roleOpts}</select></td>
+ <td><input type="number" class="dom-input mu-expire" value="${u.expireDays ?? 365}" min="1" style="width:90px"></td>
+ <td style="text-align:center">${toggleEl}</td>
+ <td>${deleteEl}</td>
+ </tr>`;
+}
+
+function collectMaintUsers()
+{
+ return Array.from($id('mu-tbody')?.querySelectorAll('tr') ?? []).map(tr => ({
+ userName: tr.querySelector('.mu-username')?.value.trim() ?? '',
+ password: tr.querySelector('.mu-password')?.value ?? '',
+ role: tr.querySelector('.mu-role')?.value || 'Administrator',
+ expireDays: parseInt(tr.querySelector('.mu-expire')?.value) || 365,
+ enabled: tr.querySelector('.mu-enabled')?.checked ?? true
+ }));
+}
+
+document.addEventListener('DOMContentLoaded', () =>
+{
+ const btn = $id('tab-maint-users');
+ if (btn) btn.addEventListener('click', () => { if (!_maintUsers) loadMaintUsers(); });
+ initMaintUsers();
+});
+
+// ── IP Groups ──────────────────────────────────────────────
+
+let _ipGroups = null;
+const _ipgOpen = {}; // idx → bool
+
+function initIpGroups()
+{
+ const addBtn = $id('ipg-add-btn');
+ if (!addBtn) return;
+
+ addBtn.addEventListener('click', () =>
+ {
+ if (!_ipGroups) return;
+ _ipGroups.push({
+ name: 'New Group',
+ priority: _ipGroups.length + 1,
+ cidr: '0.0.0.0/0',
+ expires: null,
+ access: { smtp: true, pop3: true, imap: true, antiSpam: false, antiVirus: false, requireSslTlsForAuth: false },
+ emailFlows: {
+ localToLocal: { allowed: false, requireAuth: false },
+ localToExternal: { allowed: false, requireAuth: false },
+ externalToLocal: { allowed: true, requireAuth: false },
+ externalToExternal: { allowed: false, requireAuth: false }
+ }
+ });
+ const newIdx = _ipGroups.length - 1;
+ _ipgOpen[newIdx] = true;
+ renderIpGroups();
+ });
+
+ const list = $id('ipg-list');
+ if (list)
+ {
+ list.addEventListener('click', ipgListClick);
+ list.addEventListener('change', ipgListChange);
+ }
+}
+
+function ipgListClick(e)
+{
+ const t = e.target;
+ const idx = t.dataset.idx !== undefined ? parseInt(t.dataset.idx) : NaN;
+
+ if (t.dataset.action === 'ipg-toggle-expand') { _ipgOpen[idx] = !_ipgOpen[idx]; renderIpGroups(); return; }
+ if (t.dataset.action === 'ipg-delete') { ipgDelete(idx); return; }
+ if (t.dataset.action === 'ipg-save') { ipgSave(idx, t); return; }
+}
+
+function ipgListChange(e) { /* reserved */ }
+
+async function loadIpGroups()
+{
+ try
+ {
+ _ipGroups = await apiFetch('/api/config/ipgroups');
+ renderIpGroups();
+ }
+ catch (e) { showMsg($id('msg-ipgroups'), false, e.message || 'Error'); }
+}
+
+function renderIpGroups()
+{
+ const el = $id('ipg-list');
+ if (!el) return;
+ if (!_ipGroups || _ipGroups.length === 0)
+ {
+ el.innerHTML = '<p class="dom-empty">No IP groups configured yet.</p>';
+ return;
+ }
+ el.innerHTML = _ipGroups.map((g, i) => ipgCard(g, i)).join('');
+}
+
+function ipgCard(g, idx)
+{
+ const open = !!_ipgOpen[idx];
+ const body = open ? ipgCardBody(g, idx) : '';
+ return `
+ <div class="dom-card${open ? ' dom-card--open' : ''}" id="ipg-card-${idx}">
+ <div class="dom-card__header" data-action="ipg-toggle-expand" data-idx="${idx}">
+ <span class="dom-name-label" data-action="ipg-toggle-expand" data-idx="${idx}">${esc(g.name)}</span>
+ <span class="dom-hint" style="margin-left:8px;pointer-events:none">${esc(g.cidr)}</span>
+ <span style="margin-left:auto;margin-right:12px;color:var(--text-muted);font-size:.8em;pointer-events:none">Priority ${g.priority}</span>
+ <button class="dom-delete-btn" data-action="ipg-delete" data-idx="${idx}" title="Delete group">&times;</button>
+ </div>
+ ${body}
+ </div>`;
+}
+
+function ipgCardBody(g, idx)
+{
+ const a = g.access || {};
+ const ef = g.emailFlows || {};
+
+ return `
+ <div style="padding:12px">
+ <div class="form-grid">
+ ${field(`ipg-name-${idx}`, 'Name', g.name, 'text')}
+ ${field(`ipg-cidr-${idx}`, 'CIDR', g.cidr, 'text')}
+ ${field(`ipg-priority-${idx}`, 'Priority', g.priority, 'number')}
+ ${field(`ipg-expires-${idx}`, 'Expires (ISO date, empty = never)', g.expires ?? '', 'text')}
+ </div>
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:14px">
+ <div class="ipg-group">
+ <div class="ipg-group__legend">Access</div>
+ <div class="toggle-list">
+ ${toggle(`ipg-smtp-${idx}`, 'SMTP', 'Allow SMTP connections', a.smtp ?? false)}
+ ${toggle(`ipg-pop3-${idx}`, 'POP3', 'Allow POP3 connections', a.pop3 ?? false)}
+ ${toggle(`ipg-imap-${idx}`, 'IMAP', 'Allow IMAP connections', a.imap ?? false)}
+ ${toggle(`ipg-antispam-${idx}`, 'Anti-Spam', 'Apply anti-spam checks', a.antiSpam ?? false)}
+ ${toggle(`ipg-antivirus-${idx}`,'Anti-Virus', 'Apply anti-virus checks', a.antiVirus ?? false)}
+ ${toggle(`ipg-reqtls-${idx}`, 'Require TLS for Auth', 'Enforce TLS before authentication', a.requireSslTlsForAuth ?? false)}
+ </div>
+ </div>
+ <div class="ipg-group">
+ <div class="ipg-group__legend">Email Flows</div>
+ <table class="ports-table" style="width:100%">
+ <thead><tr><th>Flow</th><th style="text-align:center">Allowed</th><th style="text-align:center">Require Auth</th></tr></thead>
+ <tbody>
+ ${ipgFlowRow(idx, 'localToLocal', 'Local → Local', ef.localToLocal)}
+ ${ipgFlowRow(idx, 'localToExternal', 'Local → External', ef.localToExternal)}
+ ${ipgFlowRow(idx, 'externalToLocal', 'External → Local', ef.externalToLocal)}
+ ${ipgFlowRow(idx, 'externalToExternal', 'External → External', ef.externalToExternal)}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div class="cfg-footer">
+ <button class="btn-save" data-action="ipg-save" data-idx="${idx}">Save group</button>
+ <span class="save-msg" id="ipg-msg-${idx}"></span>
+ </div>
+ </div>`;
+}
+
+function ipgFlowRow(idx, key, label, flow)
+{
+ const f = flow || {};
+ return `<tr>
+ <td>${esc(label)}</td>
+ <td style="text-align:center">
+ <label class="port-toggle-wrap" title="Allowed">
+ <input type="checkbox" id="ipg-${key}-allowed-${idx}"${f.allowed ? ' checked' : ''}>
+ <span class="port-toggle-track"></span>
+ </label>
+ </td>
+ <td style="text-align:center">
+ <label class="port-toggle-wrap" title="Require Auth">
+ <input type="checkbox" id="ipg-${key}-reqauth-${idx}"${f.requireAuth ? ' checked' : ''}>
+ <span class="port-toggle-track"></span>
+ </label>
+ </td>
+ </tr>`;
+}
+
+function collectIpGroup(idx)
+{
+ const flowKeys = ['localToLocal', 'localToExternal', 'externalToLocal', 'externalToExternal'];
+ const emailFlows = {};
+ for (const k of flowKeys)
+ emailFlows[k] = {
+ allowed: $id(`ipg-${k}-allowed-${idx}`)?.checked ?? false,
+ requireAuth: $id(`ipg-${k}-reqauth-${idx}`)?.checked ?? false
+ };
+
+ return {
+ name: val(`ipg-name-${idx}`),
+ cidr: val(`ipg-cidr-${idx}`),
+ priority: int(`ipg-priority-${idx}`),
+ expires: val(`ipg-expires-${idx}`) || null,
+ access: {
+ smtp: chk(`ipg-smtp-${idx}`),
+ pop3: chk(`ipg-pop3-${idx}`),
+ imap: chk(`ipg-imap-${idx}`),
+ antiSpam: chk(`ipg-antispam-${idx}`),
+ antiVirus: chk(`ipg-antivirus-${idx}`),
+ requireSslTlsForAuth: chk(`ipg-reqtls-${idx}`)
+ },
+ emailFlows
+ };
+}
+
+async function ipgSave(idx, btn)
+{
+ if (!_ipGroups) return;
+ _ipGroups[idx] = collectIpGroup(idx);
+ const msg = $id(`ipg-msg-${idx}`);
+ btn.disabled = true;
+ try
+ {
+ await apiFetch('/api/config/ipgroups', _ipGroups);
+ showMsg(msg, true, 'Saved');
+ renderIpGroups();
+ _ipgOpen[idx] = true;
+ }
+ catch (e) { showMsg(msg, false, e.message || 'Error'); }
+ finally { btn.disabled = false; }
+}
+
+function ipgDelete(idx)
+{
+ if (!_ipGroups || !confirm('Remove this IP group?')) return;
+ _ipGroups.splice(idx, 1);
+ delete _ipgOpen[idx];
+ // re-index open state
+ const newOpen = {};
+ Object.keys(_ipgOpen).forEach(k =>
+ {
+ const ki = parseInt(k);
+ if (ki < idx) newOpen[ki] = _ipgOpen[k];
+ else if (ki > idx) newOpen[ki - 1] = _ipgOpen[k];
+ });
+ Object.keys(_ipgOpen).forEach(k => delete _ipgOpen[k]);
+ Object.assign(_ipgOpen, newOpen);
+
+ apiFetch('/api/config/ipgroups', _ipGroups)
+ .then(() => { showMsg($id('msg-ipgroups'), true, 'Deleted'); })
+ .catch(e => { showMsg($id('msg-ipgroups'), false, e.message || 'Error'); });
+ renderIpGroups();
+}
+
+// load IP groups when tab becomes active
+document.addEventListener('DOMContentLoaded', () =>
+{
+ const ipgBtn = $id('tab-ipgroups');
+ if (ipgBtn)
+ ipgBtn.addEventListener('click', () => { if (!_ipGroups) loadIpGroups(); });
+ initIpGroups();
+});
diff --git a/MailSharp.WebManager/wwwroot/Index.cshtml b/MailSharp.WebManager/wwwroot/Index.cshtml
new file mode 100644
index 0000000..cab7ad2
--- /dev/null
+++ b/MailSharp.WebManager/wwwroot/Index.cshtml
@@ -0,0 +1,340 @@
+@page
+@{
+ ViewData["Title"] = "MailSharp Manager";
+}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>@ViewData["Title"]</title>
+ <link rel="stylesheet" href="~/styles/mailsharp.css">
+ <script src="~/default.js" defer></script>
+</head>
+<body>
+@if (User.Identity?.IsAuthenticated == true)
+{
+ <header class="app-header">
+ <div class="app-header__brand">
+ <div class="app-header__logo">MS</div>
+ <span class="app-header__title">MailSharp Manager</span>
+ </div>
+ <div class="app-header__right">
+ <span class="app-header__status" id="header-status">Loading…</span>
+ <button class="theme-toggle" id="theme-toggle" title="Toggle light/dark">☀</button>
+ <span class="app-header__user">@User.Identity.Name</span>
+ <a class="app-header__logout" href="/Account/Logout">Logout</a>
+ </div>
+ </header>
+
+ <main class="app-main">
+
+ <nav class="tab-nav">
+ <button class="tab-btn active" id="tab-smtp" data-panel="panel-smtp">
+ <span class="tab-dot"></span>SMTP
+ </button>
+ <button class="tab-btn" id="tab-imap" data-panel="panel-imap">
+ <span class="tab-dot"></span>IMAP
+ </button>
+ <button class="tab-btn" id="tab-pop3" data-panel="panel-pop3">
+ <span class="tab-dot"></span>POP3
+ </button>
+ <button class="tab-btn" id="tab-general" data-panel="panel-general">
+ &#9881; General Settings
+ </button>
+ <button class="tab-btn" id="tab-domains" data-panel="panel-domains">
+ &#127760; Domains
+ </button>
+ <button class="tab-btn" id="tab-ipgroups" data-panel="panel-ipgroups">
+ &#128274; IP Groups
+ </button>
+ <button class="tab-btn" id="tab-maint-users" data-panel="panel-maint-users">
+ &#128100; Maintenance Users
+ </button>
+ </nav>
+
+ <!-- ── SMTP ───────────────────────────────────────── -->
+ <div class="tab-panel active" id="panel-smtp">
+
+ <div class="inline-settings" id="cfg-smtp-wrap">
+ <button class="inline-settings__toggle" id="cfg-smtp-toggle" data-target="cfg-smtp-body">
+ &#9881; Settings
+ </button>
+ <div class="inline-settings__body" id="cfg-smtp-body"></div>
+ </div>
+
+ <div class="section-label">Connections &amp; Traffic</div>
+ <div class="stats-grid">
+ <div class="stat-card stat-card--blue">
+ <div class="stat-card__label">Total Connections</div>
+ <div class="stat-card__value" id="smtp-total-conn">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Active Sessions</div>
+ <div class="stat-card__value" id="smtp-active">—</div>
+ </div>
+ <div class="stat-card stat-card--green">
+ <div class="stat-card__label">Messages Received</div>
+ <div class="stat-card__value" id="smtp-recv">—</div>
+ <div class="stat-card__sub" id="smtp-last-msg">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Messages Relayed</div>
+ <div class="stat-card__value" id="smtp-relayed">—</div>
+ </div>
+ <div class="stat-card stat-card--amber">
+ <div class="stat-card__label">Data Received</div>
+ <div class="stat-card__value" id="smtp-bytes">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Uptime</div>
+ <div class="stat-card__value" id="smtp-uptime">—</div>
+ <div class="stat-card__sub" id="smtp-started">—</div>
+ </div>
+ </div>
+
+ <div class="section-label">Authentication</div>
+ <div class="stats-grid">
+ <div class="stat-card stat-card--green">
+ <div class="stat-card__label">Auth Success</div>
+ <div class="stat-card__value" id="smtp-auth-ok">—</div>
+ </div>
+ <div class="stat-card stat-card--red">
+ <div class="stat-card__label">Auth Failed</div>
+ <div class="stat-card__value" id="smtp-auth-fail">—</div>
+ </div>
+ </div>
+
+ <div class="section-label">Rejections</div>
+ <div class="rejection-panel">
+ <div class="bar-row">
+ <div class="bar-meta"><span class="bar-name">SPF</span><span class="bar-count" id="smtp-rej-spf">—</span></div>
+ <div class="bar-track"><div class="bar-fill bar-fill--red" id="bar-spf"></div></div>
+ </div>
+ <div class="bar-row">
+ <div class="bar-meta"><span class="bar-name">DKIM</span><span class="bar-count" id="smtp-rej-dkim">—</span></div>
+ <div class="bar-track"><div class="bar-fill bar-fill--amber" id="bar-dkim"></div></div>
+ </div>
+ <div class="bar-row">
+ <div class="bar-meta"><span class="bar-name">DMARC</span><span class="bar-count" id="smtp-rej-dmarc">—</span></div>
+ <div class="bar-track"><div class="bar-fill bar-fill--purple" id="bar-dmarc"></div></div>
+ </div>
+ </div>
+
+ <div class="section-label">Top Domains</div>
+ <div class="tables-row">
+ <div class="domain-card">
+ <div class="domain-card__header">Top Senders</div>
+ <div id="smtp-tbl-senders"><div class="domain-card__empty">No data yet</div></div>
+ </div>
+ <div class="domain-card">
+ <div class="domain-card__header">Top Recipients</div>
+ <div id="smtp-tbl-recipients"><div class="domain-card__empty">No data yet</div></div>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- ── IMAP ───────────────────────────────────────── -->
+ <div class="tab-panel" id="panel-imap">
+
+ <div class="inline-settings" id="cfg-imap-wrap">
+ <button class="inline-settings__toggle" id="cfg-imap-toggle" data-target="cfg-imap-body">
+ &#9881; Settings
+ </button>
+ <div class="inline-settings__body" id="cfg-imap-body"></div>
+ </div>
+
+ <div class="section-label">Connections &amp; Traffic</div>
+ <div class="stats-grid">
+ <div class="stat-card stat-card--blue">
+ <div class="stat-card__label">Total Connections</div>
+ <div class="stat-card__value" id="imap-total-conn">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Active Sessions</div>
+ <div class="stat-card__value" id="imap-active">—</div>
+ </div>
+ <div class="stat-card stat-card--green">
+ <div class="stat-card__label">Messages Fetched</div>
+ <div class="stat-card__value" id="imap-fetched">—</div>
+ <div class="stat-card__sub" id="imap-last-activity">—</div>
+ </div>
+ <div class="stat-card stat-card--amber">
+ <div class="stat-card__label">Data Sent</div>
+ <div class="stat-card__value" id="imap-bytes">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Commands</div>
+ <div class="stat-card__value" id="imap-commands">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Folder Selects</div>
+ <div class="stat-card__value" id="imap-folder-selects">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Uptime</div>
+ <div class="stat-card__value" id="imap-uptime">—</div>
+ <div class="stat-card__sub" id="imap-started">—</div>
+ </div>
+ </div>
+
+ <div class="section-label">Authentication</div>
+ <div class="stats-grid">
+ <div class="stat-card stat-card--green">
+ <div class="stat-card__label">Login Success</div>
+ <div class="stat-card__value" id="imap-auth-ok">—</div>
+ </div>
+ <div class="stat-card stat-card--red">
+ <div class="stat-card__label">Login Failed</div>
+ <div class="stat-card__value" id="imap-auth-fail">—</div>
+ </div>
+ </div>
+
+ <div class="section-label">Top Users</div>
+ <div class="domain-card">
+ <div class="domain-card__header">Most Active Accounts</div>
+ <div id="imap-tbl-users"><div class="domain-card__empty">No data yet</div></div>
+ </div>
+ </div>
+
+ <!-- ── POP3 ───────────────────────────────────────── -->
+ <div class="tab-panel" id="panel-pop3">
+
+ <div class="inline-settings" id="cfg-pop3-wrap">
+ <button class="inline-settings__toggle" id="cfg-pop3-toggle" data-target="cfg-pop3-body">
+ &#9881; Settings
+ </button>
+ <div class="inline-settings__body" id="cfg-pop3-body"></div>
+ </div>
+
+ <div class="section-label">Connections &amp; Traffic</div>
+ <div class="stats-grid">
+ <div class="stat-card stat-card--blue">
+ <div class="stat-card__label">Total Connections</div>
+ <div class="stat-card__value" id="pop3-total-conn">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Active Sessions</div>
+ <div class="stat-card__value" id="pop3-active">—</div>
+ </div>
+ <div class="stat-card stat-card--green">
+ <div class="stat-card__label">Messages Retrieved</div>
+ <div class="stat-card__value" id="pop3-retrieved">—</div>
+ <div class="stat-card__sub" id="pop3-last-activity">—</div>
+ </div>
+ <div class="stat-card stat-card--red">
+ <div class="stat-card__label">Messages Deleted</div>
+ <div class="stat-card__value" id="pop3-deleted">—</div>
+ </div>
+ <div class="stat-card stat-card--amber">
+ <div class="stat-card__label">Data Sent</div>
+ <div class="stat-card__value" id="pop3-bytes">—</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__label">Uptime</div>
+ <div class="stat-card__value" id="pop3-uptime">—</div>
+ <div class="stat-card__sub" id="pop3-started">—</div>
+ </div>
+ </div>
+
+ <div class="section-label">Authentication</div>
+ <div class="stats-grid">
+ <div class="stat-card stat-card--green">
+ <div class="stat-card__label">Login Success</div>
+ <div class="stat-card__value" id="pop3-auth-ok">—</div>
+ </div>
+ <div class="stat-card stat-card--red">
+ <div class="stat-card__label">Login Failed</div>
+ <div class="stat-card__value" id="pop3-auth-fail">—</div>
+ </div>
+ </div>
+
+ <div class="section-label">Top Users</div>
+ <div class="domain-card">
+ <div class="domain-card__header">Most Active Accounts</div>
+ <div id="pop3-tbl-users"><div class="domain-card__empty">No data yet</div></div>
+ </div>
+ </div>
+
+ <!-- ── Domains ─────────────────────────────────────── -->
+ <div class="tab-panel" id="panel-domains">
+
+ <div class="domain-mgr-toolbar">
+ <input type="text" id="dom-new-name" placeholder="example.com" class="dom-input">
+ <button class="btn-save" id="dom-add-btn">+ Add domain</button>
+ <span class="save-msg" id="msg-domains"></span>
+ </div>
+
+ <div id="dom-list"></div>
+
+ </div>
+
+ <!-- ── General Settings ───────────────────────────── -->
+ <div class="tab-panel" id="panel-general">
+ <div class="inline-settings" id="cfg-general-wrap" style="border:none;background:none">
+ <div class="inline-settings__body" id="cfg-general-body" style="display:block"></div>
+ </div>
+ </div>
+
+ <!-- ── Manager Users ──────────────────────────────── -->
+ <div class="tab-panel" id="panel-maint-users">
+
+ <div class="domain-mgr-toolbar">
+ <button class="btn-save" id="mu-add-btn">+ Add user</button>
+ <span class="save-msg" id="msg-maint-users"></span>
+ </div>
+
+ <table class="ports-table mu-table" id="mu-table">
+ <thead>
+ <tr>
+ <th>Username</th>
+ <th>Password</th>
+ <th>Role</th>
+ <th>Expire days</th>
+ <th style="text-align:center">Enabled</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody id="mu-tbody"></tbody>
+ </table>
+
+ <div class="cfg-footer" style="margin-top:12px">
+ <button class="btn-save" id="mu-save-btn">Save</button>
+ <span class="save-msg" id="mu-save-msg"></span>
+ </div>
+
+ </div>
+
+ <!-- ── IP Groups ──────────────────────────────────── -->
+ <div class="tab-panel" id="panel-ipgroups">
+
+ <div class="domain-mgr-toolbar">
+ <button class="btn-save" id="ipg-add-btn">+ Add group</button>
+ <span class="save-msg" id="msg-ipgroups"></span>
+ </div>
+
+ <div id="ipg-list"></div>
+
+ </div>
+
+ <div class="refresh-bar">
+ <div class="refresh-dot" id="refresh-dot"></div>
+ <span>Updated <span id="refresh-time">—</span></span>
+ </div>
+
+ </main>
+}
+else
+{
+ <div class="auth-page">
+ <div class="auth-card denied-card">
+ <div class="auth-card__logo">MS</div>
+ <div class="auth-card__title">MailSharp Manager</div>
+ <div class="auth-card__sub">Sign in to continue</div>
+ <a href="/Account/Login" class="btn-primary" style="display:block;text-align:center;">Go to Login</a>
+ </div>
+ </div>
+}
+</body>
+</html>
diff --git a/MailSharp.WebManager/wwwroot/styles/mailsharp.css b/MailSharp.WebManager/wwwroot/styles/mailsharp.css
new file mode 100644
index 0000000..6c38234
--- /dev/null
+++ b/MailSharp.WebManager/wwwroot/styles/mailsharp.css
@@ -0,0 +1,1446 @@
+/* MailSharp Manager — base stylesheet */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+/* ── Light mode (default) ────────────────────────────────── */
+:root {
+ --bg: #f3f4f8;
+ --surface: #ffffff;
+ --surface-2: #f0f1f5;
+ --border: #dde0ea;
+ --text: #1a1d2e;
+ --text-muted: #7a7f9a;
+ --text-dim: #b0b4c8;
+ --accent: #3a7ef5;
+ --accent-glow: rgba(58,126,245,.14);
+ --green: #18a658;
+ --green-bg: rgba(24,166,88,.10);
+ --red: #e03e3b;
+ --red-bg: rgba(224,62,59,.10);
+ --amber: #d4820a;
+ --amber-bg: rgba(212,130,10,.10);
+ --purple: #7c5ce0;
+ --purple-bg: rgba(124,92,224,.10);
+ --radius: 10px;
+ --radius-sm: 6px;
+ --font: 'Segoe UI', system-ui, -apple-system, sans-serif;
+ --mono: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+ --transition: .2s ease;
+}
+
+/* ── Dark mode ───────────────────────────────────────────── */
+body.dark {
+ --bg: #0d0f17;
+ --surface: #13161f;
+ --surface-2: #1a1e2e;
+ --border: #252840;
+ --text: #e2e4f0;
+ --text-muted: #5a5f7a;
+ --text-dim: #383c56;
+ --accent: #4f8ef7;
+ --accent-glow: rgba(79,142,247,.18);
+ --green: #3ecf72;
+ --green-bg: rgba(62,207,114,.12);
+ --red: #f25f5c;
+ --red-bg: rgba(242,95,92,.12);
+ --amber: #f0a050;
+ --amber-bg: rgba(240,160,80,.12);
+ --purple: #9b7cf0;
+ --purple-bg: rgba(155,124,240,.12);
+}
+
+html { font-size: 16px; }
+
+body {
+ font-family: var(--font);
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+a:hover { text-decoration: underline; }
+
+/* ── Layout ─────────────────────────────────────────────── */
+
+.app-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ padding: 0 24px;
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.app-header__brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.app-header__logo {
+ width: 28px;
+ height: 28px;
+ background: linear-gradient(135deg, var(--accent), var(--purple));
+ border-radius: 7px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: .75rem;
+ font-weight: 800;
+ color: #fff;
+ letter-spacing: -.5px;
+ flex-shrink: 0;
+}
+
+.app-header__title {
+ font-size: .95rem;
+ font-weight: 700;
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.app-header__right {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ font-size: .82rem;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.app-header__status {
+ font-size: .78rem;
+}
+
+.app-header__user {
+ color: var(--text);
+ font-weight: 500;
+}
+
+.app-header__logout {
+ color: var(--text-muted);
+ font-size: .8rem;
+}
+.app-header__logout:hover { color: var(--red); text-decoration: none; }
+
+.theme-toggle {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 1px solid var(--border);
+ background: var(--surface-2);
+ color: var(--text-muted);
+ font-size: .9rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background var(--transition), color var(--transition), border-color var(--transition);
+ flex-shrink: 0;
+}
+.theme-toggle:hover { background: var(--border); color: var(--text); }
+
+.app-main {
+ padding: 28px 24px 48px;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+/* ── Section header ─────────────────────────────────────── */
+
+.section-label {
+ font-size: .68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1.4px;
+ color: var(--text-dim);
+ margin-bottom: 14px;
+ margin-top: 32px;
+}
+.section-label:first-child { margin-top: 0; }
+
+/* ── Service pills ──────────────────────────────────────── */
+
+.services {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.service-pill {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 7px 16px;
+ font-size: .84rem;
+ font-weight: 600;
+ color: var(--text);
+ transition: border-color var(--transition);
+}
+.service-pill.is-up { border-color: var(--green); }
+.service-pill.is-down { border-color: var(--red); }
+
+.pill-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-dim);
+ transition: background var(--transition), box-shadow var(--transition);
+}
+.is-up .pill-dot { background: var(--green); box-shadow: 0 0 8px var(--green); }
+.is-down .pill-dot { background: var(--red); }
+
+/* ── Stat cards ─────────────────────────────────────────── */
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
+ gap: 12px;
+}
+
+.stat-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 18px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ transition: border-color var(--transition);
+}
+.stat-card:hover { border-color: #353860; }
+
+.stat-card__label {
+ font-size: .68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text-muted);
+}
+
+.stat-card__value {
+ font-size: 1.7rem;
+ font-weight: 800;
+ color: #fff;
+ font-family: var(--mono);
+ line-height: 1;
+}
+
+.stat-card__sub {
+ font-size: .72rem;
+ color: var(--text-dim);
+}
+
+.stat-card--blue .stat-card__value { color: var(--accent); }
+.stat-card--green .stat-card__value { color: var(--green); }
+.stat-card--red .stat-card__value { color: var(--red); }
+.stat-card--amber .stat-card__value { color: var(--amber); }
+.stat-card--purple .stat-card__value { color: var(--purple); }
+
+/* ── Rejection bars ─────────────────────────────────────── */
+
+.rejection-panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 20px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.bar-row { display: flex; flex-direction: column; gap: 5px; }
+
+.bar-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.bar-name { font-size: .78rem; color: var(--text-muted); font-weight: 600; }
+.bar-count { font-size: .78rem; font-family: var(--mono); color: var(--text); }
+
+.bar-track {
+ background: var(--bg);
+ border-radius: 4px;
+ height: 6px;
+ overflow: hidden;
+}
+
+.bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ width: 0%;
+ transition: width .5s ease;
+}
+.bar-fill--red { background: var(--red); }
+.bar-fill--amber { background: var(--amber); }
+.bar-fill--purple { background: var(--purple); }
+
+/* ── Domain tables ──────────────────────────────────────── */
+
+.tables-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.domain-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.domain-card__header {
+ padding: 11px 18px;
+ font-size: .68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text-dim);
+ border-bottom: 1px solid var(--border);
+}
+
+.domain-card table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.domain-card td {
+ padding: 9px 18px;
+ font-size: .84rem;
+ border-bottom: 1px solid var(--surface-2);
+}
+.domain-card tr:last-child td { border-bottom: none; }
+
+.domain-card td:first-child { color: var(--text); }
+.domain-card td:last-child { text-align: right; font-family: var(--mono); font-weight: 700; color: var(--accent); }
+
+.domain-card__empty {
+ padding: 20px;
+ text-align: center;
+ color: var(--text-dim);
+ font-size: .84rem;
+}
+
+/* ── Footer bar ─────────────────────────────────────────── */
+
+.refresh-bar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 24px;
+ font-size: .72rem;
+ color: var(--text-dim);
+}
+
+.refresh-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--green);
+ opacity: 0;
+ transition: opacity .3s;
+}
+.refresh-dot.pulse { opacity: 1; animation: pulse 1s ease-out forwards; }
+@@keyframes pulse {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+/* ── Tab navigation ─────────────────────────────────────── */
+
+.tab-nav {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 28px;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.tab-nav::-webkit-scrollbar { display: none; }
+
+.tab-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 20px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: .88rem;
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color var(--transition), border-color var(--transition);
+ margin-bottom: -1px;
+}
+.tab-btn:hover { color: var(--text); }
+.tab-btn.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+.tab-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--text-dim);
+ transition: background var(--transition), box-shadow var(--transition);
+}
+.tab-btn.svc-up .tab-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
+.tab-btn.svc-down .tab-dot { background: var(--red); }
+.tab-btn.svc-disabled .tab-dot { background: var(--text-dim); box-shadow: none; }
+
+.tab-panel { display: none; }
+.tab-panel.active { display: block; }
+
+/* ── Login page ─────────────────────────────────────────── */
+
+.auth-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: radial-gradient(ellipse at 50% 0%, rgba(79,142,247,.08) 0%, transparent 70%), var(--bg);
+}
+
+.auth-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 40px 36px;
+ width: 100%;
+ max-width: 380px;
+ box-shadow: 0 24px 64px rgba(0,0,0,.4);
+}
+
+.auth-card__logo {
+ width: 44px;
+ height: 44px;
+ background: linear-gradient(135deg, var(--accent), var(--purple));
+ border-radius: 11px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+ font-weight: 800;
+ color: #fff;
+ margin: 0 auto 20px;
+}
+
+.auth-card__title {
+ text-align: center;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #fff;
+ margin-bottom: 6px;
+}
+
+.auth-card__sub {
+ text-align: center;
+ font-size: .82rem;
+ color: var(--text-muted);
+ margin-bottom: 28px;
+}
+
+.form-group { margin-bottom: 16px; }
+
+.form-group label {
+ display: block;
+ font-size: .8rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+}
+
+.form-group input {
+ width: 100%;
+ padding: 10px 14px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-size: .9rem;
+ font-family: var(--font);
+ outline: none;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+.form-group input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+
+.btn-primary {
+ width: 100%;
+ padding: 11px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius-sm);
+ font-size: .9rem;
+ font-weight: 700;
+ font-family: var(--font);
+ cursor: pointer;
+ transition: opacity var(--transition), transform var(--transition);
+ margin-top: 8px;
+}
+.btn-primary:hover { opacity: .88; }
+.btn-primary:active { transform: scale(.98); }
+
+.auth-error {
+ background: var(--red-bg);
+ border: 1px solid var(--red);
+ color: var(--red);
+ border-radius: var(--radius-sm);
+ padding: 10px 14px;
+ font-size: .82rem;
+ margin-bottom: 18px;
+}
+
+/* ── Access denied page ─────────────────────────────────── */
+
+.denied-card {
+ text-align: center;
+}
+
+.denied-card__icon {
+ font-size: 2.5rem;
+ margin-bottom: 12px;
+}
+
+.denied-card__title {
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: var(--red);
+ margin-bottom: 10px;
+}
+
+.denied-card__text {
+ font-size: .88rem;
+ color: var(--text-muted);
+ margin-bottom: 20px;
+}
+
+/* ── Settings panel ──────────────────────────────────────── */
+
+.settings-panel { display: flex; flex-direction: column; }
+
+/* Inline settings strip at bottom of each protocol panel */
+.inline-settings {
+ margin-bottom: 24px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.inline-settings__toggle {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 20px;
+ background: var(--surface);
+ border: none;
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: .82rem;
+ font-weight: 600;
+ cursor: pointer;
+ text-align: left;
+ transition: color var(--transition), background var(--transition);
+}
+.inline-settings__toggle::after {
+ content: '▾';
+ margin-left: auto;
+ transition: transform var(--transition);
+}
+.inline-settings__toggle:hover { color: var(--text); background: var(--surface-2); }
+.inline-settings.open .inline-settings__toggle { color: var(--accent); border-bottom: 1px solid var(--border); }
+.inline-settings.open .inline-settings__toggle::after { transform: rotate(180deg); }
+
+.inline-settings__body { display: none; padding: 24px; background: var(--surface); }
+.inline-settings.open .inline-settings__body { display: block; }
+
+.cfg-loading { color: var(--text-muted); font-size: .84rem; }
+
+/* ── Settings subtabs ──────────────────────────────────── */
+.cfg-subtab-nav {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ overflow-x: auto;
+ scrollbar-width: none;
+ margin: -24px -24px 0;
+ padding: 0 20px;
+ background: var(--surface-2);
+}
+.cfg-subtab-nav::-webkit-scrollbar { display: none; }
+
+.cfg-subtab-btn {
+ padding: 9px 14px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-muted);
+ font-size: .82rem;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ margin-bottom: -1px;
+ transition: color var(--transition), border-color var(--transition);
+}
+.cfg-subtab-btn:hover { color: var(--text); }
+.cfg-subtab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+.cfg-subtab-panels { padding-top: 20px; }
+.cfg-subtab-panel { }
+
+/* Other panel */
+.settings-panel { display: flex; flex-direction: column; gap: 0; }
+
+/* Sub-tabs within settings */
+.cfg-tab-nav {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 24px;
+}
+
+.cfg-tab-btn {
+ padding: 8px 20px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: .84rem;
+ font-weight: 600;
+ cursor: pointer;
+ margin-bottom: -1px;
+ transition: color var(--transition), border-color var(--transition);
+}
+.cfg-tab-btn:hover { color: var(--text); }
+.cfg-tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+.cfg-panel { display: none; }
+.cfg-panel.active { display: block; }
+
+/* Section titles within a cfg-panel */
+.cfg-section-title {
+ font-size: .82rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .9px;
+ color: var(--text);
+ margin: 24px 0 12px;
+}
+.cfg-section-title:first-child { margin-top: 0; }
+
+/* IP Group groupbox */
+.ipg-group {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 14px 10px;
+}
+.ipg-group__legend {
+ font-size: .75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .8px;
+ color: var(--text-muted);
+ margin-bottom: 10px;
+}
+.ipg-group .toggle-list { margin-bottom: 0; }
+.ipg-group .ports-table th,
+.ipg-group .ports-table td { padding: 6px 10px; }
+
+.cfg-restart-note {
+ font-size: .7rem;
+ color: var(--amber);
+ font-weight: 400;
+ text-transform: none;
+ letter-spacing: 0;
+ margin-left: 8px;
+}
+
+.cfg-footer {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ margin-top: 20px;
+}
+
+.toggle-list { margin-bottom: 20px; }
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 16px;
+ margin-bottom: 20px;
+}
+.form-grid--narrow {
+ grid-template-columns: repeat(auto-fill, minmax(130px, 160px));
+}
+.form-grid--dkim {
+ grid-template-columns: 1fr;
+}
+.form-grid--dkim .file-picker-row input[type="text"] {
+ max-width: 360px;
+}
+.dkim-selector-field { max-width: 160px; }
+.dkim-selector-field input { width: 100%; }
+
+/* User Aliases table */
+table.dom-table[id^="ua-table-"] { table-layout: fixed; width: 484px !important; }
+table.dom-table[id^="ua-table-"] th:nth-child(1),
+table.dom-table[id^="ua-table-"] td:nth-child(1) { width: 160px; }
+table.dom-table[id^="ua-table-"] th:nth-child(2),
+table.dom-table[id^="ua-table-"] td:nth-child(2) { width: 32px; text-align: center; }
+table.dom-table[id^="ua-table-"] th:nth-child(3),
+table.dom-table[id^="ua-table-"] td:nth-child(3) { width: 260px; }
+table.dom-table[id^="ua-table-"] th:last-child,
+table.dom-table[id^="ua-table-"] td:last-child { width: 32px; }
+
+/* Align add-row inputs with table columns */
+.ua-add-row {
+ display: grid;
+ grid-template-columns: 160px 32px 260px 32px;
+ align-items: center;
+ margin-top: 6px;
+}
+.ua-add-row > span { text-align: center; color: var(--text-muted); }
+.ua-add-row .btn-add-port { width: 28px; height: 28px; padding: 0; text-align: center; }
+.ua-add-row .dom-input { width: 100%; }
+.ua-add-row .ua-target-row { display: flex; width: 260px; }
+.ua-add-row .ua-target-row .dom-input { flex: 1; min-width: 0; border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
+
+.form-field { display: flex; flex-direction: column; gap: 5px; }
+
+.form-field select {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-size: .88rem;
+ font-family: var(--font);
+ padding: 9px 12px;
+ outline: none;
+ max-width: 200px;
+ cursor: pointer;
+}
+
+.form-field label {
+ font-size: .72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .7px;
+ color: var(--text-muted);
+}
+
+.form-field input[type="text"],
+.form-field input[type="number"],
+.form-field input[type="password"],
+.form-field textarea {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-size: .88rem;
+ font-family: var(--font);
+ padding: 9px 12px;
+ outline: none;
+ width: 100%;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+.form-field input:focus,
+.form-field textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+
+.form-field textarea {
+ resize: vertical;
+ min-height: 72px;
+ font-family: var(--mono);
+ font-size: .8rem;
+}
+
+/* Toggle switch */
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 9px 0;
+ border-bottom: 1px solid var(--surface-2);
+}
+.toggle-row:last-child { border-bottom: none; }
+
+.toggle-row__label { font-size: .88rem; color: var(--text); }
+.toggle-row__sub { font-size: .72rem; color: var(--text-muted); margin-top: 2px; }
+
+.toggle {
+ position: relative;
+ width: 40px;
+ height: 22px;
+ flex-shrink: 0;
+}
+.toggle input { opacity: 0; width: 0; height: 0; }
+.toggle-track {
+ position: absolute;
+ inset: 0;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ cursor: pointer;
+ transition: background var(--transition), border-color var(--transition);
+}
+.toggle-track::after {
+ content: '';
+ position: absolute;
+ top: 3px; left: 3px;
+ width: 14px; height: 14px;
+ border-radius: 50%;
+ background: var(--text-dim);
+ transition: transform var(--transition), background var(--transition);
+}
+.toggle input:checked + .toggle-track { background: var(--accent); border-color: var(--accent); }
+.toggle input:checked + .toggle-track::after { transform: translateX(18px); background: #fff; }
+
+/* Ports table */
+.ports-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: .84rem;
+ margin-bottom: 12px;
+}
+.ports-table th {
+ text-align: left;
+ padding: 6px 10px;
+ font-size: .68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .8px;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+}
+.ports-table td { padding: 6px 10px; border-bottom: 1px solid var(--surface-2); }
+.ports-table tr:last-child td { border-bottom: none; }
+.ports-table input {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: .82rem;
+ padding: 4px 8px;
+ width: 100%;
+ outline: none;
+}
+.ports-table input:focus { border-color: var(--accent); }
+.ports-table select {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: .82rem;
+ padding: 4px 8px;
+ width: 100%;
+ outline: none;
+}
+
+/* Port row toggle */
+.port-toggle-wrap {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ position: relative;
+ width: 36px;
+ height: 20px;
+ flex-shrink: 0;
+}
+.port-toggle-wrap input { opacity: 0; width: 0; height: 0; }
+.port-toggle-track {
+ position: absolute;
+ inset: 0;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ cursor: pointer;
+ transition: background var(--transition), border-color var(--transition);
+}
+.port-toggle-track::after {
+ content: '';
+ position: absolute;
+ top: 2px; left: 2px;
+ width: 14px; height: 14px;
+ border-radius: 50%;
+ background: var(--text-dim);
+ transition: transform var(--transition), background var(--transition);
+}
+.port-toggle-wrap input:checked + .port-toggle-track { background: var(--accent); border-color: var(--accent); }
+.port-toggle-wrap input:checked + .port-toggle-track::after { transform: translateX(16px); background: #fff; }
+.ports-table tr.port-row-disabled td { opacity: 0.45; }
+
+/* Port add/remove actions */
+.ports-actions {
+ margin-bottom: 12px;
+}
+
+.file-picker-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.file-picker-row input[type="text"] {
+ flex: 1;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-size: .88rem;
+ font-family: var(--mono);
+ padding: 9px 12px;
+ outline: none;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+.file-picker-row input[type="text"]:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+.btn-browse {
+ padding: 8px 14px;
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: .82rem;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: border-color var(--transition), color var(--transition);
+}
+.btn-browse:hover { border-color: var(--accent); color: var(--accent); }
+
+.btn-add-port {
+ padding: 5px 14px;
+ background: transparent;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--accent);
+ font-family: var(--font);
+ font-size: .82rem;
+ cursor: pointer;
+ transition: border-color var(--transition), background var(--transition);
+}
+.btn-add-port:hover { border-color: var(--accent); background: var(--accent-glow); }
+
+.btn-remove-port {
+ padding: 3px 8px;
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-size: .75rem;
+ cursor: pointer;
+ line-height: 1;
+ transition: color var(--transition), border-color var(--transition);
+}
+.btn-remove-port:hover { color: var(--red); border-color: var(--red); }
+
+.ports-table td:last-child { width: 36px; text-align: center; }
+.ports-table input[type="number"] { width: 80px; }
+
+/* Section footer with save */
+.section-footer {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ margin-top: 4px;
+}
+
+.btn-save {
+ padding: 8px 22px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius-sm);
+ font-family: var(--font);
+ font-size: .85rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: opacity var(--transition);
+}
+.btn-save:hover { opacity: .85; }
+.btn-save:disabled { opacity: .4; cursor: not-allowed; }
+
+.save-msg {
+ font-size: .8rem;
+ opacity: 0;
+ transition: opacity .3s;
+}
+.save-msg.show-ok { color: var(--green); opacity: 1; }
+.save-msg.show-err { color: var(--red); opacity: 1; }
+
+.port-restart-note {
+ font-size: .72rem;
+ color: var(--amber);
+ margin-top: 6px;
+}
+
+/* ── Domain manager ─────────────────────────────────────── */
+
+.domain-mgr-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.dom-input {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-size: .88rem;
+ font-family: var(--font);
+ padding: 8px 12px;
+ outline: none;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+.dom-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+
+.dom-empty, .dom-hint {
+ color: var(--text-dim);
+ font-size: .82rem;
+ padding: 4px 0 10px;
+}
+
+/* ── Domain card ───────────────────────────────────────── */
+
+.dom-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-bottom: 10px;
+ overflow: hidden;
+ transition: border-color var(--transition);
+}
+.dom-card--open { border-color: var(--accent); }
+
+.dom-card__header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 16px;
+ user-select: none;
+ transition: background var(--transition);
+}
+.dom-card__header:hover { background: var(--surface-2); }
+
+.dom-status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.dom-status--on { background: var(--green); box-shadow: 0 0 6px var(--green); }
+.dom-status--off { background: var(--text-dim); }
+
+.dom-name-label {
+ flex: 1;
+ color: var(--text);
+ font-size: .95rem;
+ font-weight: 600;
+ font-family: var(--mono);
+ padding: 2px 4px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.dom-name-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border);
+}
+.dom-name-row__label {
+ font-size: .72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .7px;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+.dom-name-input {
+ flex: 1;
+ max-width: 320px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-size: .88rem;
+ font-family: var(--mono);
+ padding: 6px 10px;
+ outline: none;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+.dom-name-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+
+.dom-expand-btn {
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-size: .8rem;
+ padding: 3px 8px;
+ cursor: pointer;
+ transition: color var(--transition), border-color var(--transition);
+}
+.dom-expand-btn:hover { color: var(--accent); border-color: var(--accent); }
+
+.dom-delete-btn {
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-size: 1rem;
+ line-height: 1;
+ padding: 3px 8px;
+ cursor: pointer;
+ transition: color var(--transition), border-color var(--transition);
+}
+.dom-delete-btn:hover { color: var(--red); border-color: var(--red); }
+
+/* ── Sub-tabs ──────────────────────────────────────────── */
+
+.dom-subtab-nav {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ border-top: 1px solid var(--border);
+ background: var(--surface-2);
+ padding: 0 8px;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.dom-subtab-nav::-webkit-scrollbar { display: none; }
+
+.dom-subtab-btn {
+ padding: 8px 16px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: .8rem;
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+ margin-bottom: -1px;
+ transition: color var(--transition), border-color var(--transition);
+}
+.dom-subtab-btn:hover { color: var(--text); }
+.dom-subtab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+.dom-subtab-body {
+ padding: 20px 20px 4px;
+}
+
+.dom-card__footer {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 20px;
+ border-top: 1px solid var(--border);
+}
+
+.dom-tab-footer {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 0 4px;
+}
+
+.btn-row-save {
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--green);
+ font-size: .85rem;
+ cursor: pointer;
+ padding: 3px 8px;
+ transition: border-color var(--transition), background var(--transition);
+}
+.btn-row-save:hover { border-color: var(--green); background: color-mix(in srgb, var(--green) 10%, transparent); }
+
+.usr-actions { white-space: nowrap; }
+
+.dom-input-cell {
+ font-family: var(--mono);
+ font-size: .88rem;
+ white-space: nowrap;
+}
+.dom-input-cell .ua-domain-suffix {
+ display: inline;
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: .8rem;
+ color: var(--text-muted);
+}
+
+/* ── Alias tags ────────────────────────────────────────── */
+
+.dom-alias-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.dom-alias-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 3px 10px 3px 12px;
+ font-size: .78rem;
+ font-family: var(--mono);
+ color: var(--text);
+}
+
+.dom-alias-remove {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: .9rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0;
+ transition: color var(--transition);
+}
+.dom-alias-remove:hover { color: var(--red); }
+
+.dom-alias-add-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+/* ── User alias table ──────────────────────────────────── */
+
+.dom-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: .84rem;
+ margin-bottom: 8px;
+}
+.dom-table th {
+ text-align: left;
+ font-size: .68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .8px;
+ color: var(--text-muted);
+ padding: 4px 8px 6px;
+ border-bottom: 1px solid var(--border);
+}
+.dom-table td { padding: 5px 4px; vertical-align: middle; }
+.dom-table td .dom-input { width: 100%; }
+
+/* ── Email list block ──────────────────────────────────── */
+
+.dom-list-block {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 14px;
+ margin-bottom: 10px;
+}
+
+.dom-list-block__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.dom-list-name {
+ font-family: var(--mono);
+ font-size: .88rem;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+/* ── Users table ───────────────────────────────────────── */
+
+.usr-table { table-layout: fixed; }
+.usr-table th, .usr-table td { vertical-align: middle; }
+.usr-table th:nth-child(1),
+.usr-table td:nth-child(1) { width: 220px; }
+.usr-table th:nth-child(2),
+.usr-table td:nth-child(2) { width: 90px; }
+.usr-table th:nth-child(3),
+.usr-table td:nth-child(3) { width: 75px; }
+.usr-table th:nth-child(4),
+.usr-table th:nth-child(5),
+.usr-table td:nth-child(4),
+.usr-table td:nth-child(5) { width: 90px; }
+.usr-table th:last-child,
+.usr-table td:last-child { width: 80px; }
+
+.usr-address-cell {
+ display: flex;
+ align-items: center;
+}
+.usr-address-cell .dom-input {
+ border-radius: var(--radius-sm) 0 0 var(--radius-sm);
+ flex: 1;
+ min-width: 0;
+}
+
+.usr-table tfoot td { border-top: 1px solid var(--border); padding-top: 8px; }
+
+.usr-add-btn {
+ padding: 5px 10px;
+ font-size: 1rem;
+ line-height: 1;
+}
+.ua-target-row {
+ display: flex;
+ align-items: stretch;
+}
+.ua-target-row .dom-input {
+ border-radius: var(--radius-sm) 0 0 var(--radius-sm);
+}
+.ua-domain-suffix {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-left: none;
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+ padding: 6px 8px;
+ font-size: .8rem;
+ font-family: var(--mono);
+ color: var(--text-muted);
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+}
+
+.usr-domain-suffix {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-left: none;
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+ padding: 8px;
+ font-size: .8rem;
+ font-family: var(--mono);
+ color: var(--text-muted);
+ white-space: nowrap;
+ line-height: 1;
+}
+
+.usr-size-cell { display: flex; flex-direction: column; gap: 4px; }
+
+.usr-bar-track {
+ height: 4px;
+ background: var(--surface-2);
+ border-radius: 2px;
+ overflow: hidden;
+}
+.usr-bar-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 2px;
+ transition: width .4s ease;
+}
+.usr-bar-fill--warn { background: var(--red); }
+
+.usr-stat {
+ font-size: .78rem;
+ font-family: var(--mono);
+ color: var(--text-muted);
+}
+
+/* ── Catch-all row ─────────────────────────────────────── */
+
+.dom-catchall-row {
+ display: flex;
+ align-items: center;
+ gap: 0;
+}
+.dom-catchall-row .dom-input {
+ border-radius: var(--radius-sm) 0 0 var(--radius-sm);
+ flex: 1;
+}
+.dom-catchall-suffix {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-left: none;
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+ padding: 8px 10px;
+ font-size: .84rem;
+ font-family: var(--mono);
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
+/* ── Responsive ─────────────────────────────────────────── */
+
+@media (max-width: 768px) {
+ .app-main { padding: 20px 16px 40px; }
+
+ .app-header { padding: 0 16px; }
+
+ .app-header__user { display: none; }
+
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
+
+ .tables-row { grid-template-columns: 1fr; }
+
+ .stat-card__value { font-size: 1.4rem; }
+}
+
+@media (max-width: 480px) {
+ .stats-grid { grid-template-columns: 1fr 1fr; }
+ .stat-card { padding: 14px 14px; }
+}
diff --git a/MailSharp.slnx b/MailSharp.slnx
new file mode 100644
index 0000000..b28857b
--- /dev/null
+++ b/MailSharp.slnx
@@ -0,0 +1,10 @@
+<Solution>
+ <Project Path="MailSharp.Common/MailSharp.Common.csproj" />
+ <Project Path="MailSharp.DNS/MailSharp.DNS.csproj" Id="853dd9a2-5577-451b-88cb-ddcfc15e059c" />
+ <Project Path="MailSharp.IMAP/MailSharp.IMAP.csproj" />
+ <Project Path="MailSharp.MailClient/MailSharp.MailClient.csproj" Id="6b353405-77ee-4030-bc9f-77f35a5a7948" />
+ <Project Path="MailSharp.POP3/MailSharp.POP3.csproj" />
+ <Project Path="MailSharp.SMTP/MailSharp.SMTP.csproj" />
+ <Project Path="MailSharp.TestFormApp/MailSharp.TestFormApp.csproj" />
+ <Project Path="MailSharp.WebManager/MailSharp.WebManager.csproj" />
+</Solution>
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ca0bc1b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,86 @@
+# MailSharp
+
+A self-hosted email server built with C# and .NET 10, providing SMTP, IMAP, and POP3 services with an integrated web management interface.
+
+## Features
+
+- **SMTP** — ports 25, 587 (STARTTLS), and 465 (TLS); DKIM signing/verification; SPF and DMARC checking; relay support; configurable email flow policies per IP group
+- **IMAP** — ports 143 (STARTTLS) and 993 (TLS); IDLE, ACL, QUOTA, SORT extensions; public folder support
+- **POP3** — ports 110 and 995 (TLS)
+- **Web Manager** — ASP.NET Core Razor Pages dashboard for server status, domain management, and configuration; role-based access (User / Administrator)
+- **DNS** — built-in async DNS resolver with configurable upstream servers
+- **IP Groups** — per-CIDR access control with per-protocol and per-email-flow policies, expiry support
+- **Anti-spam / Anti-virus** — pluggable per IP group
+
+## Project Structure
+
+| Project | Description |
+|---|---|
+| `MailSharp.Common` | Shared interfaces, models, and service extensions |
+| `MailSharp.DNS` | Async DNS resolver |
+| `MailSharp.SMTP` | SMTP server |
+| `MailSharp.IMAP` | IMAP server |
+| `MailSharp.POP3` | POP3 server |
+| `MailSharp.WebManager` | ASP.NET Core web application and host process |
+| `MailSharp.TestFormApp` | WinForms test client |
+
+## Getting Started
+
+### Prerequisites
+
+- [.NET 10 SDK](https://dotnet.microsoft.com/download)
+- A valid TLS certificate (`.pfx`) for your mail domain
+
+### Build and run
+
+```bash
+git clone https://github.com/alphons/MailSharp.git
+cd MailSharp
+dotnet run --project MailSharp.WebManager
+```
+
+The web interface is available at `https://localhost:5001`.
+
+Default admin credentials: `admin` / `admin` — **change these before exposing the server externally**.
+
+### Install as a Windows Service
+
+Run `_install.cmd` as Administrator from the published output directory. The script creates and starts a `MailSharp` Windows Service configured for delayed auto-start with automatic restart on failure.
+
+To remove the service, run `_uninstall.cmd` as Administrator.
+
+## Configuration
+
+All server settings are stored in `mailsharp.json` alongside the executable. The web manager provides a UI for most settings; `appsettings.json` controls logging and ASP.NET host options only.
+
+### Key sections in `mailsharp.json`
+
+**`IpGroups`** — define CIDR ranges with per-protocol access and email flow policies:
+```json
+{
+ "Name": "Remote",
+ "Priority": 3,
+ "Cidr": "0.0.0.0/0",
+ "Access": { "Smtp": true, "Pop3": false, "Imap": true, "RequireSslTlsForAuth": true },
+ "EmailFlows": {
+ "ExternalToLocal": { "Allowed": true, "RequireAuth": false },
+ "LocalToExternal": { "Allowed": false, "RequireAuth": true }
+ }
+}
+```
+
+**`SmtpSettings`** — ports, TLS certificate, local domains, relay, DKIM, storage paths, connection limits.
+
+**`Pop3Settings` / `ImapSettings`** — ports, TLS certificate, connection limits, IMAP extensions.
+
+**`MailboxSettings`** — path where mailboxes are stored on disk.
+
+**`MaintenanceUsers`** — web manager accounts with roles and optional expiry.
+
+## License
+
+MIT
+
+## Contributing
+
+Fork the repository, create a feature branch, and open a pull request.