using System.Collections.Concurrent;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace WpywMail.Native;
///
/// SMTP 服务端:
/// 25 端口 —— 公网收信(只接受本地收件人,不中继)
/// 587 端口 —— 已认证的客户端发信
///
/// 相比 v1 的关键修正:
/// 1. DATA 阶段按字节读取(v1 用 StreamReader 读文本再拼回,破坏 8bit 内容与行尾);
/// 2. 只要加载到证书就广告 STARTTLS(v1 只在「非自签名」时才广告,而 AUTH 又要求
/// 加密,导致 587 端口完全无法认证的死锁);
/// 3. 收信时补 Received 头,并对认证失败做临时封禁。
///
public sealed class SmtpServer
{
private static readonly ConcurrentDictionary Failures = new();
private readonly AppConfig config;
private readonly IMailStore store;
private readonly X509Certificate2? certificate;
private readonly bool advertiseStartTls;
public SmtpServer(AppConfig config, IMailStore store)
{
this.config = config;
this.store = store;
if (!string.IsNullOrWhiteSpace(config.TlsCertificatePath) && File.Exists(config.TlsCertificatePath))
{
certificate = new X509Certificate2(config.TlsCertificatePath, config.TlsCertificatePassword);
var selfSigned = certificate.Subject.Equals(certificate.Issuer, StringComparison.OrdinalIgnoreCase);
advertiseStartTls = config.Smtp.AdvertiseStartTls;
AppLog.Info($"[SMTP] 已加载 TLS 证书:{certificate.Subject}({(selfSigned ? "自签名" : "受信任")},至 {certificate.NotAfter:yyyy-MM-dd})");
if (selfSigned) AppLog.Warn("[SMTP] 证书是自签名:STARTTLS 会正常广告,但部分严格客户端会拒绝,建议换取受信任证书。");
}
else
{
AppLog.Warn("[SMTP] 未找到 TLS 证书;STARTTLS 不可用,587 端口将无法完成认证(AUTH 要求加密)。");
AppLog.Warn("[SMTP] 请配置 TlsCertificatePath 指向 mail.<你的域名> 的 PFX 证书。");
}
}
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}(需 STARTTLS + AUTH)");
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(() => SafeHandleAsync(client, submission, token));
}
}
catch (OperationCanceledException) { }
catch (Exception ex) { AppLog.Error($"[SMTP] {(submission ? 587 : 25)} 接收循环异常:{ex.Message}"); }
finally { listener.Stop(); }
}
private async Task SafeHandleAsync(TcpClient client, bool submission, CancellationToken token)
{
try { await HandleClient(client, submission, token); }
catch (Exception ex) { AppLog.Error($"[SMTP] 会话处理异常:{ex.Message}"); }
finally { client.Dispose(); }
}
private async Task HandleClient(TcpClient client, bool submission, CancellationToken token)
{
var rawStream = client.GetStream();
Stream stream = rawStream;
var reader = new SmtpReader(stream);
var writer = NewWriter(stream);
var tls = false;
string? authenticatedUser = null;
string? sender = null;
var recipients = new List();
var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "未知地址";
var remote = client.Client.RemoteEndPoint?.ToString() ?? remoteIp;
var clientHelo = "";
try
{
if (IsBanned(remoteIp))
{
AppLog.Warn($"[SMTP] {remoteIp} 已被临时封禁,直接断开。");
await Send(writer, "421 Too many authentication failures, try again later");
return;
}
AppLog.Info($"[SMTP] 收到连接:{remote},模式={(submission ? "客户端发信" : "公网收信")}");
var banner = submission ? $"220 {config.Hostname} ESMTP WpywMail submission" : $"220 {config.Hostname} ESMTP WpywMail";
await Send(writer, banner);
while (!token.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(token);
if (line is null) break;
var upper = line.Trim().ToUpperInvariant();
if (upper.StartsWith("EHLO") || upper.StartsWith("HELO"))
{
clientHelo = line[(line.IndexOf(' ') + 1)..].Trim();
await SendCapabilities(writer, submission, tls);
}
else if (upper == "STARTTLS")
{
if (certificate is null || !advertiseStartTls)
{
await Send(writer, "454 TLS not available");
continue;
}
await Send(writer, "220 Ready to start TLS");
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
{
ServerCertificate = certificate,
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13,
ClientCertificateRequired = false,
}, token);
stream = ssl;
reader = new SmtpReader(stream);
writer = NewWriter(stream);
tls = true;
authenticatedUser = null;
AppLog.Info($"[SMTP] {remote} 已建立 TLS 会话。");
}
else if (upper.StartsWith("AUTH"))
{
var allowed = submission || config.Smtp.AllowAuthOnInbound;
if (!allowed || !tls)
{
// 关键修复:明确告诉客户端为什么不能认证,而不是让它无解
await Send(writer, "538 Encryption required for authentication");
continue;
}
var result = await AuthenticateAsync(line, reader, writer, token);
if (result is null)
{
RegisterFailure(remoteIp);
AppLog.Warn($"[SMTP] {remote} 认证失败。");
await Send(writer, "535 Authentication failed");
}
else
{
ClearFailures(remoteIp);
authenticatedUser = result;
AppLog.Info($"[SMTP] {remote} 认证成功:{result}");
await Send(writer, "235 Authentication successful");
}
}
else if (upper == "RSET")
{
sender = null;
recipients.Clear();
await Send(writer, "250 Reset");
}
else if (upper.StartsWith("NOOP"))
{
await Send(writer, "250 OK");
}
else if (upper.StartsWith("MAIL FROM:"))
{
// ── 安全修复(2026-09-15)───────────────────────────────────
// 原实现在这里「先赋值 sender,再做认证校验」,导致 MAIL FROM
// 被 530 拒绝之后 sender 依然非空,于是 RCPT 与 DATA 两处守卫
// (sender is null)全部失效:未认证客户端可以走完 RCPT + DATA
// 全流程,报文会被 SaveRaw 写进 blob 存储。开放中继之所以没有
// 真正打通,只是因为随后对 null 账号解引用抛了异常 —— 属偶然,
// 不是设计。现在改为通过全部校验后才赋值。
var candidate = Mime.Addresses(line).FirstOrDefault() ?? ExtractAddress(line);
sender = null;
recipients.Clear();
if (submission && authenticatedUser is null)
{
AppLog.Warn($"[SMTP] {remote} 未认证就尝试发信:{candidate}(已拒绝)");
await Send(writer, "530 Authentication required");
}
else if (submission && config.Smtp.EnforceSenderMatch &&
!string.Equals(candidate, authenticatedUser, StringComparison.OrdinalIgnoreCase))
{
AppLog.Warn($"[SMTP] {remote} 发件人 {candidate} 与已认证账号 {authenticatedUser} 不匹配。");
await Send(writer, "553 Sender must match authenticated mailbox");
}
else
{
sender = candidate;
AppLog.Info($"[SMTP] {remote} MAIL FROM:{sender}");
await Send(writer, "250 2.1.0 Sender accepted");
}
}
else if (upper.StartsWith("RCPT TO:"))
{
var recipient = Mime.Addresses(line).FirstOrDefault() ?? ExtractAddress(line);
if (sender is null)
{
await Send(writer, "503 5.5.1 Need MAIL FROM first");
}
else if (submission && authenticatedUser is null)
{
// 安全修复(2026-09-15):提交端口必须已认证才允许指定收件人。
// 原实现只在 MAIL FROM 处拦未认证,收件人完全不校验,且中继
// 检查写成 !submission,于是 587 上外域地址也会被 250 接受。
AppLog.Warn($"[SMTP] {remote} 未认证的提交会话尝试指定收件人:{recipient}(已拒绝)");
await Send(writer, "530 Authentication required");
}
else if (!submission && !store.IsLocalAddress(recipient))
{
AppLog.Warn($"[SMTP] {remote} 非本地收件人被拒绝:{recipient}");
await Send(writer, "550 5.7.1 Relay denied");
}
else
{
recipients.Add(recipient);
AppLog.Info($"[SMTP] {remote} RCPT TO:{recipient}");
await Send(writer, "250 2.1.5 Recipient accepted");
}
}
else if (upper == "DATA")
{
if (sender is null || recipients.Count == 0)
{
await Send(writer, "503 5.5.1 Need sender and recipient");
continue;
}
if (submission && authenticatedUser is null)
{
// 安全修复(2026-09-15):双保险。即使前面的状态机被绕过,
// 也绝不让未认证会话进入数据阶段 —— 否则读取到的报文会先被
// SaveRaw 落盘,形成未认证、无限速、可并发的写盘路径。
AppLog.Error($"[SMTP] {remote} 未认证的提交会话尝试 DATA,已拒绝。");
await Send(writer, "530 Authentication required");
sender = null;
recipients.Clear();
continue;
}
await Send(writer, "354 End data with .");
byte[] raw;
try
{
raw = await reader.ReadDataAsync(config.Smtp.MaxMessageBytes, token);
}
catch (InvalidOperationException ex)
{
await Send(writer, $"552 5.3.4 {ex.Message}");
sender = null;
recipients.Clear();
continue;
}
await StoreIncomingAsync(raw, submission, sender, recipients, authenticatedUser, remoteIp, remote, clientHelo, token);
await Send(writer, "250 2.0.0 Message accepted");
sender = null;
recipients.Clear();
}
else if (upper == "QUIT")
{
await Send(writer, "221 Bye");
break;
}
else
{
await Send(writer, "502 5.5.2 Command not implemented");
}
}
}
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or ObjectDisposedException) { }
catch (Exception ex) { AppLog.Error($"[SMTP] {remote} 会话错误:{ex.Message}"); }
finally
{
rawStream.Dispose();
}
}
/// 把收到的报文落库;submission 模式则进入发件队列。
private async Task StoreIncomingAsync(byte[] raw, bool submission, string sender, List recipients,
string? authenticatedUser, string remoteIp, string remote, string helo, CancellationToken token)
{
if (config.Smtp.AddReceivedHeader && !submission)
{
raw = AddReceivedHeader(raw, remoteIp, recipients);
}
if (submission)
{
if (string.IsNullOrEmpty(authenticatedUser))
{
// 安全修复(2026-09-15):提交模式必须有已认证账号。
// 原实现直接使用 authenticatedUser!(null 宽容运算符),在未认证
// 路径下传入 null,最终在 SqliteStore.InsertMessageLocked 里对
// OwnerEmail 解引用抛 NullReferenceException —— 落盘已经发生,
// 异常只是恰好阻止了入队。
AppLog.Error($"[SMTP] 拒绝入队:提交会话没有已认证账号(发件人 {sender})。");
return;
}
var queued = Mime.Parse(raw);
// 客户端提交的报文按原样排队(DKIM 在投递时签名),但正文/主题仍解析入库便于列表展示
store.QueueOutbound(authenticatedUser!, recipients.ToArray(),
queued.Subject, queued.Text, raw, queued.Cc, queued.Html, queued.InReplyTo);
AppLog.Info($"[SMTP] 已进入发件队列:{authenticatedUser} → {string.Join(", ", recipients)},主题:{queued.Subject}");
return;
}
// ── 入站身份校验:SPF / DKIM / DMARC(默认只标注 + 投垃圾箱,不拒收)
InboundAuthVerdict? verdict = null;
if (config.InboundAuth.Enabled)
{
try
{
verdict = await InboundAuth.CheckAsync(raw, remoteIp, helo, sender, config, token);
raw = InboundAuth.PrependHeaders(raw, verdict.HeaderBlock);
AppLog.Info($"[入站校验] {sender} → {string.Join(", ", recipients)}:spf={verdict.Spf}({verdict.SpfDomain}) "
+ $"dkim={verdict.Dkim} dmarc={verdict.Dmarc}(p={verdict.DmarcPolicy}) 分数={verdict.Score}"
+ (verdict.Spam ? " → 投垃圾箱" : ""));
foreach (var reason in verdict.Reasons) AppLog.Warn($"[入站校验] {sender}:{reason}");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// 校验自身失败不能影响收信:宁可放过也不丢信
AppLog.Error($"[入站校验] 出错(按未判定处理):{ex.Message}");
}
}
var parsed = Mime.Parse(raw);
var folder = verdict is { Spam: true } ? "spam" : "inbox";
foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (store.FindUser(recipient) is null) continue;
var attachments = new List();
foreach (var attachment in parsed.Attachments)
{
if (attachment.Data.Length == 0) continue;
attachments.Add(new Attachment
{
FileName = attachment.FileName,
ContentType = attachment.ContentType,
Size = attachment.Data.Length,
StoredAs = store.SaveAttachment(attachment.Data, attachment.FileName),
ContentId = attachment.ContentId,
Inline = attachment.Inline,
});
}
store.SaveMessage(new MailMessage
{
OwnerEmail = recipient.ToLowerInvariant(),
Folder = folder,
From = parsed.From.Length > 0 ? parsed.From : sender,
To = recipient,
Cc = parsed.Cc,
Subject = parsed.Subject,
Text = parsed.Text,
Html = parsed.Html,
MessageId = parsed.MessageId,
InReplyTo = parsed.InReplyTo,
References = parsed.References,
Date = parsed.Date ?? DateTimeOffset.UtcNow,
ReceivedAt = DateTimeOffset.UtcNow,
DeliveryStatus = verdict is { Spam: true } ? "received-spam" : "received",
Unread = true,
Attachments = attachments,
}, raw);
}
AppLog.Info($"[SMTP] 已接收邮件:{sender} → {string.Join(", ", recipients)},主题:{parsed.Subject}"
+ $"({(folder == "spam" ? "垃圾箱" : "收件箱")}{parsed.Attachments.Count switch { > 0 => $",附件 {parsed.Attachments.Count} 个", _ => "" }})");
}
private byte[] AddReceivedHeader(byte[] raw, string remoteIp, List recipients)
{
var header = $"Received: from {remoteIp} by {config.Hostname} with ESMTP id {Guid.NewGuid():N} " +
$"for <{recipients.FirstOrDefault()}>; {Mime.FormatDate(DateTimeOffset.Now)}{"\r\n"}";
var output = new MemoryStream(raw.Length + header.Length + 16);
output.Write(Encoding.ASCII.GetBytes(header));
output.Write(raw);
return output.ToArray();
}
private async Task SendCapabilities(StreamWriter writer, bool submission, bool tls)
{
var capabilities = new List
{
$"SIZE {config.Smtp.MaxMessageBytes}",
"8BITMIME",
"PIPELINING",
"ENHANCEDSTATUSCODES",
};
if (certificate is not null && advertiseStartTls && !tls) capabilities.Add("STARTTLS");
if (tls && (submission || config.Smtp.AllowAuthOnInbound)) capabilities.Add("AUTH PLAIN LOGIN");
await Send(writer, $"250-{config.Hostname}");
for (var i = 0; i < capabilities.Count; i++)
{
var prefix = i == capabilities.Count - 1 ? "250 " : "250-";
await Send(writer, prefix + capabilities[i]);
}
}
private async Task AuthenticateAsync(string command, SmtpReader reader, StreamWriter writer, CancellationToken token)
{
var parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
string? email = null;
string? password = null;
if (parts.Length >= 2 && parts[1].Equals("PLAIN", StringComparison.OrdinalIgnoreCase))
{
var payload = parts.Length >= 3 ? parts[2] : null;
if (string.IsNullOrEmpty(payload))
{
await Send(writer, "334 ");
payload = (await reader.ReadLineAsync(token) ?? "").Trim();
}
var bytes = TryBase64(payload);
if (bytes is null) return null;
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))
{
string? userPayload = parts.Length >= 3 ? parts[2] : null;
if (string.IsNullOrEmpty(userPayload))
{
await Send(writer, "334 VXNlcm5hbWU6");
userPayload = (await reader.ReadLineAsync(token) ?? "").Trim();
}
await Send(writer, "334 UGFzc3dvcmQ6");
var passwordPayload = (await reader.ReadLineAsync(token) ?? "").Trim();
var userBytes = TryBase64(userPayload);
var passwordBytes = TryBase64(passwordPayload);
if (userBytes is null || passwordBytes is null) return null;
email = Encoding.UTF8.GetString(userBytes);
password = Encoding.UTF8.GetString(passwordBytes);
}
else
{
await Send(writer, "504 5.5.4 Authentication mechanism not supported");
return null;
}
return store.Authenticate(email ?? "", password ?? "")?.Email;
}
private static byte[]? TryBase64(string value)
{
try { return Convert.FromBase64String(value.Trim()); }
catch { return null; }
}
// ---------------------------------------------------------------- 认证失败封禁
private sealed record Failure(int Count, DateTimeOffset Until);
private static bool IsBanned(string ip)
{
if (!Failures.TryGetValue(ip, out var failure)) return false;
if (failure.Until > DateTimeOffset.UtcNow) return true;
Failures.TryRemove(ip, out _);
return false;
}
private void RegisterFailure(string ip)
{
var threshold = Math.Max(1, config.Smtp.AuthFailuresBeforeBan);
var ban = TimeSpan.FromMinutes(Math.Max(1, config.Smtp.BanMinutes));
Failures.AddOrUpdate(ip,
_ => new Failure(1, DateTimeOffset.MinValue),
(_, existing) => new Failure(existing.Count + 1, existing.Count + 1 >= threshold ? DateTimeOffset.UtcNow.Add(ban) : existing.Until));
if (Failures.TryGetValue(ip, out var current) && current.Count >= threshold)
AppLog.Warn($"[SMTP] {ip} 认证失败 {current.Count} 次,已临时封禁 {ban.TotalMinutes:0} 分钟。");
}
private static void ClearFailures(string ip) => Failures.TryRemove(ip, out _);
// ---------------------------------------------------------------- 工具
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();
}
// 控制通道只传 ASCII 命令;DATA 走 SmtpReader 的字节通道
private static StreamWriter NewWriter(Stream stream) => new(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = "\r\n" };
private static Task Send(StreamWriter writer, string value) => writer.WriteLineAsync(value);
}