Files
wpywmail/server-native-v2/SmtpServer.cs
T

510 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// SMTP 服务端:
/// 25 端口 —— 公网收信(只接受本地收件人,不中继)
/// 587 端口 —— 已认证的客户端发信
///
/// 相比 v1 的关键修正:
/// 1. DATA 阶段按字节读取(v1 用 StreamReader 读文本再拼回,破坏 8bit 内容与行尾);
/// 2. 只要加载到证书就广告 STARTTLS(v1 只在「非自签名」时才广告,而 AUTH 又要求
/// 加密,导致 587 端口完全无法认证的死锁);
/// 3. 收信时补 Received 头,并对认证失败做临时封禁。
/// </summary>
public sealed class SmtpServer
{
private static readonly ConcurrentDictionary<string, Failure> 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<string>();
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 <CRLF>.<CRLF>");
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();
}
}
/// <summary>把收到的报文落库;submission 模式则进入发件队列。</summary>
private async Task StoreIncomingAsync(byte[] raw, bool submission, string sender, List<string> 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<Attachment>();
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<string> 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<string>
{
$"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<string?> 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);
}