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
+249
View File
@@ -0,0 +1,249 @@
using System.Text;
namespace WpywMail.Native;
public static class Program
{
public static async Task<int> Main(string[] args)
{
if (args.Contains("--version"))
{
Console.WriteLine($"{BuildInfo.Product} {BuildInfo.Version}");
return 0;
}
if (args.Contains("--selftest"))
{
return SelfTest.Run();
}
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(settingsPath))
{
Console.Error.WriteLine($"未找到 {settingsPath},请复制 appsettings.example.json 并填写。");
return 2;
}
AppConfig config;
try
{
config = System.Text.Json.JsonSerializer.Deserialize<AppConfig>(
await File.ReadAllTextAsync(settingsPath),
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))
?? throw new InvalidOperationException("appsettings.json 解析结果为空。");
config.Validate();
}
catch (Exception ex)
{
Console.Error.WriteLine($"配置错误:{ex.Message}");
return 3;
}
if (args.Contains("--check-config"))
{
Console.WriteLine("配置校验通过。");
Console.WriteLine($" 域名 :{config.Domain}");
Console.WriteLine($" 主机名 :{config.Hostname}");
Console.WriteLine($" 管理员 :{config.AdminEmail}");
Console.WriteLine($" 数据目录 :{config.DataDirectory}");
Console.WriteLine($" 收信/发信 :{config.SmtpPort} / {config.SubmissionPort}");
Console.WriteLine($" 投递模式 :{config.DeliveryMode}");
Console.WriteLine($" 存储后端 :{config.Storage.Provider}" +
(config.Storage.Provider.Equals("sqlite", StringComparison.OrdinalIgnoreCase)
? $"({Path.Combine(config.DataDirectory, "wpywmail.db")})" : "(JSON 文件)"));
Console.WriteLine($" DKIM :{(config.Dkim.Enabled ? $"启用(选择器 {config.Dkim.Selector})" : "未启用")}");
// 账号策略:这里必须把「本机托管地址免验证」的原因写清楚 ——
// 否则运维看到 RequireEmailVerification=true 却收不到验证码会一头雾水。
var acc = config.Accounts;
var domains = acc.EffectiveDomains(config.Domain);
var allHosted = domains.Length > 0
&& domains.All(d => d.Equals(config.Domain, StringComparison.OrdinalIgnoreCase));
var svc = AccountService.IsHostedDomain;
var hostedDomains = domains.Where(d => svc("x@" + d, config.Domain, config.Hostname)).ToArray();
Console.WriteLine($" 自助注册 :{acc.Registration}" +
(acc.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase) ? "(需邀请码)" : "")
+ $",允许域名:{string.Join(" / ", domains)}");
Console.WriteLine($" 邮箱验证 :{(acc.RequireEmailVerification ? "要求" : "不要求")}"
+ (acc.RequireEmailVerification && allHosted
? " —— 【注意】允许的域名都由本机托管:验证码邮件投进的正是「验证通过前登录不了」的信箱,"
+ "已对这些地址自动跳过验证(授权凭据是邀请码)。要让邮箱验证生效,请把 AllowedDomains 改成托管在别处的域名。"
: acc.RequireEmailVerification && hostedDomains.Length > 0
? $" —— 其中 {string.Join(" / ", hostedDomains)} 由本机托管,这些地址注册时免验证码直接开通"
: ""));
return 0;
}
// 机器可读地输出 DKIM 公钥记录,供部署脚本写入 DNS。
// 这一模式必须保持 stdout 干净(只输出 KEY=VALUE),因此关闭控制台日志。
if (args.Contains("--dkim-dns"))
{
AppLog.Configure(config, enableConsole: false);
var dnsSigner = DkimSigner.Create(config);
if (dnsSigner is null)
{
Console.Error.WriteLine("DKIM 未启用或初始化失败(检查 Dkim.Enabled)。");
return 4;
}
Console.WriteLine("NAME=" + dnsSigner.RecordName);
Console.WriteLine("VALUE=" + dnsSigner.RecordValue);
Console.WriteLine("DMARC_NAME=_dmarc." + config.Domain);
Console.WriteLine("DMARC_VALUE=v=DMARC1; p=none; rua=mailto:" + config.AdminEmail);
return 0;
}
AppLog.Configure(config);
// 彻底删除账号(管理员维护命令)。会先逐封永久删除该账号的邮件(顺带回收大对象),
// 再删用户行、会话、验证码与出站队列;审计保留,并写一条 account-purged。
if (args.Contains("--purge-user"))
{
var index = Array.IndexOf(args, "--purge-user");
var target = index >= 0 && index + 1 < args.Length ? args[index + 1] : "";
if (string.IsNullOrWhiteSpace(target) || !target.Contains('@'))
{
Console.Error.WriteLine("用法:--purge-user <email>(例如 --purge-user [email protected])");
return 2;
}
using var purgeStore = CreateStore(config);
var existing = purgeStore.FindUserAnyState(target);
if (existing is null)
{
Console.Error.WriteLine($"找不到账号:{target}");
return 1;
}
var mails = purgeStore.ListMessages(existing.Email, "", "");
foreach (var mail in mails) purgeStore.DeleteMessage(existing.Email, mail.Id, permanent: true);
var removed = purgeStore.DeleteUser(existing.Email);
purgeStore.RecordAuthEvent(new AuthEvent
{
Email = existing.Email,
Ip = "local",
Reason = "account-purged",
Success = true,
Detail = $"邮件 {mails.Count} 封已永久删除(active={existing.Active},命令 --purge-user)",
});
purgeStore.Persist();
Console.WriteLine(removed
? $"已彻底删除账号 {existing.Email}:邮件 {mails.Count} 封、会话/验证码/队列一并清除(审计保留)"
: $"删除失败:{target}");
return removed ? 0 : 1;
}
// 对**已收到的真实邮件**做 DKIM 验签(走真实 DNS 取发件域公钥)。
// 用途:① 检验验签实现是否真的对(真邮件是外部签名器签的,自己造的签名骗不过它);
// ② 运维排查「这封信到底是不是伪造的」。SPF 需要收信当时的来源 IP,历史邮件没有,故只做 DKIM。
if (args.Contains("--verify-inbound"))
{
var index = Array.IndexOf(args, "--verify-inbound");
var limit = index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var parsed) ? parsed : 25;
using var verifyStore = CreateStore(config);
var dns = new UdpDnsLookup(config);
var all = verifyStore.AllMessages().OrderByDescending(m => m.ReceivedAt > m.Date ? m.ReceivedAt : m.Date).Take(Math.Max(1, limit)).ToList();
Console.WriteLine($"检查最近 {all.Count} 封已收邮件的 DKIM 签名(真实 DNS):");
int signed = 0, passed = 0, failed = 0, unsigned = 0;
foreach (var message in all)
{
byte[] raw;
try { raw = verifyStore.ReadRaw(message.RawPath); }
catch { continue; }
var results = DkimVerifier.VerifyAllAsync(raw, dns, CancellationToken.None).GetAwaiter().GetResult();
if (results.Count == 0) { unsigned++; continue; }
signed++;
var ok = results.Any(r => r.Outcome == "pass");
if (ok) passed++; else failed++;
var head = $"[{(ok ? "通过" : results.Any(r => r.Outcome == "temperror") ? "查询失败" : "不通过")}]";
Console.WriteLine($"{head} {message.From,-38} {message.Subject[..Math.Min(38, message.Subject.Length)]}");
foreach (var r in results)
Console.WriteLine($" d={r.Domain} s={r.Selector} → {r.Outcome}:{r.Detail}");
}
Console.WriteLine();
Console.WriteLine($"合计:带签名 {signed} 封(通过 {passed} / 不通过 {failed}),无签名 {unsigned} 封。");
return failed == 0 ? 0 : 1;
}
if (args.Contains("--migrate")) {
using var migrationStore = new FileStore(config);
return Migration.Run(config, migrationStore);
}
// ---------------- 存储后端维护命令 ----------------
if (args.Contains("--migrate-to-sqlite"))
return StorageMigration.ToSqlite(config, deleteSourceFiles: args.Contains("--delete-source"));
if (args.Contains("--migrate-to-json"))
return StorageMigration.ToJson(config);
if (args.Contains("--storage-status"))
return StorageMigration.Status(config);
if (args.Contains("--compact"))
return StorageMigration.Compact(config);
if (args.Contains("--vacuum"))
return StorageMigration.Vacuum(config);
if (args.Contains("--bench-store"))
{
var count = args.Select(a => int.TryParse(a, out var n) ? n : 0).FirstOrDefault(n => n > 0);
return StorageBench.Run(config, count > 0 ? count : 400, mutationSamples: 150);
}
// 任何未捕获异常都记录后再退出,避免静默消失
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
AppLog.Error($"[致命] 未处理异常:{(e.ExceptionObject as Exception)?.Message ?? e.ExceptionObject?.ToString()}");
TaskScheduler.UnobservedTaskException += (_, e) =>
{
AppLog.Error($"[致命] 未观察的任务异常:{e.Exception.Message}");
e.SetObserved();
};
var store = CreateStore(config);
var signer = DkimSigner.Create(config);
using var cancellation = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
AppLog.Info("收到退出信号,正在停止……");
cancellation.Cancel();
};
AppLog.Info($"中文邮箱服务正在启动,域名:{config.Domain},主机名:{config.Hostname},版本:{BuildInfo.Version}");
if (!config.Dkim.Enabled)
AppLog.Warn("[DKIM] 未启用签名。建议开启并配置 DNS 记录,否则外发邮件容易被判为垃圾邮件。");
var api = new ApiServer(config, store);
var smtp = new SmtpServer(config, store);
var queue = new DeliveryQueue(config, store, signer);
var imap = new ImapServer(config, store);
if (config.Imap.Enabled)
AppLog.Info($"[IMAP] 已启用:明文/STARTTLS 端口 {config.Imap.Port},隐式 TLS 端口 {config.Imap.TlsPort}," +
$"登录要求 TLS={config.Imap.RequireTlsForLogin}");
try
{
await Task.WhenAll(
api.RunAsync(cancellation.Token),
smtp.RunAsync(cancellation.Token),
queue.RunAsync(cancellation.Token),
imap.RunAsync(cancellation.Token));
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
AppLog.Error($"[致命] 服务异常退出:{ex}");
return 1;
}
AppLog.Info("服务已停止。");
store.Dispose();
return 0;
}
/// <summary>按 Storage.Provider 创建存储后端。sqlite 为默认。</summary>
public static IMailStore CreateStore(AppConfig config) =>
config.Storage.Provider.Equals("json", StringComparison.OrdinalIgnoreCase)
? new FileStore(config)
: new SqliteStore(config);
}