using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel; using System.Configuration; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.ServiceProcess; using System.Text; using System.Text.RegularExpressions; using System.Threading; using Newtonsoft.Json.Linq; namespace smtpproxy.net { public class SMTPProxy : ServiceBase { private ConcurrentQueue EmailQueue; private ManualResetEvent mre; private bool bRunning; private Thread Sending; private Thread Listening; private long ThreadCount; private bool ConsoleMode; private static readonly object _syncObject = new object(); private IContainer components; public SMTPProxy() { InitializeComponent(); base.ServiceName = "SMTPProxy.net"; ConsoleMode = false; EmailQueue = new ConcurrentQueue(); mre = new ManualResetEvent(false); bRunning = true; ThreadCount = 0L; } protected override void OnStart(string[] args) { Listening = new Thread(StartListening) { IsBackground = true }; Listening.Start(); Sending = new Thread(StartSending) { IsBackground = true }; Sending.Start(); } public void Start() { ConsoleMode = true; Listening = new Thread(StartListening) { IsBackground = true }; Listening.Start(); Sending = new Thread(StartSending) { IsBackground = true }; Sending.Start(); } protected override void OnStop() { bRunning = false; mre.Set(); } public void Reload(string folder) { if (!Directory.Exists(folder)) return; foreach (string item in Directory.EnumerateFiles(folder, "*.eml")) { try { Email email = new Email { sDATA = File.ReadAllText(item), sHELO = File.Exists(item.Replace(".eml", ".HELO")) ? File.ReadAllText(item.Replace(".eml", ".HELO")) : "HELO localhost\r\n", sMAIL = File.Exists(item.Replace(".eml", ".MAIL")) ? File.ReadAllText(item.Replace(".eml", ".MAIL")) : "", FromIP = "127.0.0.1" }; string rcptFile = item.Replace(".eml", ".RCPTS"); if (File.Exists(rcptFile)) { foreach (string rcpt in File.ReadAllLines(rcptFile)) { if (!string.IsNullOrWhiteSpace(rcpt)) { email.sRCPTs.Add(rcpt.Trim() + "\r\n"); } } } EmailQueue.Enqueue(email); } catch (Exception ex) { Log($"Failed to reload email file {item}: {ex.Message}"); } } SendQueuedEmails(); } public void StartSending() { while (!mre.WaitOne(30000) && bRunning) { SendQueuedEmails(); } } public void StartListening() { try { string listenSetting = ConfigurationManager.AppSettings["ListeningAddress"] ?? "0.0.0.0"; IPAddress ipAddress = IPAddress.Parse(listenSetting); IPEndPoint localEP = new IPEndPoint(ipAddress, 25); Socket socket = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp); socket.Bind(localEP); socket.Listen(100); Log($"Started Listening on port 25 at {ipAddress}"); while (bRunning) { try { Socket clientSocket = socket.Accept(); if (clientSocket.Connected) { Log($"New Connection: {DateTime.Now.ToString("G", CultureInfo.CreateSpecificCulture("en-AU"))}"); ThreadPool.QueueUserWorkItem(ProcessMailClient, clientSocket); } } catch (SocketException ex) { if (!bRunning) break; Log($"SocketException accepting client: {ex.Message}"); } catch (Exception ex) { Log($"Unexpected exception accepting client: {ex.Message}"); } } } catch (Exception ex) { Log($"Fatal listening error: {ex}"); } } private void ProcessMailClient(object stateInfo) { Socket socket = (Socket)stateInfo; socket.ReceiveTimeout = 180000; socket.SendTimeout = 180000; IPEndPoint remoteEndPoint = socket.RemoteEndPoint as IPEndPoint; string clientIp = remoteEndPoint?.Address.ToString() ?? "0.0.0.0"; string logDir = Path.Combine(ConfigurationManager.AppSettings["LogFolder"] ?? "C:\\Logs\\", "in"); Directory.CreateDirectory(logDir); string logFileName = Path.Combine(logDir, $"{Guid.NewGuid()}_{clientIp}.log"); Log($"New Client Connected from {clientIp}", logFileName); byte[] welcome = Encoding.ASCII.GetBytes("220 Connected to SBC SMTPProxy.net\r\n"); socket.Send(welcome); List rcptList = new List(); byte[] buffer = new byte[4096]; StringBuilder commandBuffer = new StringBuilder(); string heloHeader = ""; string mailFrom = ""; string dataBody = ""; bool receivingData = false; try { while (socket.Connected) { int bytesRead = socket.Receive(buffer, SocketFlags.None); if (bytesRead == 0) { Log("Client disconnected gracefully.", logFileName); break; } string chunk = Encoding.Default.GetString(buffer, 0, bytesRead); commandBuffer.Append(chunk); if (receivingData) { dataBody += chunk; if (dataBody.EndsWith("\r\n.\r\n") || dataBody == ".\r\n") { receivingData = false; Email email = new Email { sHELO = heloHeader, sMAIL = mailFrom, sRCPTs = new List(rcptList), sDATA = dataBody, FromIP = clientIp }; EmailQueue.Enqueue(email); Log("Queued inbound message.", logFileName); mailFrom = ""; dataBody = ""; rcptList.Clear(); commandBuffer.Clear(); byte[] okBytes = Encoding.ASCII.GetBytes("250 OK\r\n"); socket.Send(okBytes); Log("250 OK", logFileName); } continue; } // Process complete commands ending in newline string fullBuffer = commandBuffer.ToString(); if (!fullBuffer.Contains("\r\n")) { continue; } string[] lines = fullBuffer.Split(new[] { "\r\n" }, StringSplitOptions.None); // Keep uncompleted line in buffer commandBuffer.Clear(); commandBuffer.Append(lines[lines.Length - 1]); for (int i = 0; i < lines.Length - 1; i++) { string line = lines[i].Trim(); if (string.IsNullOrEmpty(line)) continue; string verb = line.Length >= 4 ? line.Substring(0, 4).ToUpperInvariant() : line.ToUpperInvariant(); string response = "500 Invalid Command\r\n"; switch (verb) { case "HELO": case "EHLO": heloHeader = line + "\r\n"; response = "250 Hi\r\n"; break; case "MAIL": if (string.IsNullOrEmpty(heloHeader)) { response = "503 Do HELO first\r\n"; } else if (!string.IsNullOrEmpty(mailFrom)) { response = "503 Already have MAIL\r\n"; } else { mailFrom = line + "\r\n"; response = "250 MAIL command recorded\r\n"; } break; case "RCPT": if (string.IsNullOrEmpty(heloHeader)) { response = "503 Do HELO first\r\n"; } else if (string.IsNullOrEmpty(mailFrom)) { response = "503 Do MAIL first\r\n"; } else { rcptList.Add(line + "\r\n"); response = "250 RCPT command recorded\r\n"; } break; case "DATA": if (string.IsNullOrEmpty(heloHeader)) { response = "503 Do HELO first\r\n"; } else if (string.IsNullOrEmpty(mailFrom)) { response = "503 Do MAIL first\r\n"; } else if (rcptList.Count == 0) { response = "503 Do RCPT first\r\n"; } else { response = "354 Start mail input; end with .\r\n"; receivingData = true; dataBody = ""; } break; case "RSET": response = "250 OK\r\n"; mailFrom = ""; dataBody = ""; rcptList.Clear(); break; case "QUIT": byte[] quitBytes = Encoding.ASCII.GetBytes("221 Bye\r\n"); socket.Send(quitBytes); socket.Shutdown(SocketShutdown.Both); socket.Close(); return; default: Log($"Unknown command: {line}", logFileName); break; } byte[] respBytes = Encoding.ASCII.GetBytes(response); socket.Send(respBytes); Log(response.TrimEnd(), logFileName); } } } catch (Exception ex) { Log($"Session exception: {ex.Message}", logFileName); } finally { try { if (socket.Connected) { socket.Shutdown(SocketShutdown.Both); socket.Close(); } } catch { } } // Trigger worker threads if backlog grows int threadMax = int.TryParse(ConfigurationManager.AppSettings["ThreadMax"], out var tm) ? tm : 5; if (EmailQueue.Count > 5 && Interlocked.Read(ref ThreadCount) < threadMax) { Interlocked.Increment(ref ThreadCount); ThreadPool.QueueUserWorkItem(state => { try { SendQueuedEmails(); } finally { Interlocked.Decrement(ref ThreadCount); } }); } } private void Log(string info, string logFileName = null) { if (ConsoleMode) { Console.WriteLine(info?.Replace("\r\n", "")); } if (!string.IsNullOrEmpty(logFileName)) { try { using (StreamWriter sw = File.AppendText(logFileName)) { sw.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {info}"); } return; } catch { } } lock (_syncObject) { try { string logDir = Path.Combine(ConfigurationManager.AppSettings["LogFolder"] ?? "C:\\Logs\\", "in"); Directory.CreateDirectory(logDir); string path = Path.Combine(logDir, $"{DateTime.Now:yyyyMMdd}.log"); using (StreamWriter sw = File.AppendText(path)) { sw.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {info}"); } } catch { } } } private void SendQueuedEmails() { string profilesBase = ConfigurationManager.AppSettings["Profiles"] ?? "C:\\Profiles\\"; while (EmailQueue.TryDequeue(out Email email)) { string cleanSender = email.sMAIL .Replace(" ", "") .Replace("MAILFROM:", "") .Replace("<", "") .Replace(">", "") .Replace("\r", "") .Replace("\n", ""); string profilePath = Path.Combine(profilesBase, email.FromIP, $"{cleanSender}.json"); if (!File.Exists(profilePath)) { int atIndex = cleanSender.IndexOf('@'); string domain = atIndex >= 0 ? cleanSender.Substring(atIndex + 1) : ""; profilePath = Path.Combine(profilesBase, email.FromIP, $"default@{domain}.json"); if (!File.Exists(profilePath)) { profilePath = Path.Combine(profilesBase, email.FromIP, "default.json"); } } if (!File.Exists(profilePath)) { Log($"No configuration profile found for IP {email.FromIP} and sender {cleanSender}"); continue; } JObject config; try { config = JObject.Parse(File.ReadAllText(profilePath)); } catch (Exception ex) { Log($"Error parsing JSON profile at {profilePath}: {ex.Message}"); continue; } bool loggingEnabled = config["logging"]?["enabled"]?.Value() == 1; string logBasePath = config["logging"]?["path"]?.ToString() ?? "C:\\Logs\\"; bool hideRecipients = config["HideRecipients"]?.Value() == 1; string logDir = Path.Combine(logBasePath, "log"); string outDir = Path.Combine(logBasePath, "out"); Directory.CreateDirectory(logDir); Directory.CreateDirectory(outDir); Guid guid = Guid.NewGuid(); string primaryRcpt = email.sRCPTs.Count > 0 ? email.GetRecipientEmail(email.sRCPTs[0]) : "unknown"; string mailLogPath = Path.Combine(logDir, $"{primaryRcpt}.{guid}.log"); if (loggingEnabled) { File.WriteAllText(Path.Combine(outDir, $"{primaryRcpt}.{guid}.eml"), email.sDATA); File.WriteAllText(Path.Combine(outDir, $"{primaryRcpt}.{guid}.HELO"), email.sHELO); File.WriteAllText(Path.Combine(outDir, $"{primaryRcpt}.{guid}.MAIL"), email.sMAIL); File.WriteAllLines(Path.Combine(outDir, $"{primaryRcpt}.{guid}.RCPTS"), email.sRCPTs); } string smtpHost = config["smtpserver"]?.ToString(); int smtpPort = config["smtpserverport"]?.Value() ?? 25; if (string.IsNullOrEmpty(smtpHost)) { Log("Missing smtpserver target in configuration profile.", mailLogPath); continue; } bool replaceRecipients = config["recipientreplacement"]?["enabled"]?.Value() == 1; List replacementList = config["recipientreplacement"]?["replacements"]?.ToObject>() ?? new List(); bool filterRecipients = config["recipientfilter"]?["enabled"]?.Value() == 1; List whitelist = config["recipientfilter"]?["whitelist"]?.ToObject>() ?? new List(); bool bccEnabled = config["bcc"]?["enabled"]?.Value() == 1; List bccRecipients = config["bcc"]?["recipients"]?.ToObject>() ?? new List(); Socket relaySocket = null; try { IPAddress targetIp; if (!IPAddress.TryParse(smtpHost, out targetIp)) { var hostEntry = Dns.GetHostEntry(smtpHost); targetIp = hostEntry.AddressList.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? hostEntry.AddressList[0]; } relaySocket = new Socket(targetIp.AddressFamily, SocketType.Stream, ProtocolType.Tcp); relaySocket.ReceiveTimeout = 60000; relaySocket.SendTimeout = 60000; relaySocket.Connect(new IPEndPoint(targetIp, smtpPort)); if (!CheckAndSend("220", email.sHELO, relaySocket, mailLogPath) || !CheckAndSend("250", email.sMAIL, relaySocket, mailLogPath)) { continue; } bool hasValidRecipients = false; if (replaceRecipients) { foreach (string rep in replacementList) { if (CheckAndSend("250", $"RCPT TO: <{rep}>\r\n", relaySocket, mailLogPath)) { hasValidRecipients = true; } } } else { foreach (string rcptLine in email.sRCPTs) { if (rcptLine.Trim().Length < 8) continue; if (filterRecipients) { string extractedEmail = email.GetRecipientEmail(rcptLine); if (!whitelist.Any(w => w.Equals(extractedEmail, StringComparison.OrdinalIgnoreCase))) { Log($"Filtering recipient: {extractedEmail}", mailLogPath); continue; } Log($"Recipient approved by whitelist: {extractedEmail}", mailLogPath); } if (CheckAndSend("250", rcptLine, relaySocket, mailLogPath)) { hasValidRecipients = true; } } } if (!hasValidRecipients) { Log("No valid recipients accepted by upstream relay. Aborting.", mailLogPath); continue; } if (bccEnabled) { foreach (string bcc in bccRecipients) { CheckAndSend("250", $"RCPT TO: <{bcc}>\r\n", relaySocket, mailLogPath); } } if (CheckAndSend("250", "DATA\r\n", relaySocket, mailLogPath)) { string cleansedData = CleanseDataBlock(email.sDATA, hideRecipients); if (!cleansedData.EndsWith("\r\n")) cleansedData += "\r\n"; if (!cleansedData.EndsWith("\r\n.\r\n")) cleansedData += ".\r\n"; if (CheckAndSend("354", cleansedData, relaySocket, mailLogPath)) { CheckAndSend("250", "RSET\r\n", relaySocket, mailLogPath); CheckAndSend("250", "QUIT\r\n", relaySocket, mailLogPath); } } } catch (Exception ex) { Log($"Upstream relay transmission failed: {ex.Message}", mailLogPath); } finally { if (relaySocket != null) { try { if (relaySocket.Connected) relaySocket.Shutdown(SocketShutdown.Both); relaySocket.Close(); } catch { } } } } } private static string CleanseDataBlock(string sData, bool bHideRecipients) { if (!bHideRecipients || string.IsNullOrEmpty(sData)) return sData; StringBuilder sb = new StringBuilder(); using (StringReader reader = new StringReader(sData)) { string line; bool inHeaderSection = true; bool skippingToHeader = false; while ((line = reader.ReadLine()) != null) { // Empty line marks end of email headers and start of MIME/Body if (inHeaderSection && line.Length == 0) { inHeaderSection = false; skippingToHeader = false; } if (inHeaderSection) { if (Regex.IsMatch(line, @"^[Tt][Oo]:", RegexOptions.IgnoreCase)) { skippingToHeader = true; continue; // Strip To: line } // Check for header folding (continuation line starting with whitespace) if (skippingToHeader && (line.StartsWith(" ") || line.StartsWith("\t"))) { continue; // Strip folded recipient lines } else { skippingToHeader = false; } } sb.Append(line).Append("\r\n"); } } return sb.ToString(); } private bool CheckAndSend(string prevSuccessCode, string nextCommand, Socket sock, string logFileName) { byte[] buffer = new byte[4096]; try { int bytesRead = sock.Receive(buffer); if (bytesRead <= 0) { Log("Upstream closed connection unexpectedly.", logFileName); return false; } string response = Encoding.ASCII.GetString(buffer, 0, bytesRead); Log($"Upstream << {response.TrimEnd()}", logFileName); if (!response.StartsWith(prevSuccessCode, StringComparison.Ordinal)) { Log($"Expected response code {prevSuccessCode}, received: {response}", logFileName); return false; } Log($"Upstream >> {(nextCommand.Length > 200 ? nextCommand.Substring(0, 200) + "..." : nextCommand.TrimEnd())}", logFileName); byte[] sendBytes = Encoding.ASCII.GetBytes(nextCommand); sock.Send(sendBytes); return true; } catch (Exception ex) { Log($"CheckAndSend failed: {ex.Message}", logFileName); return false; } } protected override void Dispose(bool disposing) { if (disposing) { components?.Dispose(); mre?.Dispose(); } base.Dispose(disposing); } private void InitializeComponent() { components = new Container(); base.ServiceName = "SMTPProxy.net"; } } }