Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端

This commit is contained in:
WpyQwq
2026-09-19 11:19:40 +08:00
commit b8814a7615
84 changed files with 21197 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
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<string>();
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 <CRLF>.<CRLF>");
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<string?> 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<byte[]> ReadData(StreamReader reader, CancellationToken token)
{
var lines = new List<string>();
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); }
}