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
+131
View File
@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using System.Net;
using System.Text;
using System.Text.Json;
namespace WpywMail.Native;
public sealed class ApiServer
{
private readonly AppConfig config;
private readonly FileStore store;
private readonly HttpListener listener = new();
private readonly ConcurrentDictionary<string, Session> sessions = new();
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web);
public ApiServer(AppConfig config, FileStore store) { this.config = config; this.store = store; listener.Prefixes.Add(config.HttpPrefix); }
public async Task RunAsync(CancellationToken token)
{
listener.Start();
AppLog.Info($"[接口] 已监听:{config.HttpPrefix}");
try
{
while (!token.IsCancellationRequested)
{
var context = await listener.GetContextAsync().WaitAsync(token);
_ = Task.Run(() => Handle(context), token);
}
}
catch (OperationCanceledException) { }
finally { listener.Stop(); }
}
private async Task Handle(HttpListenerContext context)
{
var request = context.Request; var response = context.Response;
response.Headers["Access-Control-Allow-Origin"] = "*";
response.Headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type";
response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, OPTIONS";
if (request.HttpMethod == "OPTIONS") { response.StatusCode = 204; response.Close(); return; }
try
{
var path = request.Url?.AbsolutePath.TrimEnd('/') ?? "";
if (path == "/api/health") { await Reply(response, new { ok = true, service = "wpyw.mail.native", hostname = config.Hostname, domain = config.Domain }); return; }
if (path == "/api/login" && request.HttpMethod == "POST") { await Login(request, response); return; }
var user = Authenticate(request);
if (user is null) { await Reply(response, new { error = "登录已失效" }, 401); return; }
if (path == "/api/me") { await Reply(response, new { user = new { email = user.Email, role = user.Role }, stats = store.Stats(user.Email) }); return; }
if (path == "/api/config") { await Reply(response, new { domain = config.Domain, hostname = config.Hostname, account = user.Email, protocols = new { smtp = config.SmtpPort, submission = config.SubmissionPort, api = config.HttpPrefix } }); return; }
if (path == "/api/logout" && request.HttpMethod == "POST") { RemoveSession(request); await Reply(response, new { ok = true }); return; }
if (path == "/api/messages" && request.HttpMethod == "GET") { await ListMessages(request, response, user); return; }
if (path.StartsWith("/api/messages/", StringComparison.OrdinalIgnoreCase)) { await MessageDetail(request, response, user, path[14..]); return; }
if (path == "/api/send" && request.HttpMethod == "POST") { await SendMessage(request, response, user); return; }
if (path == "/api/account/password" && request.HttpMethod == "POST") { await ChangePassword(request, response, user); return; }
if (path == "/api/admin/users" && user.Role == "admin") { await AdminUsers(request, response); return; }
await Reply(response, new { error = "接口不存在" }, 404);
}
catch (Exception ex) { AppLog.Error($"[接口] {request.HttpMethod} {request.Url}:{ex.Message}"); await Reply(response, new { error = ex.Message }, 500); }
}
private async Task Login(HttpListenerRequest request, HttpListenerResponse response)
{
var body = await ReadJson<LoginRequest>(request) ?? new LoginRequest("", "");
var user = store.Authenticate(body.Email, body.Password);
if (user is null) { await Reply(response, new { error = "邮箱或密码不正确" }, 401); return; }
var token = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
sessions[token] = new Session(user.Email, DateTimeOffset.UtcNow.AddDays(7));
await Reply(response, new { token, user = new { email = user.Email, role = user.Role, domain = config.Domain } });
}
private MailUser? Authenticate(HttpListenerRequest request)
{
var token = request.Headers["Authorization"]?.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase).Trim();
if (string.IsNullOrWhiteSpace(token) || !sessions.TryGetValue(token, out var session) || session.Expires < DateTimeOffset.UtcNow) return null;
return store.FindUser(session.Email);
}
private void RemoveSession(HttpListenerRequest request)
{
var token = request.Headers["Authorization"]?.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase).Trim();
if (!string.IsNullOrWhiteSpace(token)) sessions.TryRemove(token, out _);
}
private async Task ListMessages(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var folder = request.QueryString["folder"] ?? "inbox"; var query = request.QueryString["q"] ?? "";
var result = store.ListMessages(user.Email, folder, query).Select(x => new { x.Id, x.From, x.To, x.Subject, x.Date, x.Unread, x.Starred, x.DeliveryStatus, preview = x.Text.Replace("\r", " ").Replace("\n", " ")[..Math.Min(140, x.Text.Length)] });
await Reply(response, new { messages = result });
}
private async Task MessageDetail(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string id)
{
var message = store.GetMessage(user.Email, id);
if (message is null) { await Reply(response, new { error = "邮件不存在" }, 404); return; }
store.MarkRead(user.Email, id);
await Reply(response, new { message });
}
private async Task SendMessage(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var body = await ReadJson<SendRequest>(request) ?? new SendRequest("", "", "");
var recipients = Mime.Addresses(body.To);
if (recipients.Length == 0 || string.IsNullOrWhiteSpace(body.Subject) || string.IsNullOrWhiteSpace(body.Text)) { await Reply(response, new { error = "收件人、主题和正文不能为空" }, 400); return; }
var raw = Mime.Build(user.Email, recipients, body.Subject, body.Text);
var message = store.QueueOutbound(user.Email, recipients, body.Subject, body.Text, raw);
await Reply(response, new { queued = true, messageId = message.Id }, 202);
}
private async Task ChangePassword(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var body = await ReadJson<Dictionary<string, string>>(request) ?? new();
if (!body.TryGetValue("password", out var password) || password.Length < 12) { await Reply(response, new { error = "密码至少需要 12 个字符" }, 400); return; }
store.ChangePassword(user.Email, password); await Reply(response, new { ok = true });
}
private async Task AdminUsers(HttpListenerRequest request, HttpListenerResponse response)
{
if (request.HttpMethod == "GET") { await Reply(response, new { users = store.ListUsers().Select(x => new { x.Email, x.DisplayName, x.Role, x.Active }) }); return; }
if (request.HttpMethod == "POST")
{
var body = await ReadJson<Dictionary<string, string>>(request) ?? new();
if (!body.TryGetValue("email", out var email) || !body.TryGetValue("password", out var password) || password.Length < 12) { await Reply(response, new { error = "邮箱和至少 12 位密码是必需的" }, 400); return; }
var user = store.CreateUser(email, password, body.GetValueOrDefault("displayName", "")); await Reply(response, new { user = new { user.Email, user.DisplayName, user.Role } }, 201); return;
}
await Reply(response, new { error = "不支持的请求方法" }, 405);
}
private static async Task<T?> ReadJson<T>(HttpListenerRequest request) { using var reader = new StreamReader(request.InputStream, Encoding.UTF8); return JsonSerializer.Deserialize<T>(await reader.ReadToEndAsync(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); }
private async Task Reply(HttpListenerResponse response, object value, int status = 200) { response.StatusCode = status; response.ContentType = "application/json; charset=utf-8"; var bytes = JsonSerializer.SerializeToUtf8Bytes(value, json); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); response.Close(); }
private sealed record Session(string Email, DateTimeOffset Expires);
}
+32
View File
@@ -0,0 +1,32 @@
using System.Text;
namespace WpywMail.Native;
public static class AppLog
{
private static readonly object Gate = new();
private static string path = "";
public static void Configure(AppConfig config)
{
Directory.CreateDirectory(config.DataDirectory);
path = Path.Combine(config.DataDirectory, "service.log");
Info("日志系统已启动。");
}
public static void Info(string message) => Write("信息", message);
public static void Warn(string message) => Write("警告", message);
public static void Error(string message) => Write("错误", message);
private static void Write(string level, string message)
{
var line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz} [{level}] {message}";
Console.WriteLine(line);
if (string.IsNullOrWhiteSpace(path)) return;
try
{
lock (Gate) File.AppendAllText(path, line + Environment.NewLine, new UTF8Encoding(false));
}
catch { }
}
}
+75
View File
@@ -0,0 +1,75 @@
using System.Net;
using System.Net.Mail;
namespace WpywMail.Native;
public sealed class DeliveryQueue
{
private readonly AppConfig config;
private readonly FileStore store;
private readonly DirectSmtpDelivery direct;
public DeliveryQueue(AppConfig config, FileStore store)
{
this.config = config;
this.store = store;
direct = new DirectSmtpDelivery(config, store);
}
public async Task RunAsync(CancellationToken token)
{
AppLog.Info($"[发送] 投递模式:{(config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) ? "SMTP 中继" : "按 MX 直接投递")}");
while (!token.IsCancellationRequested)
{
foreach (var job in store.TakeDueQueue(10))
{
try
{
var message = store.GetById(job.MessageId) ?? throw new InvalidOperationException("发送队列中的邮件不存在。");
await Deliver(message, job.Recipients, token);
store.CompleteQueue(job);
AppLog.Info($"[发送] 投递成功:{string.Join(", ", job.Recipients)}");
}
catch (Exception ex)
{
store.FailQueue(job, ex);
AppLog.Error($"[发送队列] {job.MessageId} → {string.Join(", ", job.Recipients)}:{ex.Message}");
}
}
await Task.Delay(TimeSpan.FromSeconds(5), token).ContinueWith(_ => { });
}
}
private async Task Deliver(MailMessage message, string[] recipients, CancellationToken token)
{
if (config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase))
{
await DeliverThroughRelay(message, recipients, token);
return;
}
await direct.DeliverAsync(message, recipients, token);
}
private async Task DeliverThroughRelay(MailMessage message, string[] recipients, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(config.Relay.Host)) throw new InvalidOperationException("DeliveryMode=relay 时必须配置 Relay.Host。");
using var client = new SmtpClient(config.Relay.Host, config.Relay.Port)
{
EnableSsl = config.Relay.EnableSsl,
DeliveryMethod = SmtpDeliveryMethod.Network,
Timeout = 60_000
};
if (!string.IsNullOrWhiteSpace(config.Relay.User)) client.Credentials = new NetworkCredential(config.Relay.User, config.Relay.Password);
using var mail = new System.Net.Mail.MailMessage
{
From = new MailAddress(message.From),
Subject = message.Subject,
Body = message.Text,
BodyEncoding = System.Text.Encoding.UTF8,
SubjectEncoding = System.Text.Encoding.UTF8
};
foreach (var recipient in recipients) mail.To.Add(recipient);
await client.SendMailAsync(mail, token);
}
}
+317
View File
@@ -0,0 +1,317 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Text;
namespace WpywMail.Native;
public sealed class DirectSmtpDelivery
{
private readonly AppConfig config;
private readonly FileStore store;
public DirectSmtpDelivery(AppConfig config, FileStore store)
{
this.config = config;
this.store = store;
}
public async Task DeliverAsync(MailMessage message, string[] recipients, CancellationToken token)
{
if (recipients.Length == 0) throw new InvalidOperationException("没有可投递的收件人。");
var raw = store.ReadRaw(message.RawPath);
foreach (var group in recipients.Where(IsValidAddress).GroupBy(GetDomain, StringComparer.OrdinalIgnoreCase))
{
await DeliverDomainAsync(message.From, group.Key, group.ToArray(), raw, token);
}
}
private async Task DeliverDomainAsync(string sender, string domain, string[] recipients, byte[] raw, CancellationToken token)
{
var mxHosts = await MxResolver.ResolveAsync(domain, config.DirectDelivery, token);
if (mxHosts.Count == 0) throw new InvalidOperationException($"找不到 {domain} 的 MX 记录。");
Exception? last = null;
foreach (var mxHost in mxHosts)
{
try
{
await SendToMxAsync(mxHost, sender, recipients, raw, token, tryStartTls: true);
return;
}
catch (StartTlsUnavailableException) when (!config.DirectDelivery.RequireStartTls)
{
try
{
await SendToMxAsync(mxHost, sender, recipients, raw, token, tryStartTls: false);
return;
}
catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException or AuthenticationException)
{
last = ex;
AppLog.Warn($"[发送] MX {mxHost} 明文重试失败:{ex.Message}");
}
}
catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException or AuthenticationException)
{
last = ex;
AppLog.Warn($"[发送] MX {mxHost} 失败:{ex.Message}");
}
}
throw new InvalidOperationException($"无法投递到 {domain}:{last?.Message ?? "所有 MX 服务器均失败"}");
}
private async Task SendToMxAsync(string mxHost, string sender, string[] recipients, byte[] raw, CancellationToken token, bool tryStartTls)
{
using var client = new TcpClient { NoDelay = true };
await client.ConnectAsync(mxHost, 25, token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.ConnectionTimeoutSeconds), token);
Stream stream = client.GetStream();
StreamReader reader = NewReader(stream);
StreamWriter writer = NewWriter(stream);
try
{
Expect(await ReadReplyAsync(reader, token), "连接欢迎语", 220);
var hello = await CommandAsync(reader, writer, $"EHLO {config.Hostname}", token);
if (hello.Code < 200 || hello.Code >= 300)
{
Expect(await CommandAsync(reader, writer, $"HELO {config.Hostname}", token), "HELO", 250);
}
else if (tryStartTls && config.DirectDelivery.OpportunisticStartTls && HasCapability(hello, "STARTTLS"))
{
var startTls = await CommandAsync(reader, writer, "STARTTLS", token);
if (startTls.Code == 220)
{
try
{
var ssl = new SslStream(stream, leaveInnerStreamOpen: false, ValidateCertificate);
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
{
TargetHost = mxHost,
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
}, token).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
stream = ssl;
reader = NewReader(stream);
writer = NewWriter(stream);
hello = await CommandAsync(reader, writer, $"EHLO {config.Hostname}", token);
}
catch (AuthenticationException) when (!config.DirectDelivery.RequireStartTls)
{
throw new StartTlsUnavailableException("对方 STARTTLS 证书验证失败。");
}
}
else if (config.DirectDelivery.RequireStartTls)
{
throw new InvalidOperationException("对方 SMTP 不接受 STARTTLS。");
}
}
else if (config.DirectDelivery.RequireStartTls)
{
throw new InvalidOperationException("对方 SMTP 未提供 STARTTLS。");
}
Expect(await CommandAsync(reader, writer, $"MAIL FROM:<{NormalizeAddress(sender)}>", token), "MAIL FROM", 250);
foreach (var recipient in recipients)
{
Expect(await CommandAsync(reader, writer, $"RCPT TO:<{NormalizeAddress(recipient)}>", token), $"RCPT TO {recipient}", 250, 251);
}
Expect(await CommandAsync(reader, writer, "DATA", token), "DATA", 354);
await WriteDataAsync(writer, raw, token);
Expect(await ReadReplyAsync(reader, token), "邮件正文", 250);
await TryQuitAsync(reader, writer, token);
}
finally
{
await stream.DisposeAsync();
}
}
private async Task WriteDataAsync(StreamWriter writer, byte[] raw, CancellationToken token)
{
var text = Encoding.UTF8.GetString(raw).Replace("\r\n", "\n").Replace('\r', '\n');
var lines = text.Split('\n');
for (var index = 0; index < lines.Length; index++)
{
if (index == lines.Length - 1 && lines[index].Length == 0) continue;
var line = lines[index];
if (line.StartsWith('.')) line = "." + line;
await writer.WriteLineAsync(line).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
}
await writer.WriteLineAsync(".").WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
}
private async Task<SmtpReply> CommandAsync(StreamReader reader, StreamWriter writer, string command, CancellationToken token)
{
await writer.WriteLineAsync(command).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
return await ReadReplyAsync(reader, token);
}
private async Task<SmtpReply> ReadReplyAsync(StreamReader reader, CancellationToken token)
{
var lines = new List<string>();
var first = await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token)
?? throw new IOException("SMTP 连接提前关闭。");
lines.Add(first);
if (first.Length < 3 || !int.TryParse(first[..3], out var code)) throw new InvalidOperationException($"SMTP 返回无效响应:{first}");
if (first.Length > 3 && first[3] == '-')
{
while (true)
{
var line = await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token)
?? throw new IOException("SMTP 多行响应提前结束。");
lines.Add(line);
if (line.StartsWith($"{code:D3} ", StringComparison.Ordinal)) break;
}
}
return new SmtpReply(code, lines);
}
private static bool HasCapability(SmtpReply reply, string capability) =>
reply.Lines.Any(x => x.Length > 4 && x[4..].StartsWith(capability, StringComparison.OrdinalIgnoreCase));
private static void Expect(SmtpReply reply, string step, params int[] expected)
{
if (!expected.Contains(reply.Code)) throw new SmtpDeliveryException(step, reply.Code, reply.Lines.LastOrDefault() ?? "");
}
private static async Task TryQuitAsync(StreamReader reader, StreamWriter writer, CancellationToken token)
{
try
{
await writer.WriteLineAsync("QUIT").WaitAsync(TimeSpan.FromSeconds(5), token);
await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(5), token);
}
catch { }
}
private static StreamReader NewReader(Stream stream) => new(stream, Encoding.ASCII, false, 8192, true);
private static StreamWriter NewWriter(Stream stream) => new(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = "\r\n" };
private static bool ValidateCertificate(object sender, System.Security.Cryptography.X509Certificates.X509Certificate? certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, SslPolicyErrors errors) => errors == SslPolicyErrors.None;
private static bool IsValidAddress(string value) => value.Contains('@') && value.IndexOf('@') > 0 && value.IndexOf('@') < value.Length - 1;
private static string GetDomain(string value) => value[(value.LastIndexOf('@') + 1)..].Trim().TrimEnd('.').ToLowerInvariant();
private static string NormalizeAddress(string value) => value.Trim().Trim('<', '>');
private sealed record SmtpReply(int Code, IReadOnlyList<string> Lines);
private sealed class StartTlsUnavailableException(string message) : Exception(message);
}
public sealed class SmtpDeliveryException(string step, int code, string detail) : Exception($"{step} 失败:{code} {detail}")
{
public int Code { get; } = code;
public bool Permanent => Code >= 500 && Code <= 599;
}
internal static class MxResolver
{
public static async Task<IReadOnlyList<string>> ResolveAsync(string domain, DirectDeliveryConfig config, CancellationToken token)
{
var servers = GetDnsServers(config.DnsServer);
foreach (var server in servers)
{
try
{
var records = await QueryAsync(domain, server, config.DnsTimeoutSeconds, token);
if (records.Count > 0) return records;
}
catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException)
{
AppLog.Warn($"[DNS] 查询 {domain} 的 MX 失败({server}):{ex.Message}");
}
}
try
{
var addresses = await Dns.GetHostAddressesAsync(domain, token);
return addresses.Select(x => x.ToString()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
catch { return []; }
}
private static async Task<IReadOnlyList<string>> QueryAsync(string domain, IPAddress server, int timeoutSeconds, CancellationToken token)
{
using var udp = new UdpClient(server.AddressFamily);
var query = BuildQuery(domain, out var id);
await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53));
var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)), token);
return ParseResponse(result.Buffer, id);
}
private static byte[] BuildQuery(string domain, out ushort id)
{
id = (ushort)Random.Shared.Next(1, ushort.MaxValue);
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true);
writer.Write(ToNetwork(id)); writer.Write(ToNetwork((ushort)0x0100)); writer.Write(ToNetwork((ushort)1)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0));
foreach (var label in domain.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries))
{
var bytes = Encoding.ASCII.GetBytes(label);
writer.Write((byte)bytes.Length); writer.Write(bytes);
}
writer.Write((byte)0); writer.Write(ToNetwork((ushort)15)); writer.Write(ToNetwork((ushort)1));
return stream.ToArray();
}
private static IReadOnlyList<string> ParseResponse(byte[] data, ushort expectedId)
{
if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return [];
var flags = ReadUInt16(data, 2);
if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return [];
var questions = ReadUInt16(data, 4); var answers = ReadUInt16(data, 6); var authority = ReadUInt16(data, 8); var additional = ReadUInt16(data, 10);
var offset = 12;
for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; }
var records = new List<(ushort Preference, string Host)>();
for (var i = 0; i < answers + authority + additional && offset < data.Length; i++)
{
ReadName(data, ref offset);
if (offset + 10 > data.Length) break;
var type = ReadUInt16(data, offset); var cls = ReadUInt16(data, offset + 2); var length = ReadUInt16(data, offset + 8); offset += 10;
if (offset + length > data.Length) break;
if (type == 15 && cls == 1 && length >= 3)
{
var preference = ReadUInt16(data, offset); var nameOffset = offset + 2; var host = ReadName(data, ref nameOffset);
if (!string.IsNullOrWhiteSpace(host)) records.Add((preference, host.TrimEnd('.')));
}
offset += length;
}
return records.OrderBy(x => x.Preference).Select(x => x.Host).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private static string ReadName(byte[] data, ref int offset)
{
var labels = new List<string>(); var cursor = offset; var jumped = false; var next = offset;
while (cursor < data.Length)
{
var length = data[cursor++];
if (length == 0) { if (!jumped) next = cursor; break; }
if ((length & 0xC0) == 0xC0)
{
if (cursor >= data.Length) throw new InvalidOperationException("DNS 名称指针无效。");
var pointer = ((length & 0x3F) << 8) | data[cursor++];
if (!jumped) next = cursor; cursor = pointer; jumped = true; continue;
}
if (length > 63 || cursor + length > data.Length) throw new InvalidOperationException("DNS 名称长度无效。");
labels.Add(Encoding.ASCII.GetString(data, cursor, length)); cursor += length;
}
offset = next;
return string.Join('.', labels);
}
private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]);
private static ushort ToNetwork(ushort value) => (ushort)((value << 8) | (value >> 8));
private static IReadOnlyList<IPAddress> GetDnsServers(string configured)
{
if (IPAddress.TryParse(configured, out var parsed)) return [parsed];
var system = NetworkInterface.GetAllNetworkInterfaces()
.Where(x => x.OperationalStatus == OperationalStatus.Up)
.SelectMany(x => x.GetIPProperties().DnsAddresses)
.Where(x => x.AddressFamily == AddressFamily.InterNetwork || (x.AddressFamily == AddressFamily.InterNetworkV6 && !x.IsIPv6SiteLocal))
.Distinct()
.OrderBy(x => x.AddressFamily == AddressFamily.InterNetwork ? 0 : 1)
.ToArray();
return system.Length > 0 ? system : [IPAddress.Parse("223.5.5.5"), IPAddress.Parse("1.1.1.1")];
}
}
+234
View File
@@ -0,0 +1,234 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace WpywMail.Native;
public sealed class FileStore
{
private readonly object gate = new();
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
private readonly string usersPath;
private readonly string messagesPath;
private readonly string queuePath;
private readonly string rawDirectory;
private readonly AppConfig config;
private List<MailUser> users = [];
private List<MailMessage> messages = [];
private List<QueueItem> queue = [];
public FileStore(AppConfig config)
{
this.config = config;
Directory.CreateDirectory(config.DataDirectory);
rawDirectory = Path.Combine(config.DataDirectory, "raw");
Directory.CreateDirectory(rawDirectory);
usersPath = Path.Combine(config.DataDirectory, "users.json");
messagesPath = Path.Combine(config.DataDirectory, "messages.json");
queuePath = Path.Combine(config.DataDirectory, "queue.json");
Load();
EnsureAdmin();
}
private void Load()
{
lock (gate)
{
users = Read<List<MailUser>>(usersPath) ?? [];
messages = Read<List<MailMessage>>(messagesPath) ?? [];
queue = Read<List<QueueItem>>(queuePath) ?? [];
foreach (var item in queue.Where(x => x.Status == "processing"))
{
item.Status = "retry";
item.NextAttempt = DateTimeOffset.UtcNow;
}
}
}
private T? Read<T>(string path)
{
if (!File.Exists(path)) return default;
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), json); }
catch { return default; }
}
private void Write<T>(string path, T value)
{
var temp = path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(value, json), Encoding.UTF8);
File.Move(temp, path, true);
}
private void EnsureAdmin()
{
if (string.IsNullOrWhiteSpace(config.AdminPassword))
throw new InvalidOperationException("appsettings.json 中必须设置 AdminPassword。");
lock (gate)
{
if (users.Any(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase))) return;
users.Add(new MailUser
{
Email = config.AdminEmail.ToLowerInvariant(),
DisplayName = "Administrator",
Role = "admin",
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
});
users[^1].PasswordHash = HashPassword(config.AdminPassword, users[^1].PasswordSalt);
Write(usersPath, users);
}
}
public MailUser? FindUser(string email) => users.FirstOrDefault(x => x.Active && x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
public MailUser? Authenticate(string email, string password)
{
var user = FindUser(email);
return user is not null && VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
}
public bool IsLocalAddress(string email) => FindUser(email) is not null;
public IReadOnlyList<MailUser> ListUsers() => users.Where(x => x.Active).OrderBy(x => x.Email).ToArray();
public MailUser CreateUser(string email, string password, string displayName)
{
email = email.Trim().ToLowerInvariant();
if (FindUser(email) is not null) throw new InvalidOperationException("用户已存在。");
var user = new MailUser
{
Email = email,
DisplayName = displayName,
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
};
user.PasswordHash = HashPassword(password, user.PasswordSalt);
lock (gate) { users.Add(user); Write(usersPath, users); }
return user;
}
public void ChangePassword(string email, string password)
{
lock (gate)
{
var user = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("用户不存在。");
user.PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
user.PasswordHash = HashPassword(password, user.PasswordSalt);
Write(usersPath, users);
}
}
public string SaveRaw(byte[] raw)
{
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}.eml";
var relative = Path.Combine("raw", name);
File.WriteAllBytes(Path.Combine(config.DataDirectory, relative), raw);
return relative;
}
public byte[] ReadRaw(string relativePath)
{
var full = Path.GetFullPath(Path.Combine(config.DataDirectory, relativePath));
if (!full.StartsWith(Path.GetFullPath(rawDirectory), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("非法文件路径。");
return File.ReadAllBytes(full);
}
public MailMessage SaveMessage(MailMessage message, byte[]? raw = null)
{
if (raw is not null) message.RawPath = SaveRaw(raw);
lock (gate) { messages.Add(message); Write(messagesPath, messages); }
return message;
}
public IReadOnlyList<MailMessage> ListMessages(string owner, string folder, string query)
{
query = query.Trim();
lock (gate)
{
return messages.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
.Where(x => x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
.Where(x => query.Length == 0 || $"{x.From} {x.To} {x.Subject} {x.Text}".Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(x => x.Date).ToArray();
}
}
public MailMessage? GetMessage(string owner, string id) => messages.FirstOrDefault(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
public void MarkRead(string owner, string id, bool read = true)
{
lock (gate) { var item = GetMessage(owner, id); if (item is null) return; item.Unread = !read; Write(messagesPath, messages); }
}
public MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text, byte[] raw)
{
var message = new MailMessage { OwnerEmail = owner, Folder = "sent", From = owner, To = string.Join(", ", recipients), Subject = subject, Text = text, DeliveryStatus = "queued", Unread = false };
message.RawPath = SaveRaw(raw);
lock (gate)
{
messages.Add(message);
foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase))
{
queue.Add(new QueueItem { MessageId = message.Id, OwnerEmail = owner, Recipients = [recipient] });
}
Write(messagesPath, messages);
Write(queuePath, queue);
}
return message;
}
public IReadOnlyList<QueueItem> TakeDueQueue(int limit)
{
lock (gate)
{
var due = queue.Where(x => x.Status is "pending" or "retry" && x.NextAttempt <= DateTimeOffset.UtcNow).Take(limit).ToArray();
foreach (var item in due) item.Status = "processing";
Write(queuePath, queue);
return due;
}
}
public MailMessage? GetById(string id) => messages.FirstOrDefault(x => x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
public void CompleteQueue(QueueItem item)
{
lock (gate)
{
item.Status = "sent";
var message = GetById(item.MessageId);
if (message is not null)
{
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
message.DeliveryStatus = remaining ? "queued" : "sent";
}
Write(queuePath, queue); Write(messagesPath, messages);
}
}
public void FailQueue(QueueItem item, Exception error)
{
lock (gate)
{
item.Attempts++;
item.LastError = error.Message;
var permanent = error is SmtpDeliveryException smtp && smtp.Permanent;
item.Status = permanent || item.Attempts >= 8 ? "failed" : "retry";
item.NextAttempt = DateTimeOffset.UtcNow.AddMinutes(Math.Min(60, Math.Pow(2, item.Attempts)));
var message = GetById(item.MessageId);
if (message is not null)
{
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
message.DeliveryStatus = item.Status == "failed" && !remaining ? "failed" : "queued";
}
Write(queuePath, queue); Write(messagesPath, messages);
}
}
public object Stats(string owner)
{
lock (gate)
{
return new { inbox = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox"), unread = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox" && x.Unread), sent = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "sent"), queue = queue.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Status is "pending" or "retry" or "processing") };
}
}
private static string HashPassword(string password, string salt) => Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, Convert.FromBase64String(salt), 120_000, HashAlgorithmName.SHA256, 32));
private static bool VerifyPassword(string password, string hash, string salt) => CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(hash), Convert.FromBase64String(HashPassword(password, salt)));
}
+47
View File
@@ -0,0 +1,47 @@
using System.Text;
namespace WpywMail.Native;
public sealed record ParsedMime(string From, string To, string Subject, string MessageId, string Text);
public static class Mime
{
public static ParsedMime Parse(byte[] raw)
{
var value = Encoding.UTF8.GetString(raw).Replace("\r\n", "\n");
var split = value.IndexOf("\n\n", StringComparison.Ordinal);
var headerText = split >= 0 ? value[..split] : value;
var body = split >= 0 ? value[(split + 2)..] : "";
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? current = null;
foreach (var line in headerText.Split('\n'))
{
if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) headers[current] += " " + line.Trim();
else { var colon = line.IndexOf(':'); if (colon > 0) { current = line[..colon]; headers[current] = line[(colon + 1)..].Trim(); } }
}
return new ParsedMime(headers.GetValueOrDefault("From", ""), headers.GetValueOrDefault("To", ""), headers.GetValueOrDefault("Subject", "(无主题)"), headers.GetValueOrDefault("Message-ID", ""), body.TrimEnd());
}
public static byte[] Build(string from, string[] to, string subject, string text)
{
var body = WrapBase64(Encoding.UTF8.GetBytes(text));
var value = $"From: {from}\r\nTo: {string.Join(", ", to)}\r\nSubject: {EncodeHeader(subject)}\r\nDate: {DateTimeOffset.UtcNow:R}\r\nMessage-ID: <{Guid.NewGuid():N}@wpyw.site>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{body}\r\n";
return Encoding.UTF8.GetBytes(value);
}
private static string EncodeHeader(string value)
{
if (value.All(ch => ch <= 0x7F)) return value;
var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
return $"=?UTF-8?B?{encoded}?=";
}
private static string WrapBase64(byte[] bytes)
{
var encoded = Convert.ToBase64String(bytes);
return string.Join("\r\n", Enumerable.Range(0, (encoded.Length + 75) / 76)
.Select(index => encoded.Substring(index * 76, Math.Min(76, encoded.Length - index * 76))));
}
public static string[] Addresses(string value) => value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(x => x.Contains('<') ? x[(x.IndexOf('<') + 1)..x.IndexOf('>')] : x).Where(x => x.Contains('@')).ToArray();
}
+81
View File
@@ -0,0 +1,81 @@
using System.Text.Json.Serialization;
namespace WpywMail.Native;
public sealed class AppConfig
{
public string Domain { get; set; } = "wpyw.site";
public string Hostname { get; set; } = "mail.wpyw.site";
public string HttpPrefix { get; set; } = "http://127.0.0.1:8787/";
public int SmtpPort { get; set; } = 25;
public int SubmissionPort { get; set; } = 587;
public string DataDirectory { get; set; } = @"H:\MailData";
public string AdminEmail { get; set; } = "[email protected]";
public string AdminPassword { get; set; } = "";
public string TlsCertificatePath { get; set; } = "";
public string TlsCertificatePassword { get; set; } = "";
public string DeliveryMode { get; set; } = "direct";
public DirectDeliveryConfig DirectDelivery { get; set; } = new();
public RelayConfig Relay { get; set; } = new();
}
public sealed class DirectDeliveryConfig
{
public int ConnectionTimeoutSeconds { get; set; } = 30;
public int CommandTimeoutSeconds { get; set; } = 30;
public int DnsTimeoutSeconds { get; set; } = 5;
public bool OpportunisticStartTls { get; set; } = true;
public bool RequireStartTls { get; set; }
public string DnsServer { get; set; } = "";
}
public sealed class RelayConfig
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string User { get; set; } = "";
public string Password { get; set; } = "";
public bool EnableSsl { get; set; } = true;
}
public sealed class MailUser
{
public string Email { get; set; } = "";
public string DisplayName { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string PasswordSalt { get; set; } = "";
public bool Active { get; set; } = true;
public string Role { get; set; } = "user";
}
public sealed class MailMessage
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string OwnerEmail { get; set; } = "";
public string Folder { get; set; } = "inbox";
public string From { get; set; } = "";
public string To { get; set; } = "";
public string Subject { get; set; } = "(无主题)";
public string Text { get; set; } = "";
public string RawPath { get; set; } = "";
public string MessageId { get; set; } = "";
public DateTimeOffset Date { get; set; } = DateTimeOffset.UtcNow;
public bool Unread { get; set; } = true;
public bool Starred { get; set; }
public string DeliveryStatus { get; set; } = "received";
}
public sealed class QueueItem
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string MessageId { get; set; } = "";
public string OwnerEmail { get; set; } = "";
public string[] Recipients { get; set; } = [];
public int Attempts { get; set; }
public DateTimeOffset NextAttempt { get; set; } = DateTimeOffset.UtcNow;
public string Status { get; set; } = "pending";
public string LastError { get; set; } = "";
}
public sealed record LoginRequest(string Email, string Password);
public sealed record SendRequest(string To, string Subject, string Text);
+23
View File
@@ -0,0 +1,23 @@
using System.Text.Json;
namespace WpywMail.Native;
public static class Program
{
public static async Task Main()
{
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(settingsPath)) throw new FileNotFoundException("请将 appsettings.example.json 复制为 appsettings.json 并填写密码。", settingsPath);
var config = JsonSerializer.Deserialize<AppConfig>(await File.ReadAllTextAsync(settingsPath), new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? throw new InvalidOperationException("无法读取 appsettings.json");
if (config.AdminPassword.Length < 12 || config.AdminPassword.Contains("replace-with", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请在 appsettings.json 设置至少 12 位 AdminPassword。");
AppLog.Configure(config);
var store = new FileStore(config);
using var cancellation = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cancellation.Cancel(); };
var api = new ApiServer(config, store);
var smtp = new SmtpServer(config, store);
var queue = new DeliveryQueue(config, store);
AppLog.Info($"中文邮箱服务正在启动,域名:{config.Domain},主机名:{config.Hostname}");
await Task.WhenAll(api.RunAsync(cancellation.Token), smtp.RunAsync(cancellation.Token), queue.RunAsync(cancellation.Token));
}
}
+78
View File
@@ -0,0 +1,78 @@
# wpyw.mail Windows 邮箱服务
这是一个不依赖 Node、npm、数据库或第三方运行库的 Windows 原生服务端。安装包会自动配置:
- 邮箱:`[email protected]`
- 收信:SMTP 25
- 客户端发信:SMTP Submission 587
- 客户端接口:本机 `127.0.0.1:8787`
- 文件存储、收件箱、已发送、发送队列和失败重试
- STARTTLS(需要 `mail.wpyw.site` 的 PFX 证书)
## 一、最简单的安装方式
1. 在 Windows Server 2022 上以管理员身份运行 `wpyw-mail-server-installer.exe`。
2. 安装器出现中文问题时,按下面的规则填写:
| 安装器问题 | 应填写什么 | 第一次安装建议 |
| --- | --- | --- |
| 程序安装目录 | 服务程序放在哪里 | 直接回车 |
| 邮件数据目录 | 邮件和账户数据长期保存在哪里 | 磁盘空间充足时填 `D:\WpywMailData`,没有 D 盘就直接回车 |
| 邮箱密码 | `[email protected]` 的登录密码 | 输入至少 12 位强密码,输入时屏幕不会显示 |
| SMTP 外发中继服务器 | 用来把邮件发到公网的 SMTP 服务器 | 没有就直接回车,使用 MX 直投 |
| SMTP 外发中继端口 | 中继服务器端口 | 只有填写中继服务器后才出现,默认 587 |
| SMTP 中继账号 | 中继账号 | 只有填写中继服务器后才出现 |
| SMTP 中继密码 | 中继密码 | 只有填写中继账号后才出现 |
| PFX 证书路径 | `mail.wpyw.site` 的证书文件路径 | 没有就直接回车,安装器会生成临时证书 |
| PFX 证书密码 | PFX 文件密码 | 没密码就直接回车 |
3. 安装器会创建 Windows 防火墙规则、注册开机启动任务并启动服务。
4. 安装完成后,记录安装器显示的邮箱地址和数据目录。
## 二、第一次安装可以直接这样填
如果你暂时没有 SMTP 中继,也没有正式 PFX 证书:
```text
程序安装目录:直接回车
邮件数据目录:直接回车
邮箱密码:输入你自己设置的至少 12 位密码
SMTP 外发中继服务器:直接回车
SMTP 外发中继端口:不会出现
SMTP 中继账号:不会出现
SMTP 中继密码:不会出现
PFX 证书路径:直接回车
PFX 证书密码:不会出现,或直接回车
```
不填写 SMTP 中继时,服务端会使用 MX 直投:查询收件人域名的 MX 记录,再连接对方 25 端口发送。你已经确认服务器可以连接 QQ MX 的 25 端口。
## 三、Cloudflare DNS 保持这样
```text
mail.wpyw.site A <SERVER_IP> DNS only
wpyw.site MX mail.wpyw.site DNS only
```
网站或未来 WinUI 客户端的 Web/API 路由可以通过 Cloudflare Tunnel 指向:
```text
http://127.0.0.1:8787/
```
SMTP 25 和 587 不要放到普通 HTTP Tunnel 路由里;它们应直接连接 `mail.wpyw.site`,并在服务器和云主机防火墙中开放。
## 四、当前版本的边界
当前服务端还没有 IMAP/POP3、DKIM 签名和完整反垃圾系统,因此第一版 WinUI 客户端通过 REST API 工作,Outlook/手机暂时不能直接用 IMAP 登录。MX 直投能工作不代表所有服务商都会接受邮件;正式公网使用还应补齐 DKIM、DMARC、反向 DNS 和退信处理。
## 五、手工启动和查看
安装器默认注册的任务名是 `WpywMail`。管理员 PowerShell 中可以查看:
```powershell
Get-ScheduledTask -TaskName WpywMail
Get-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口'
```
服务数据在安装时填写的数据目录中;不要删除其中的 `users.json`、`messages.json` 和 `queue.json`。
+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); }
}
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
+28
View File
@@ -0,0 +1,28 @@
{
"Domain": "wpyw.site",
"Hostname": "mail.wpyw.site",
"HttpPrefix": "http://127.0.0.1:8787/",
"SmtpPort": 25,
"SubmissionPort": 587,
"DataDirectory": "H:\\MailData",
"AdminEmail": "[email protected]",
"AdminPassword": "replace-with-a-long-password",
"TlsCertificatePath": "H:\\MailData\\certs\\mail.wpyw.site.pfx",
"TlsCertificatePassword": "replace-with-certificate-password",
"DeliveryMode": "direct",
"DirectDelivery": {
"ConnectionTimeoutSeconds": 30,
"CommandTimeoutSeconds": 30,
"DnsTimeoutSeconds": 5,
"OpportunisticStartTls": true,
"RequireStartTls": false,
"DnsServer": ""
},
"Relay": {
"Host": "",
"Port": 587,
"User": "",
"Password": "",
"EnableSsl": true
}
}
+3
View File
@@ -0,0 +1,3 @@
New-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口' -Direction Inbound -Protocol TCP -LocalPort 25,587 -Action Allow
# API 8787 默认只监听本机,再通过 Cloudflare Tunnel 暴露 Web/API。
# 如需让独立客户端直连,请先评估安全策略后再单独开放 8787。
+17
View File
@@ -0,0 +1,17 @@
param(
[string]$InstallDirectory = 'C:\WpywMail',
[string]$TaskName = 'WpywMail'
)
$exe = Join-Path $InstallDirectory 'WpywMail.Native.exe'
if (-not (Test-Path -LiteralPath $exe)) {
throw "找不到 $exe,请先把 self-contained publish 目录复制到服务器。"
}
$action = New-ScheduledTaskAction -Execute $exe -WorkingDirectory $InstallDirectory
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
Start-ScheduledTask -TaskName $TaskName
Write-Host "已注册并启动任务:$TaskName"