using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Cryptography.X509Certificates; using System.Text; namespace WpywMail.Native; public sealed class SmtpServer { private readonly AppConfig config; private readonly FileStore store; private readonly X509Certificate2? certificate; private readonly bool advertiseStartTls; public SmtpServer(AppConfig config, FileStore store) { this.config = config; this.store = store; if (File.Exists(config.TlsCertificatePath)) certificate = new X509Certificate2(config.TlsCertificatePath, config.TlsCertificatePassword); advertiseStartTls = certificate is not null && !certificate.Subject.Equals(certificate.Issuer, StringComparison.OrdinalIgnoreCase); if (certificate is null) AppLog.Warn("[SMTP] 未找到 TLS 证书;正式公开使用前请配置 TlsCertificatePath。"); else if (!advertiseStartTls) AppLog.Warn("[SMTP] 当前 TLS 证书是自签名证书,公网收信暂不发布 STARTTLS,避免远端因证书不受信而退信。"); } public async Task RunAsync(CancellationToken cancellationToken) { var inbound = new TcpListener(IPAddress.Any, config.SmtpPort); var submission = new TcpListener(IPAddress.Any, config.SubmissionPort); inbound.Start(); submission.Start(); AppLog.Info($"[SMTP] 收信端口已监听:{config.SmtpPort}"); AppLog.Info($"[SMTP] 客户端发信端口已监听:{config.SubmissionPort}"); await Task.WhenAll(AcceptLoop(inbound, false, cancellationToken), AcceptLoop(submission, true, cancellationToken)); } private async Task AcceptLoop(TcpListener listener, bool submission, CancellationToken token) { try { while (!token.IsCancellationRequested) { var client = await listener.AcceptTcpClientAsync(token); _ = Task.Run(() => HandleClient(client, submission, token), token); } } catch (OperationCanceledException) { } finally { listener.Stop(); } } private async Task HandleClient(TcpClient client, bool submission, CancellationToken token) { await using var rawStream = client.GetStream(); Stream stream = rawStream; var reader = NewReader(stream); var writer = NewWriter(stream); var tls = false; string? authenticatedUser = null; string? sender = null; var recipients = new List(); var remote = client.Client.RemoteEndPoint?.ToString() ?? "未知地址"; try { AppLog.Info($"[SMTP] 收到连接:{remote},模式={(submission ? "客户端发信" : "公网收信")}"); await Send(writer, $"220 {config.Hostname} ESMTP WpywMail"); while (!token.IsCancellationRequested) { var line = await reader.ReadLineAsync(token); if (line is null) break; var command = line.Trim(); var upper = command.ToUpperInvariant(); if (upper.StartsWith("EHLO") || upper.StartsWith("HELO")) { await SendMulti(writer, $"250-{config.Hostname}", "250-SIZE 26214400", "250-8BITMIME", "250-PIPELINING", advertiseStartTls && !tls ? "250-STARTTLS" : "250 AUTH LOGIN PLAIN"); if (advertiseStartTls && !tls) await Send(writer, "250 AUTH LOGIN PLAIN"); } else if (upper == "STARTTLS") { if (certificate is null || (!submission && !advertiseStartTls)) { await Send(writer, "454 TLS unavailable"); continue; } await Send(writer, "220 Ready to start TLS"); var ssl = new SslStream(stream, false); await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 }, token); stream = ssl; reader = NewReader(stream); writer = NewWriter(stream); tls = true; authenticatedUser = null; } else if (upper.StartsWith("AUTH")) { if (submission && !tls) { await Send(writer, "538 Encryption required for authentication"); continue; } authenticatedUser = await Authenticate(command, reader, writer, token); await Send(writer, authenticatedUser is null ? "535 Authentication failed" : "235 Authentication successful"); } else if (upper == "RSET") { sender = null; recipients.Clear(); await Send(writer, "250 Reset"); } else if (upper.StartsWith("MAIL FROM:")) { sender = ExtractAddress(command); recipients.Clear(); if (submission && authenticatedUser is null) { AppLog.Warn($"[SMTP] {remote} 未认证就尝试发信:{sender}"); await Send(writer, "530 Authentication required"); } else if (submission && !sender.Equals(authenticatedUser, StringComparison.OrdinalIgnoreCase)) { AppLog.Warn($"[SMTP] {remote} 发件人不匹配:{sender}"); await Send(writer, "553 Sender must match authenticated mailbox"); } else { AppLog.Info($"[SMTP] {remote} MAIL FROM:{sender}"); await Send(writer, "250 Sender accepted"); } } else if (upper.StartsWith("RCPT TO:")) { var recipient = ExtractAddress(command); if (sender is null) { AppLog.Warn($"[SMTP] {remote} 未先发送 MAIL FROM 就发送 RCPT TO:{recipient}"); await Send(writer, "503 Need MAIL FROM first"); } else if (!submission && !store.IsLocalAddress(recipient)) { AppLog.Warn($"[SMTP] {remote} 非本地收件人被拒绝:{recipient}"); await Send(writer, "550 Relay denied"); } else { recipients.Add(recipient); AppLog.Info($"[SMTP] {remote} RCPT TO:{recipient}"); await Send(writer, "250 Recipient accepted"); } } else if (upper == "DATA") { if (sender is null || recipients.Count == 0) { await Send(writer, "503 Need sender and recipient"); continue; } await Send(writer, "354 End data with ."); var raw = await ReadData(reader, token); var parsed = Mime.Parse(raw); if (submission) { store.QueueOutbound(authenticatedUser!, recipients.ToArray(), parsed.Subject, parsed.Text, raw); } else { foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase)) { if (store.FindUser(recipient) is not null) store.SaveMessage(new MailMessage { OwnerEmail = recipient.ToLowerInvariant(), Folder = "inbox", From = parsed.From.Length > 0 ? parsed.From : sender, To = recipient, Subject = parsed.Subject, Text = parsed.Text, MessageId = parsed.MessageId, Date = DateTimeOffset.UtcNow, DeliveryStatus = "received" }, raw); } AppLog.Info($"[SMTP] 已接收邮件:{sender} → {string.Join(", ", recipients)},主题:{parsed.Subject}"); } await Send(writer, "250 Message queued"); sender = null; recipients.Clear(); } else if (upper == "NOOP") await Send(writer, "250 OK"); else if (upper == "QUIT") { await Send(writer, "221 Bye"); break; } else await Send(writer, "502 Command not implemented"); } } catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) { } catch (Exception ex) { AppLog.Error($"[SMTP] {remote} 会话错误:{ex.Message}"); } finally { client.Dispose(); } } private async Task Authenticate(string command, StreamReader reader, StreamWriter writer, CancellationToken token) { var parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); string? email = null; string? password = null; if (parts.Length >= 3 && parts[1].Equals("PLAIN", StringComparison.OrdinalIgnoreCase)) { var bytes = Convert.FromBase64String(parts[2]); var values = Encoding.UTF8.GetString(bytes).Split('\0'); email = values.Length > 1 ? values[1] : null; password = values.Length > 2 ? values[2] : null; } else if (parts.Length >= 2 && parts[1].Equals("LOGIN", StringComparison.OrdinalIgnoreCase)) { await Send(writer, "334 VXNlcm5hbWU6"); email = Encoding.UTF8.GetString(Convert.FromBase64String(await reader.ReadLineAsync(token) ?? "")); await Send(writer, "334 UGFzc3dvcmQ6"); password = Encoding.UTF8.GetString(Convert.FromBase64String(await reader.ReadLineAsync(token) ?? "")); } else { await Send(writer, "504 Authentication mechanism not supported"); return null; } return store.Authenticate(email ?? "", password ?? "")?.Email; } private static async Task ReadData(StreamReader reader, CancellationToken token) { var lines = new List(); while (true) { var line = await reader.ReadLineAsync(token) ?? "."; if (line == ".") break; lines.Add(line.StartsWith("..") ? line[1..] : line); if (lines.Sum(x => x.Length) > 25 * 1024 * 1024) throw new InvalidOperationException("Message too large"); } return Encoding.UTF8.GetBytes(string.Join("\r\n", lines) + "\r\n"); } private static string ExtractAddress(string command) { var start = command.IndexOf('<'); var end = command.IndexOf('>', start + 1); return start >= 0 && end > start ? command[(start + 1)..end].Trim().ToLowerInvariant() : command[(command.IndexOf(':') + 1)..].Trim().ToLowerInvariant(); } private static StreamReader NewReader(Stream stream) => new(stream, Encoding.UTF8, false, 8192, true); private static StreamWriter NewWriter(Stream stream) => new(stream, new UTF8Encoding(false), 8192, true) { AutoFlush = true, NewLine = "\r\n" }; private static Task Send(StreamWriter writer, string value) => writer.WriteLineAsync(value); private static async Task SendMulti(StreamWriter writer, params string[] lines) { foreach (var line in lines) await Send(writer, line); } }