Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 账号体系的业务层:自助注册、邮箱验证码、失败锁定、密码重置、会话与资料管理。
|
||||
///
|
||||
/// 设计取舍(都在代码里写清楚,方便以后回看):
|
||||
///
|
||||
/// 1. **先建未激活信箱,密码等验证通过才写入**:本地投递只认已存在的用户,所以注册时必须
|
||||
/// 先把信箱行建出来(否则验证码邮件会被判成外发、甚至直接丢弃,用户永远收不到码);
|
||||
/// 但这一行的密码是**随机不可用值**,真正的密码存在验证码记录的 Payload 里,
|
||||
/// 验证通过后才写进用户行并激活 —— 于是「谁能读到验证码,谁才能决定这个账号的密码」。
|
||||
/// 未激活的信箱不能登录 IMAP/SMTP/API(见各处的 FindUser 只取 active=1)。
|
||||
/// ⚠️ 曾经写成「验证通过再建号」,结果是验证码邮件根本送不到,属于致命流程缺陷。
|
||||
/// 2. **验证码只存哈希**:库被拖走也不能直接拿来激活账号或改密码。
|
||||
/// 3. **登录失败信息不区分原因**:对外一律「邮箱或密码不正确」,避免账号枚举;
|
||||
/// 真实原因(不存在 / 密码错 / 已停用 / 已锁定)只写进审计日志。
|
||||
/// 4. **发信走既有出站队列**:验证码邮件复用 Mime.Build + QueueOutbound + 投递队列,
|
||||
/// 不另起一条发送路径(否则重试、DKIM、队列状态都要再实现一遍)。
|
||||
/// </summary>
|
||||
public sealed class AccountService
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly IMailStore store;
|
||||
|
||||
public AccountService(AppConfig config, IMailStore store)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
// 让存储层的审计裁剪跟随配置
|
||||
if (store is SqliteStore sqlite) sqlite.AuditKeep = config.Accounts.AuditLimit;
|
||||
if (store is FileStore file) file.AuditKeep = config.Accounts.AuditLimit;
|
||||
if (config.Accounts.RequireEmailVerification
|
||||
&& config.Accounts.EffectiveDomains(config.Domain)
|
||||
.All(d => IsHostedDomain("x@" + d, config.Domain, config.Hostname)))
|
||||
{
|
||||
AppLog.Warn("[账号] Accounts.RequireEmailVerification=true,但允许注册的域名都由本机托管 —— "
|
||||
+ "验证码邮件会被投进「验证通过前登录不了」的信箱,形成死循环。"
|
||||
+ "对这些地址已自动跳过邮箱验证(注册授权凭据是邀请码)。"
|
||||
+ "要让邮箱验证真正生效,请把 AllowedDomains 换成托管在别处的域名(如 gmail.com)。");
|
||||
}
|
||||
}
|
||||
|
||||
public AccountsConfig Policy => config.Accounts;
|
||||
|
||||
// ---------------------------------------------------------------- 对外:策略
|
||||
|
||||
public object PolicyView() => new
|
||||
{
|
||||
registration = Policy.Registration,
|
||||
inviteRequired = Policy.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase),
|
||||
requireEmailVerification = Policy.RequireEmailVerification,
|
||||
minPasswordLength = Policy.MinPasswordLength,
|
||||
allowedDomains = Policy.EffectiveDomains(config.Domain),
|
||||
codeMinutes = Policy.CodeMinutes,
|
||||
maxLoginFailures = Policy.MaxLoginFailures,
|
||||
lockoutMinutes = Policy.LockoutMinutes,
|
||||
selfHostedDomain = config.Domain,
|
||||
verificationNote = Policy.RequireEmailVerification
|
||||
? $"本机托管的邮箱(@{config.Domain})注册后免验证码直接开通:验证码邮件只能投进这个信箱,"
|
||||
+ "而它在验证通过前登录不了,会形成死循环;这类地址的授权凭据是邀请码。"
|
||||
: "",
|
||||
};
|
||||
|
||||
/// <summary>该地址的信箱是否就托管在本机上(域名 = 本服务器自己的域)。</summary>
|
||||
public bool IsHostedHere(string? email) => IsHostedDomain(email, config.Domain, config.Hostname);
|
||||
|
||||
/// <summary>纯函数版本:不依赖存储,供 --check-config 等只读场景使用。</summary>
|
||||
public static bool IsHostedDomain(string? email, string? domain, string? hostname)
|
||||
{
|
||||
var at = (email ?? "").LastIndexOf('@');
|
||||
if (at < 0 || at == email!.Length - 1) return false;
|
||||
var d = email[(at + 1)..].Trim().ToLowerInvariant();
|
||||
if (d.Length == 0) return false;
|
||||
if (d.Equals((domain ?? "").Trim().ToLowerInvariant(), StringComparison.Ordinal)) return true;
|
||||
var host = (hostname ?? "").Trim().ToLowerInvariant();
|
||||
var dot = host.IndexOf('.');
|
||||
return dot > 0 && d.Equals(host[(dot + 1)..], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 这个地址要不要走邮箱验证码。
|
||||
///
|
||||
/// ⚠️ **本机托管的地址必须跳过**,否则是死循环:验证码邮件投进的就是这个信箱,
|
||||
/// 而它在验证通过前不允许登录(IMAP / Webmail / API 全部进不去)→ 用户永远拿不到验证码。
|
||||
/// 这类地址的授权凭据是**邀请码**(管理员亲自发放),注册即开通。
|
||||
/// 只有邮箱托管在别处(例如 AllowedDomains 里放了 gmail.com)时,邮箱验证才真正有意义。
|
||||
/// </summary>
|
||||
public bool NeedsEmailVerification(string? email) => Policy.RequireEmailVerification && !IsHostedHere(email);
|
||||
|
||||
// ---------------------------------------------------------------- 校验
|
||||
|
||||
public static bool LooksLikeEmail(string? email) =>
|
||||
!string.IsNullOrWhiteSpace(email)
|
||||
&& email.Length <= 254
|
||||
&& email.Count(c => c == '@') == 1
|
||||
&& email.IndexOf('@') > 0
|
||||
&& email.IndexOf('@') < email.Length - 1
|
||||
&& !email.Any(char.IsWhiteSpace)
|
||||
&& email.Contains('.');
|
||||
|
||||
/// <summary>密码强度:长度 + 不能纯数字 + 不能与邮箱相同(够用即可,不搞复杂度表演)。</summary>
|
||||
public (bool Ok, string Error) CheckPassword(string? password, string email)
|
||||
{
|
||||
var min = Math.Max(8, Policy.MinPasswordLength);
|
||||
if (string.IsNullOrEmpty(password)) return (false, "密码不能为空");
|
||||
if (password.Length < min) return (false, $"密码至少需要 {min} 个字符");
|
||||
if (password.Length > 200) return (false, "密码过长");
|
||||
if (password.All(char.IsDigit)) return (false, "密码不能全是数字");
|
||||
if (!string.IsNullOrWhiteSpace(email) && password.Equals(email, StringComparison.OrdinalIgnoreCase))
|
||||
return (false, "密码不能与邮箱相同");
|
||||
return (true, "");
|
||||
}
|
||||
|
||||
private (bool Ok, string Error) CheckDomain(string email)
|
||||
{
|
||||
var at = email.LastIndexOf('@');
|
||||
if (at < 0) return (false, "邮箱地址不合法");
|
||||
var domain = email[(at + 1)..].ToLowerInvariant();
|
||||
var allowed = Policy.EffectiveDomains(config.Domain);
|
||||
if (allowed.Length == 0) return (true, "");
|
||||
return allowed.Contains(domain)
|
||||
? (true, "")
|
||||
: (false, $"只允许注册 @{string.Join(" / @", allowed)} 的邮箱");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 验证码
|
||||
|
||||
private static string NewCode() => RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
|
||||
|
||||
private static string HashCode(string code, string salt) =>
|
||||
Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(code, Convert.FromBase64String(salt), 60_000, HashAlgorithmName.SHA256, 32));
|
||||
|
||||
private static bool VerifyCode(string code, string hash, string salt)
|
||||
{
|
||||
try
|
||||
{
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Convert.FromBase64String(hash),
|
||||
Convert.FromBase64String(HashCode(code, salt)));
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private string CreateCode(string email, string purpose, string payload)
|
||||
{
|
||||
var code = NewCode();
|
||||
var record = new VerificationCode
|
||||
{
|
||||
Email = email,
|
||||
Purpose = purpose,
|
||||
Salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||
Payload = payload,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(Math.Max(1, Policy.CodeMinutes)),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Attempts = 0,
|
||||
};
|
||||
record.CodeHash = HashCode(code, record.Salt);
|
||||
record.SentAt = DateTimeOffset.UtcNow;
|
||||
store.SaveVerificationCode(record);
|
||||
return code;
|
||||
}
|
||||
|
||||
/// <summary>校验验证码;成功返回 true 并删除该验证码。会处理过期与试错次数。</summary>
|
||||
public (bool Ok, string Error, VerificationCode? Record) ConsumeCode(string email, string purpose, string code)
|
||||
{
|
||||
var record = store.FindVerificationCode(email, purpose);
|
||||
if (record is null) return (false, "没有待验证的请求,请重新获取验证码", null);
|
||||
if (record.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
{
|
||||
store.RemoveVerificationCode(email, purpose);
|
||||
return (false, "验证码已过期,请重新获取", null);
|
||||
}
|
||||
if (record.Attempts >= Math.Max(1, Policy.MaxCodeAttempts))
|
||||
{
|
||||
store.RemoveVerificationCode(email, purpose);
|
||||
return (false, "验证码尝试次数过多,已作废,请重新获取", null);
|
||||
}
|
||||
if (!VerifyCode((code ?? "").Trim(), record.CodeHash, record.Salt))
|
||||
{
|
||||
var attempts = store.IncrementVerificationAttempts(email, purpose);
|
||||
var left = Math.Max(0, Policy.MaxCodeAttempts - attempts);
|
||||
Record(email, "", "code-failed", false, $"purpose={purpose} attempts={attempts}");
|
||||
return (false, left > 0 ? $"验证码不正确,还可以试 {left} 次" : "验证码已作废,请重新获取", null);
|
||||
}
|
||||
store.RemoveVerificationCode(email, purpose);
|
||||
return (true, "", record);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 注册
|
||||
|
||||
public sealed record RegisterResult(bool Ok, int Status, string Error, bool VerificationRequired, object? Session);
|
||||
|
||||
public RegisterResult Register(RegisterRequest request, string ip, string userAgent)
|
||||
{
|
||||
var email = (request.Email ?? "").Trim().ToLowerInvariant();
|
||||
|
||||
if (!Policy.Registration.Equals("open", StringComparison.OrdinalIgnoreCase)
|
||||
&& !Policy.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Record(email, ip, "register", false, "registration closed");
|
||||
return new RegisterResult(false, 403, "本服务器已关闭自助注册,请联系管理员开设账号", false, null);
|
||||
}
|
||||
if (Policy.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(request.InviteCode?.Trim(), Policy.InviteCode.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
Record(email, ip, "register", false, "bad invite code");
|
||||
return new RegisterResult(false, 403, "邀请码不正确", false, null);
|
||||
}
|
||||
if (!LooksLikeEmail(email))
|
||||
{
|
||||
Record(email, ip, "register", false, "bad email");
|
||||
return new RegisterResult(false, 400, "邮箱地址不合法", false, null);
|
||||
}
|
||||
var domain = CheckDomain(email);
|
||||
if (!domain.Ok)
|
||||
{
|
||||
Record(email, ip, "register", false, "domain not allowed");
|
||||
return new RegisterResult(false, 400, domain.Error, false, null);
|
||||
}
|
||||
// 已激活的账号:直接拒绝。**未激活的注册允许重来**(上一次验证码没收到 / 输错太多次),
|
||||
// 重来不会覆盖已有密码 —— 密码只由「读到验证码的人」在验证那一步写入。
|
||||
var existing = store.FindUserAnyState(email);
|
||||
if (existing is not null && existing.Active)
|
||||
{
|
||||
Record(email, ip, "register", false, "already exists");
|
||||
return new RegisterResult(false, 409, "这个邮箱已经注册过了,请直接登录或使用「忘记密码」", false, null);
|
||||
}
|
||||
if (existing is not null && existing.LastLoginAt is not null)
|
||||
{
|
||||
// 这一行曾经是正常账号(登录过),现在被管理员停用了。
|
||||
// **停用是权威状态**:不能靠「重新注册」把它翻回启用,否则拿到邀请码的人就能推翻管理员的封禁。
|
||||
Record(email, ip, "register", false, "account disabled by admin");
|
||||
return new RegisterResult(false, 403, "该账号已被管理员停用,请联系管理员", false, null);
|
||||
}
|
||||
var password = CheckPassword(request.Password, email);
|
||||
if (!password.Ok)
|
||||
{
|
||||
Record(email, ip, "register", false, "weak password");
|
||||
return new RegisterResult(false, 400, password.Error, false, null);
|
||||
}
|
||||
// 限流:同一 IP 每小时最多建成几个账号。
|
||||
//
|
||||
// ⚠ 这里**只把「真的建出了账号」算进严格配额**,失败尝试(邀请码填错、密码太短、域名不对)
|
||||
// 不算 —— 否则一个正常新用户表单填错几次就被挡一小时,而且在 NAT / 手机网络下
|
||||
// 会连累同 IP 的其他人(真机验收就撞到过:连续两次运行验收脚本,第二次直接 429)。
|
||||
// 失败尝试另有一个宽松上限,避免有人拿邀请码当靶子爆破;审计里两类都记得清清楚楚。
|
||||
var perIp = Policy.RegisterPerHourPerIp;
|
||||
if (perIp > 0 && store.CountAuthEvents(null, ip, "register", true, 60) >= perIp)
|
||||
{
|
||||
Record(email, ip, "register", false, "rate limited (ip quota)");
|
||||
return new RegisterResult(false, 429, "注册请求过于频繁,请稍后再试", false, null);
|
||||
}
|
||||
var failLimit = Math.Max(10, perIp * 4);
|
||||
if (perIp > 0 && store.CountAuthEvents(null, ip, "register", false, 60) >= failLimit)
|
||||
{
|
||||
Record(email, ip, "register", false, "rate limited (failed attempts)");
|
||||
return new RegisterResult(false, 429, "注册请求过于频繁,请稍后再试", false, null);
|
||||
}
|
||||
// 同一邮箱也有次数上限(防止有人盯着一个地址反复发验证码)
|
||||
if (perIp > 0 && store.CountAuthEvents(email, null, "register", true, 60) >= Math.Max(2, perIp))
|
||||
{
|
||||
Record(email, ip, "register", false, "email rate limited");
|
||||
return new RegisterResult(false, 429, "该邮箱的注册请求过于频繁,请稍后再试", false, null);
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(request.DisplayName) ? email.Split('@')[0] : request.DisplayName!.Trim();
|
||||
var needsVerify = NeedsEmailVerification(email);
|
||||
|
||||
// 密码哈希先算好,但**先不放用户表**(要验证邮箱时才如此)
|
||||
var salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||
var pendingHash = HashPassword(request.Password!, salt);
|
||||
|
||||
// 先把信箱行建出来(未激活),否则本地投递看不到这个收件人、验证码邮件送不进来。
|
||||
// 需要验证时,用户行里的密码是随机不可用值 —— 就算有人抢先用你的邮箱注册,
|
||||
// 也无法在这个账号上留下自己的密码;密码只在验证码通过时写入。
|
||||
var unusableHash = HashPassword(Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), salt);
|
||||
var created = store.CreateUserWithHash(email, needsVerify ? unusableHash : pendingHash, salt, displayName, active: !needsVerify);
|
||||
if (!needsVerify && !created.Active)
|
||||
{
|
||||
store.SetUserActive(email, true);
|
||||
created = store.FindUser(email) ?? created;
|
||||
}
|
||||
|
||||
if (!needsVerify)
|
||||
{
|
||||
var why = IsHostedHere(email) ? "本机托管的信箱,授权凭据是邀请码" : "策略未要求邮箱验证";
|
||||
Record(email, ip, "register", true, $"created without email verification({why})");
|
||||
AppLog.Info($"[账号] 新账号已直接开通(免邮箱验证):{email} —— {why}");
|
||||
return new RegisterResult(true, 201, "", false, NewSession(created, ip, userAgent));
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(new PendingRegistration
|
||||
{
|
||||
DisplayName = displayName,
|
||||
PasswordHash = pendingHash,
|
||||
PasswordSalt = salt,
|
||||
});
|
||||
var code = CreateCode(email, "register", payload);
|
||||
var sent = SendCodeMail(email, code, "注册验证", "register");
|
||||
Record(email, ip, "register", true, sent.Ok ? "code sent" : $"code send failed: {sent.Error}");
|
||||
if (!sent.Ok)
|
||||
return new RegisterResult(false, 502, $"验证邮件发送失败:{sent.Error}", true, null);
|
||||
|
||||
return new RegisterResult(true, 202, "", true, new
|
||||
{
|
||||
verificationRequired = true,
|
||||
email,
|
||||
expiresInMinutes = Policy.CodeMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>校验注册验证码:通过后写入密码并激活账号(等价于注册即登录)。</summary>
|
||||
public (bool Ok, int Status, string Error, object? Session) VerifyRegistration(VerifyCodeRequest request, string ip, string userAgent)
|
||||
{
|
||||
var email = (request.Email ?? "").Trim().ToLowerInvariant();
|
||||
if (!LooksLikeEmail(email)) return (false, 400, "邮箱地址不合法", null);
|
||||
|
||||
var user = store.FindUserAnyState(email);
|
||||
if (user is null) return (false, 404, "没有待验证的注册,请先提交注册", null);
|
||||
if (user.Active) return (false, 409, "这个邮箱已经激活过了,请直接登录", null);
|
||||
|
||||
var (ok, error, record) = ConsumeCode(email, "register", request.Code ?? "");
|
||||
if (!ok) return (false, 400, error, null);
|
||||
|
||||
PendingRegistration? pending;
|
||||
try
|
||||
{
|
||||
pending = JsonSerializer.Deserialize<PendingRegistration>(
|
||||
record?.Payload ?? "", new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
}
|
||||
catch { pending = null; }
|
||||
|
||||
if (pending is null || string.IsNullOrEmpty(pending.PasswordHash) || string.IsNullOrEmpty(pending.PasswordSalt))
|
||||
return (false, 400, "注册信息已失效,请重新提交注册", null);
|
||||
|
||||
// 到这一步验证码已证明邮箱归属,才把密码写进用户行并激活
|
||||
store.CreateUserWithHash(email, pending.PasswordHash, pending.PasswordSalt,
|
||||
string.IsNullOrWhiteSpace(pending.DisplayName) ? user.DisplayName : pending.DisplayName, active: false);
|
||||
store.SetUserActive(email, true);
|
||||
var activated = store.FindUser(email) ?? user;
|
||||
Record(email, ip, "register-verify", true, "activated");
|
||||
AppLog.Info($"[账号] 新账号已激活:{email}");
|
||||
return (true, 201, "", NewSession(activated, ip, userAgent));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 密码重置
|
||||
|
||||
public (bool Ok, string Error) RequestReset(string email, string ip)
|
||||
{
|
||||
email = (email ?? "").Trim().ToLowerInvariant();
|
||||
if (!LooksLikeEmail(email)) return (false, "邮箱地址不合法");
|
||||
var user = store.FindUserAnyState(email);
|
||||
if (user is null)
|
||||
{
|
||||
// 不暴露账号是否存在;但仍然记录审计
|
||||
Record(email, ip, "reset-request", false, "unknown user");
|
||||
return (true, "");
|
||||
}
|
||||
var limit = Math.Max(2, Policy.ResendPerHourPerEmail);
|
||||
if (store.CountAuthEvents(email, null, "reset-request", null, 60) >= limit)
|
||||
{
|
||||
Record(email, ip, "reset-request", false, "rate limited");
|
||||
return (false, "请求过于频繁,请稍后再试");
|
||||
}
|
||||
var code = CreateCode(email, "reset", "");
|
||||
var sent = SendCodeMail(email, code, "重置密码", "reset");
|
||||
Record(email, ip, "reset-request", sent.Ok, sent.Ok ? "code sent" : sent.Error);
|
||||
return sent.Ok ? (true, "") : (false, $"验证邮件发送失败:{sent.Error}");
|
||||
}
|
||||
|
||||
public (bool Ok, int Status, string Error) ResetPassword(ResetPasswordRequest request, string ip)
|
||||
{
|
||||
var email = (request.Email ?? "").Trim().ToLowerInvariant();
|
||||
if (!LooksLikeEmail(email)) return (false, 400, "邮箱地址不合法");
|
||||
var check = CheckPassword(request.Password, email);
|
||||
if (!check.Ok) return (false, 400, check.Error);
|
||||
|
||||
var (ok, error, _) = ConsumeCode(email, "reset", request.Code ?? "");
|
||||
if (!ok) return (false, 400, error);
|
||||
|
||||
try { store.ChangePassword(email, request.Password!); }
|
||||
catch (InvalidOperationException) { return (false, 404, "账号不存在"); }
|
||||
|
||||
// 未激活的账号走到这里说明邮箱归属已被证明(验证码在本人手里)→ 顺带激活,
|
||||
// 这就是「注册时验证码没收到、卡在未激活」的兜底恢复路径。
|
||||
var anyState = store.FindUserAnyState(email);
|
||||
var activated = anyState is { Active: false };
|
||||
if (activated) store.SetUserActive(email, true);
|
||||
|
||||
// 改密后踢掉所有会话(别人的登录一并失效,这是安全要求)
|
||||
var revoked = store.RemoveSessions(email, null);
|
||||
Record(email, ip, "reset-ok", true, $"sessions revoked={revoked} activated={activated}");
|
||||
AppLog.Info($"[账号] {email} 通过邮件验证码重置了密码{(activated ? "并激活了账号" : "")},已吊销 {revoked} 个会话。");
|
||||
return (true, 200, "");
|
||||
}
|
||||
|
||||
/// <summary>重发验证码(注册 / 重置共用)。</summary>
|
||||
public (bool Ok, int Status, string Error) ResendCode(string email, string purpose, string ip)
|
||||
{
|
||||
email = (email ?? "").Trim().ToLowerInvariant();
|
||||
if (!LooksLikeEmail(email)) return (false, 400, "邮箱地址不合法");
|
||||
if (purpose is not ("register" or "reset")) return (false, 400, "purpose 只能是 register 或 reset");
|
||||
|
||||
var limit = Math.Max(2, Policy.ResendPerHourPerEmail);
|
||||
if (store.CountAuthEvents(email, null, "code-sent", null, 60) >= limit)
|
||||
{
|
||||
Record(email, ip, "code-sent", false, "rate limited");
|
||||
return (false, 429, "重发过于频繁,请稍后再试");
|
||||
}
|
||||
var existing = store.FindVerificationCode(email, purpose);
|
||||
if (existing is null) return (false, 404, "没有待验证的请求,请重新发起");
|
||||
|
||||
var code = CreateCode(email, purpose, existing.Payload);
|
||||
var sent = SendCodeMail(email, code, purpose == "register" ? "注册验证" : "重置密码", purpose);
|
||||
Record(email, ip, "code-sent", sent.Ok, sent.Ok ? "resent" : sent.Error);
|
||||
return sent.Ok ? (true, 200, "") : (false, 502, $"验证邮件发送失败:{sent.Error}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 登录加固
|
||||
|
||||
/// <summary>
|
||||
/// 登录失败(Authenticate 返回 null)时该怎么回话,以及审计里写什么原因。
|
||||
///
|
||||
/// 原则:**只在用户真的卡住时多说话**。「注册了但没验证完」的人如果只看到「邮箱或密码不正确」,
|
||||
/// 会一直以为密码错了 —— 而正确的出路是完成验证或用「忘记密码」。
|
||||
/// 其余情形(账号不存在 / 密码错 / 被管理员停用)一律同一句话,不做账号状态探测。
|
||||
/// </summary>
|
||||
public (int Status, string Error, bool PendingVerification, string AuditReason) LoginFailureHint(string email)
|
||||
{
|
||||
var any = store.FindUserAnyState(email);
|
||||
if (any is null) return (401, "邮箱或密码不正确", false, "unknown user");
|
||||
if (any.Active) return (401, "邮箱或密码不正确", false, "bad password");
|
||||
if (store.FindVerificationCode(email, "register") is not null)
|
||||
{
|
||||
return (403,
|
||||
"这个邮箱的注册还没完成邮箱验证。请用注册时收到的验证码完成验证,或用「忘记密码」重设密码。",
|
||||
true, "pending verification");
|
||||
}
|
||||
// 被管理员停用(或注册早已过期作废)—— 不区分,避免探测账号状态
|
||||
return (401, "邮箱或密码不正确", false, "account not activated or disabled");
|
||||
}
|
||||
|
||||
/// <summary>返回锁定剩余秒数;0 表示未锁定。</summary>
|
||||
public int LockRemainingSeconds(string email)
|
||||
{
|
||||
var max = Policy.MaxLoginFailures;
|
||||
if (max <= 0) return 0;
|
||||
var failures = store.CountAuthEvents(email, null, "login-failed", false, Policy.LockoutMinutes);
|
||||
if (failures < max) return 0;
|
||||
var recent = store.ListAuthEvents(email, null, "login-failed", max);
|
||||
if (recent.Count == 0) return 0;
|
||||
var unlockAt = recent[0].At.AddMinutes(Policy.LockoutMinutes);
|
||||
var left = (int)Math.Ceiling((unlockAt - DateTimeOffset.UtcNow).TotalSeconds);
|
||||
return Math.Max(1, left);
|
||||
}
|
||||
|
||||
public object? NewSession(MailUser user, string ip, string userAgent)
|
||||
{
|
||||
var session = store.CreateSession(user.Email, config.Api.SessionDays);
|
||||
store.SetLastLogin(user.Email);
|
||||
Record(user.Email, ip, "login-ok", true, userAgent);
|
||||
return new
|
||||
{
|
||||
token = session.Token,
|
||||
expiresAt = session.Expires,
|
||||
user = new { email = user.Email, displayName = user.DisplayName, role = user.Role, domain = user.Email.Split('@').LastOrDefault() },
|
||||
};
|
||||
}
|
||||
|
||||
public void Record(string email, string ip, string reason, bool success, string detail = "", string userAgent = "")
|
||||
=> store.RecordAuthEvent(new AuthEvent
|
||||
{
|
||||
Email = email ?? "",
|
||||
Ip = ip ?? "",
|
||||
Reason = reason,
|
||||
Success = success,
|
||||
Detail = detail,
|
||||
UserAgent = userAgent,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 会话 / 资料
|
||||
|
||||
public object SessionsView(string email, string? currentToken) =>
|
||||
new
|
||||
{
|
||||
sessions = store.ListSessions(email).Select(s => new
|
||||
{
|
||||
tokenPrefix = s.Token.Length > 10 ? s.Token[..10] : s.Token,
|
||||
token = s.Token,
|
||||
current = !string.IsNullOrEmpty(currentToken) && s.Token == currentToken,
|
||||
createdAt = s.CreatedAt,
|
||||
expiresAt = s.Expires,
|
||||
}),
|
||||
};
|
||||
|
||||
public int RevokeSessions(string email, string? keepToken) => store.RemoveSessions(email, keepToken);
|
||||
|
||||
public void UpdateProfile(string email, string? displayName)
|
||||
{
|
||||
if (displayName is null) return;
|
||||
store.UpdateProfile(email, displayName.Trim());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 发信
|
||||
|
||||
/// <summary>把验证码邮件投进出站队列(复用既有的 Mime.Build + QueueOutbound + 投递/重试链路)。</summary>
|
||||
private (bool Ok, string Error) SendCodeMail(string to, string code, string title, string purpose)
|
||||
{
|
||||
try
|
||||
{
|
||||
var minutes = Math.Max(1, Policy.CodeMinutes);
|
||||
var text = new StringBuilder()
|
||||
.AppendLine($"你正在{(purpose == "register" ? "注册" : "重置")} WpywMail 账号({to})。")
|
||||
.AppendLine()
|
||||
.AppendLine($"验证码:{code}")
|
||||
.AppendLine()
|
||||
.AppendLine($"验证码 {minutes} 分钟内有效,最多可尝试 {Math.Max(1, Policy.MaxCodeAttempts)} 次。")
|
||||
.AppendLine("如果不是你本人操作,忽略这封邮件即可,你的账号不受影响。")
|
||||
.AppendLine()
|
||||
.AppendLine($"—— {config.Hostname}")
|
||||
.ToString();
|
||||
|
||||
var sender = string.IsNullOrWhiteSpace(config.AdminEmail) ? $"postmaster@{config.Domain}" : config.AdminEmail;
|
||||
var raw = Mime.Build(new ComposeRequest(
|
||||
sender,
|
||||
"WpywMail",
|
||||
[to],
|
||||
[],
|
||||
$"【WpywMail】{title}验证码:{code}",
|
||||
text,
|
||||
null,
|
||||
[],
|
||||
MessageId: null,
|
||||
InReplyTo: "",
|
||||
References: ""), config);
|
||||
|
||||
var message = store.QueueOutbound(sender, [to], $"[WpywMail] {title}验证码", text, raw,
|
||||
string.Join(", ", Array.Empty<string>()), "", "", null);
|
||||
AppLog.Info($"[账号] 已入队验证码邮件:{to}({title},messageId={message.Id})");
|
||||
return (true, "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[账号] 验证码邮件入队失败:{to} —— {ex.Message}");
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string HashPassword(string password, string salt) =>
|
||||
Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, Convert.FromBase64String(salt), 120_000, HashAlgorithmName.SHA256, 32));
|
||||
|
||||
/// <summary>注册待验证时暂存的信息(存进验证码记录的 Payload,验证通过后才写进用户表)。</summary>
|
||||
public sealed class PendingRegistration
|
||||
{
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
public string PasswordSalt { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 管理/客户端 API(默认只监听 127.0.0.1,供 Webmail 与桌面客户端经反向代理访问)。
|
||||
///
|
||||
/// 认证:POST /api/login 换取 token,之后带 Authorization: Bearer <token>。
|
||||
/// 会话落盘(sessions.json),因此重启服务不会把已登录的客户端踢掉。
|
||||
///
|
||||
/// 端点一览见 README.md;v1 的 /api/login、/api/messages、/api/send、/api/config、
|
||||
/// /api/me、/api/logout、/api/account/password、/api/admin/users 全部保持兼容。
|
||||
/// </summary>
|
||||
public sealed class ApiServer
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly IMailStore store;
|
||||
private readonly AccountService accounts;
|
||||
private readonly HttpListener listener = new();
|
||||
/// <summary>可选的公网监听(只放账号类接口,见 <see cref="IsPublicAccountRoute"/>)。为空表示不开。</summary>
|
||||
private readonly HttpListener? publicListener = null;
|
||||
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public ApiServer(AppConfig config, IMailStore store)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
this.accounts = new AccountService(config, store);
|
||||
listener.Prefixes.Add(config.HttpPrefix);
|
||||
|
||||
// 公网监听(需要在 Windows 里先给这个前缀绑定证书:netsh http add sslcert hostnameport=...)
|
||||
if (!string.IsNullOrWhiteSpace(config.Api.PublicPrefix))
|
||||
{
|
||||
publicListener = new HttpListener();
|
||||
publicListener.Prefixes.Add(config.Api.PublicPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 公网监听**只**放行「账号相关」的接口:注册、验证码、找回密码、登录、改密、会话与资料。
|
||||
/// 邮件读写(/api/messages、/api/send、/api/queue、/api/watch、/api/drafts)与管理接口
|
||||
/// (/api/admin/*)**一律不在公网暴露** —— 那些只能从回环/受控网络访问。
|
||||
/// 这是刻意做窄的暴露面:客户端要在公网自助注册与改密码,但读信发信走 IMAP/SMTP。
|
||||
/// </summary>
|
||||
public static bool IsPublicAccountRoute(string path, string method) => method switch
|
||||
{
|
||||
"GET" => path is "/api/health" or "/api/version" or "/api/auth/policy"
|
||||
or "/api/me" or "/api/account/sessions" or "/api/account/audit",
|
||||
"POST" => path is "/api/login" or "/api/logout" or "/api/register" or "/api/register/verify"
|
||||
or "/api/register/resend" or "/api/auth/forgot" or "/api/auth/reset"
|
||||
or "/api/account/password" or "/api/account/sessions/revoke",
|
||||
"PATCH" => path is "/api/account/profile",
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>客户端 IP(本机调用时就是回环地址;审计与限流用)。</summary>
|
||||
private static string ClientIp(HttpListenerRequest request) =>
|
||||
request.RemoteEndPoint?.Address?.ToString() ?? "";
|
||||
|
||||
private static string UserAgent(HttpListenerRequest request) =>
|
||||
request.UserAgent ?? "";
|
||||
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
listener.Start();
|
||||
AppLog.Info($"[接口] 已监听:{config.HttpPrefix}");
|
||||
|
||||
// 公网监听走同一套处理器,靠 IsPublicAccountRoute 把路径收窄到账号类接口
|
||||
if (publicListener is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
publicListener.Start();
|
||||
AppLog.Info($"[接口] 公网账号入口已监听:{config.Api.PublicPrefix}"
|
||||
+ "(只放注册/验证/找回密码/登录/改密/会话资料;邮件与管理接口仍只在回环)");
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var context = await publicListener.GetContextAsync().WaitAsync(token);
|
||||
_ = Task.Run(() => SafeHandleAsync(context, isPublic: true), token);
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[接口] 公网账号入口启动失败:{ex.Message}"
|
||||
+ "(HTTPS 前缀需要先用 netsh http add sslcert hostnameport=<host:port> 绑定证书)");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var context = await listener.GetContextAsync().WaitAsync(token);
|
||||
_ = Task.Run(() => SafeHandleAsync(context), token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex) { AppLog.Error($"[接口] 监听异常:{ex.Message}"); }
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
try { publicListener?.Stop(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SafeHandleAsync(HttpListenerContext context, bool isPublic = false)
|
||||
{
|
||||
try { await HandleAsync(context, isPublic); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[接口] 未处理异常:{ex.Message}");
|
||||
try { await Reply(context.Response, new { error = "服务器内部错误" }, 500); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(HttpListenerContext context, bool isPublic = false)
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
response.Headers["Access-Control-Allow-Origin"] = config.Api.CorsOrigin;
|
||||
response.Headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type";
|
||||
response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, DELETE, OPTIONS";
|
||||
response.Headers["Access-Control-Expose-Headers"] = "Content-Disposition";
|
||||
|
||||
if (request.HttpMethod == "OPTIONS") { response.StatusCode = 204; response.Close(); return; }
|
||||
|
||||
var path = (request.Url?.AbsolutePath ?? "/").TrimEnd('/');
|
||||
var method = request.HttpMethod.ToUpperInvariant();
|
||||
|
||||
// 公网入口:白名单之外的路径一律当作「不存在」,不给探测者任何信息
|
||||
if (isPublic && !IsPublicAccountRoute(path, method))
|
||||
{
|
||||
AppLog.Warn($"[接口] 公网入口拒绝:{method} {request.Url?.PathAndQuery}(来自 {ClientIp(request)})");
|
||||
await Reply(response, new { error = "接口不存在" }, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
AppLog.Info($"[接口] {method} {request.Url?.PathAndQuery}");
|
||||
|
||||
// ---- 无需认证 ----
|
||||
if (path is "/api/health" && method == "GET")
|
||||
{
|
||||
await Reply(response, new { ok = true, service = "wpyw.mail.native", version = BuildInfo.Version, domain = config.Domain, hostname = config.Hostname });
|
||||
return;
|
||||
}
|
||||
if (path is "/api/version" && method == "GET")
|
||||
{
|
||||
await Reply(response, new { version = BuildInfo.Version, domain = config.Domain, hostname = config.Hostname, dkim = config.Dkim.Enabled });
|
||||
return;
|
||||
}
|
||||
if (path is "/api/login" && method == "POST")
|
||||
{
|
||||
await LoginAsync(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 账号体系:无需认证 ----
|
||||
if (path is "/api/auth/policy" && method == "GET")
|
||||
{
|
||||
await Reply(response, accounts.PolicyView());
|
||||
return;
|
||||
}
|
||||
if (path is "/api/register" && method == "POST")
|
||||
{
|
||||
var body = await ReadJsonAsync<RegisterRequest>(request);
|
||||
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
|
||||
var result = accounts.Register(body, ClientIp(request), UserAgent(request));
|
||||
await Reply(response, result.Ok
|
||||
? new { ok = true, verificationRequired = result.VerificationRequired, session = result.Session,
|
||||
email = body.Email?.Trim().ToLowerInvariant(),
|
||||
expiresInMinutes = result.VerificationRequired ? config.Accounts.CodeMinutes : (int?)null }
|
||||
: new { error = result.Error }, result.Status);
|
||||
return;
|
||||
}
|
||||
if (path is "/api/register/verify" && method == "POST")
|
||||
{
|
||||
var body = await ReadJsonAsync<VerifyCodeRequest>(request);
|
||||
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
|
||||
var (ok, status, error, newSession) = accounts.VerifyRegistration(body, ClientIp(request), UserAgent(request));
|
||||
await Reply(response, ok ? new { ok = true, session = newSession } : new { error }, status);
|
||||
return;
|
||||
}
|
||||
if (path is "/api/register/resend" && method == "POST")
|
||||
{
|
||||
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
|
||||
var email = body.GetValueOrDefault("email") ?? "";
|
||||
var purpose = body.TryGetValue("purpose", out var p) && !string.IsNullOrWhiteSpace(p) ? p : "register";
|
||||
var (ok, status, error) = accounts.ResendCode(email, purpose, ClientIp(request));
|
||||
await Reply(response, ok ? new { ok = true } : new { error }, status);
|
||||
return;
|
||||
}
|
||||
if (path is "/api/auth/forgot" && method == "POST")
|
||||
{
|
||||
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
|
||||
var email = body.GetValueOrDefault("email") ?? "";
|
||||
var (ok, error) = accounts.RequestReset(email, ClientIp(request));
|
||||
// 成功时也统一返回 ok:不暴露「这个邮箱是否存在」
|
||||
await Reply(response, ok ? new { ok = true, expiresInMinutes = config.Accounts.CodeMinutes } : new { error },
|
||||
ok ? 200 : 429);
|
||||
return;
|
||||
}
|
||||
if (path is "/api/auth/reset" && method == "POST")
|
||||
{
|
||||
var body = await ReadJsonAsync<ResetPasswordRequest>(request);
|
||||
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
|
||||
var (ok, status, error) = accounts.ResetPassword(body, ClientIp(request));
|
||||
await Reply(response, ok ? new { ok = true } : new { error }, status);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 以下都需要认证 ----
|
||||
var token = ExtractToken(request);
|
||||
var session = store.GetSession(token);
|
||||
var user = session is null ? null : store.FindUser(session.Email);
|
||||
if (user is null)
|
||||
{
|
||||
await Reply(response, new { error = "登录已失效,请重新登录" }, 401);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (path)
|
||||
{
|
||||
case "/api/logout" when method == "POST":
|
||||
store.RemoveSession(token!);
|
||||
await Reply(response, new { ok = true });
|
||||
return;
|
||||
|
||||
case "/api/me" when method == "GET":
|
||||
await Reply(response, new { user = Project(user), stats = store.Stats(user.Email) });
|
||||
return;
|
||||
|
||||
case "/api/config" when method == "GET":
|
||||
await Reply(response, new
|
||||
{
|
||||
domain = config.Domain,
|
||||
hostname = config.Hostname,
|
||||
account = user.Email,
|
||||
protocols = new { smtp = config.SmtpPort, submission = config.SubmissionPort, api = config.HttpPrefix, tls = config.Smtp.AdvertiseStartTls },
|
||||
features = new { dkim = config.Dkim.Enabled, attachments = true, watch = true, drafts = true },
|
||||
});
|
||||
return;
|
||||
|
||||
case "/api/messages" when method == "GET":
|
||||
await ListMessagesAsync(request, response, user);
|
||||
return;
|
||||
|
||||
case "/api/send" when method == "POST":
|
||||
await SendAsync(request, response, user);
|
||||
return;
|
||||
|
||||
case "/api/drafts" when method == "POST":
|
||||
await SaveDraftAsync(request, response, user);
|
||||
return;
|
||||
|
||||
case "/api/queue" when method == "GET":
|
||||
await Reply(response, new
|
||||
{
|
||||
queue = store.ListQueue(user.Email).Select(x => new
|
||||
{
|
||||
x.Id, x.MessageId, x.Recipients, x.Attempts, x.Status, x.LastError, x.LastCode,
|
||||
nextAttempt = x.NextAttempt, createdAt = x.CreatedAt, lastAttemptAt = x.LastAttemptAt,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
|
||||
case "/api/watch" when method == "GET":
|
||||
await WatchAsync(request, response, user);
|
||||
return;
|
||||
|
||||
case "/api/account/password" when method == "POST":
|
||||
await ChangePasswordAsync(request, response, user);
|
||||
return;
|
||||
|
||||
case "/api/account/profile" when method == "PATCH":
|
||||
await UpdateProfileAsync(request, response, user);
|
||||
return;
|
||||
|
||||
case "/api/account/sessions" when method == "GET":
|
||||
await Reply(response, accounts.SessionsView(user.Email, token));
|
||||
return;
|
||||
|
||||
case "/api/account/sessions/revoke" when method == "POST":
|
||||
await RevokeSessionsAsync(request, response, user, token);
|
||||
return;
|
||||
|
||||
case "/api/account/audit" when method == "GET":
|
||||
{
|
||||
var limit = int.TryParse(request.QueryString["limit"], out var l) ? l : 50;
|
||||
await Reply(response, new
|
||||
{
|
||||
events = store.ListAuthEvents(user.Email, null, null, limit).Select(ProjectAudit),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "/api/admin/audit" when method == "GET":
|
||||
{
|
||||
if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; }
|
||||
var limit = int.TryParse(request.QueryString["limit"], out var l) ? l : 100;
|
||||
var email = request.QueryString["email"];
|
||||
var ipFilter = request.QueryString["ip"];
|
||||
var reason = request.QueryString["reason"];
|
||||
await Reply(response, new
|
||||
{
|
||||
events = store.ListAuthEvents(email, ipFilter, reason, limit).Select(ProjectAudit),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "/api/admin/users":
|
||||
if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; }
|
||||
await AdminUsersAsync(request, response, method);
|
||||
return;
|
||||
}
|
||||
|
||||
// /api/messages/{id}...
|
||||
if (path.StartsWith("/api/messages/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await MessageRouteAsync(request, response, user, path["/api/messages/".Length..], method);
|
||||
return;
|
||||
}
|
||||
if (path.StartsWith("/api/queue/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var id = path["/api/queue/".Length..];
|
||||
if (id.EndsWith("/retry", StringComparison.OrdinalIgnoreCase) && method == "POST")
|
||||
{
|
||||
store.RetryQueueItem(user.Email, id[..^"/retry".Length]);
|
||||
await Reply(response, new { ok = true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (path.StartsWith("/api/admin/users/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; }
|
||||
await AdminUserPatchAsync(request, response, Uri.UnescapeDataString(path["/api/admin/users/".Length..]), method);
|
||||
return;
|
||||
}
|
||||
|
||||
await Reply(response, new { error = "接口不存在" }, 404);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
await Reply(response, new { error = ex.Message }, 400);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[接口] {method} {path}:{ex.Message}");
|
||||
await Reply(response, new { error = ex.Message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 认证
|
||||
|
||||
/// <summary>
|
||||
/// 登录。相比 v2.0.x 增加了三件事:
|
||||
/// ① 失败计数与临时锁定(同一账号在窗口内连续失败到阈值即锁定,返回 423 与剩余秒数);
|
||||
/// ② 审计(成功/失败/锁定都记 IP 与 UA,便于排查与限流);
|
||||
/// ③ 成功时更新 lastLoginAt 并签发会话。
|
||||
/// 对外错误信息统一为「邮箱或密码不正确」,真实原因只进审计,避免账号枚举。
|
||||
/// </summary>
|
||||
private async Task LoginAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
var body = await ReadJsonAsync<LoginRequest>(request);
|
||||
var ip = ClientIp(request);
|
||||
var agent = UserAgent(request);
|
||||
if (body is null || string.IsNullOrWhiteSpace(body.Email) || string.IsNullOrEmpty(body.Password))
|
||||
{
|
||||
await Reply(response, new { error = "请提供 email 与 password" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
var email = body.Email.Trim().ToLowerInvariant();
|
||||
|
||||
var locked = accounts.LockRemainingSeconds(email);
|
||||
if (locked > 0)
|
||||
{
|
||||
accounts.Record(email, ip, "login-locked", false, $"remaining={locked}s", agent);
|
||||
AppLog.Warn($"[接口] 登录被拒(锁定中):{email},剩余 {locked}s");
|
||||
await Reply(response, new { error = $"失败次数过多,账号已临时锁定,请 {Math.Ceiling(locked / 60.0)} 分钟后再试", retryAfterSeconds = locked }, 423);
|
||||
return;
|
||||
}
|
||||
|
||||
var user = store.Authenticate(email, body.Password);
|
||||
if (user is null)
|
||||
{
|
||||
// 失败原因只进审计;对外是否多说一句,由 AccountService 判断(见 LoginFailureHint)
|
||||
var hint = accounts.LoginFailureHint(email);
|
||||
accounts.Record(email, ip, "login-failed", false, hint.AuditReason, agent);
|
||||
var failures = store.CountAuthEvents(email, null, "login-failed", false, config.Accounts.LockoutMinutes);
|
||||
AppLog.Warn($"[接口] 登录失败:{email}({hint.AuditReason},{failures}/{config.Accounts.MaxLoginFailures})");
|
||||
await Reply(response, new { error = hint.Error, pendingVerification = hint.PendingVerification }, hint.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
var session = accounts.NewSession(user, ip, agent);
|
||||
await Reply(response, session!);
|
||||
}
|
||||
|
||||
private static string? ExtractToken(HttpListenerRequest request)
|
||||
{
|
||||
var header = request.Headers["Authorization"];
|
||||
if (string.IsNullOrWhiteSpace(header)) return null;
|
||||
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : header.Trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 邮件列表与详情
|
||||
|
||||
private static object Project(MailUser user) => new
|
||||
{
|
||||
email = user.Email,
|
||||
displayName = user.DisplayName,
|
||||
role = user.Role,
|
||||
active = user.Active,
|
||||
createdAt = user.CreatedAt,
|
||||
lastLoginAt = user.LastLoginAt,
|
||||
domain = user.Email.Split('@').LastOrDefault(),
|
||||
};
|
||||
|
||||
private async Task ListMessagesAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var query = request.QueryString;
|
||||
var folder = query["folder"] ?? "inbox";
|
||||
var search = query["q"] ?? "";
|
||||
var unreadOnly = query["unread"] is "1" or "true";
|
||||
var starredOnly = query["starred"] is "1" or "true";
|
||||
var limit = int.TryParse(query["limit"], out var parsedLimit) ? Math.Clamp(parsedLimit, 1, 500) : 100;
|
||||
var offset = int.TryParse(query["offset"], out var parsedOffset) ? Math.Max(0, parsedOffset) : 0;
|
||||
|
||||
var (total, items) = store.ListMessagesPage(user.Email, folder, search, unreadOnly, starredOnly, limit, offset);
|
||||
var page = items.Select(Summary).ToArray();
|
||||
await Reply(response, new { total, offset, limit, messages = page });
|
||||
}
|
||||
|
||||
private static object Summary(MailMessage message) => new
|
||||
{
|
||||
message.Id,
|
||||
message.Folder,
|
||||
message.From,
|
||||
message.To,
|
||||
message.Cc,
|
||||
message.Subject,
|
||||
message.Date,
|
||||
message.ReceivedAt,
|
||||
message.Unread,
|
||||
message.Starred,
|
||||
message.DeliveryStatus,
|
||||
message.LastError,
|
||||
message.Size,
|
||||
attachmentCount = message.Attachments.Count,
|
||||
hasAttachments = message.Attachments.Count > 0,
|
||||
preview = Preview(message.Text.Length > 0 ? message.Text : StripTags(message.Html)),
|
||||
};
|
||||
|
||||
private static string Preview(string text)
|
||||
{
|
||||
var flat = (text ?? "").Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return flat.Length <= 140 ? flat : flat[..140] + "…";
|
||||
}
|
||||
|
||||
private static string StripTags(string html)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html)) return "";
|
||||
var builder = new StringBuilder(html.Length);
|
||||
var inside = false;
|
||||
foreach (var c in html)
|
||||
{
|
||||
if (c == '<') { inside = true; continue; }
|
||||
if (c == '>') { inside = false; continue; }
|
||||
if (!inside) builder.Append(c);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private async Task MessageRouteAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string rest, string method)
|
||||
{
|
||||
var segments = rest.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0) { await Reply(response, new { error = "缺少邮件 id" }, 400); return; }
|
||||
var id = Uri.UnescapeDataString(segments[0]);
|
||||
var message = store.GetMessage(user.Email, id);
|
||||
if (message is null) { await Reply(response, new { error = "邮件不存在" }, 404); return; }
|
||||
|
||||
// /api/messages/{id}/raw 或 /api/messages/{id}/attachments/{index}
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
if (segments[1].Equals("raw", StringComparison.OrdinalIgnoreCase) && method == "GET")
|
||||
{
|
||||
var bytes = store.ReadRaw(message.RawPath);
|
||||
await ReplyBinary(response, bytes, "message/rfc822", $"{Sanitize(message.Subject)}.eml");
|
||||
return;
|
||||
}
|
||||
if (segments[1].Equals("attachments", StringComparison.OrdinalIgnoreCase) && method == "GET")
|
||||
{
|
||||
if (segments.Length < 3 || !int.TryParse(segments[2], out var index) ||
|
||||
index < 0 || index >= message.Attachments.Count)
|
||||
{
|
||||
await Reply(response, new { error = "附件不存在" }, 404);
|
||||
return;
|
||||
}
|
||||
var attachment = message.Attachments[index];
|
||||
var data = store.ReadAttachment(attachment.StoredAs);
|
||||
await ReplyBinary(response, data, attachment.ContentType, Sanitize(attachment.FileName));
|
||||
return;
|
||||
}
|
||||
await Reply(response, new { error = "接口不存在" }, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (method)
|
||||
{
|
||||
case "GET":
|
||||
{
|
||||
var markRead = !string.Equals(request.QueryString["markRead"], "false", StringComparison.OrdinalIgnoreCase);
|
||||
if (markRead && message.Unread) store.MarkRead(user.Email, id, true);
|
||||
await Reply(response, new { message = Detail(message, markRead) });
|
||||
return;
|
||||
}
|
||||
case "PATCH":
|
||||
{
|
||||
var body = await ReadJsonAsync<Dictionary<string, JsonElement>>(request) ?? [];
|
||||
if (body.TryGetValue("unread", out var unread) && unread.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
store.MarkRead(user.Email, id, !unread.GetBoolean());
|
||||
if (body.TryGetValue("read", out var read) && read.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
store.MarkRead(user.Email, id, read.GetBoolean());
|
||||
if (body.TryGetValue("starred", out var starred) && starred.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
store.SetStar(user.Email, id, starred.GetBoolean());
|
||||
if (body.TryGetValue("folder", out var folder) && folder.ValueKind == JsonValueKind.String)
|
||||
store.MoveMessage(user.Email, id, folder.GetString() ?? "inbox");
|
||||
var updated = store.GetMessage(user.Email, id)!;
|
||||
await Reply(response, new { message = Summary(updated) });
|
||||
return;
|
||||
}
|
||||
case "DELETE":
|
||||
{
|
||||
var permanent = string.Equals(request.QueryString["permanent"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
store.DeleteMessage(user.Email, id, permanent);
|
||||
await Reply(response, new { ok = true, permanent });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
await Reply(response, new { error = "不支持的请求方法" }, 405);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static object Detail(MailMessage message, bool markedRead) => new
|
||||
{
|
||||
message.Id,
|
||||
message.Folder,
|
||||
message.From,
|
||||
message.To,
|
||||
message.Cc,
|
||||
message.Subject,
|
||||
message.Text,
|
||||
message.Html,
|
||||
message.MessageId,
|
||||
message.InReplyTo,
|
||||
message.References,
|
||||
message.Date,
|
||||
message.ReceivedAt,
|
||||
unread = markedRead ? false : message.Unread,
|
||||
message.Starred,
|
||||
message.DeliveryStatus,
|
||||
message.LastError,
|
||||
message.Size,
|
||||
message.DkimSigned,
|
||||
attachments = message.Attachments.Select((a, index) => new
|
||||
{
|
||||
index,
|
||||
a.FileName,
|
||||
a.ContentType,
|
||||
a.Size,
|
||||
a.Inline,
|
||||
url = $"/api/messages/{message.Id}/attachments/{index}",
|
||||
}),
|
||||
rawUrl = $"/api/messages/{message.Id}/raw",
|
||||
};
|
||||
|
||||
private static string Sanitize(string name)
|
||||
{
|
||||
var cleaned = new string((name ?? "message").Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray()).Trim();
|
||||
return cleaned.Length == 0 ? "message" : cleaned;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 发信
|
||||
|
||||
private async Task SendAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var body = await ReadJsonAsync<SendRequest>(request);
|
||||
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
|
||||
|
||||
var recipients = Mime.Addresses(body.To ?? "");
|
||||
var cc = Mime.Addresses(body.Cc ?? "");
|
||||
if (recipients.Length == 0) { await Reply(response, new { error = "收件人不能为空" }, 400); return; }
|
||||
if (string.IsNullOrWhiteSpace(body.Text) && string.IsNullOrWhiteSpace(body.Html))
|
||||
{
|
||||
await Reply(response, new { error = "正文不能为空" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
var subject = string.IsNullOrWhiteSpace(body.Subject) ? "(无主题)" : body.Subject!;
|
||||
var stored = new List<Attachment>();
|
||||
var outgoing = new List<OutgoingAttachment>();
|
||||
foreach (var attachment in body.Attachments ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Base64)) continue;
|
||||
byte[] data;
|
||||
try { data = Convert.FromBase64String(attachment.Base64); }
|
||||
catch { await Reply(response, new { error = $"附件 {attachment.FileName} 不是合法 base64" }, 400); return; }
|
||||
|
||||
var contentType = string.IsNullOrWhiteSpace(attachment.ContentType) ? "application/octet-stream" : attachment.ContentType!;
|
||||
var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment.bin" : attachment.FileName!;
|
||||
stored.Add(new Attachment
|
||||
{
|
||||
FileName = fileName,
|
||||
ContentType = contentType,
|
||||
Size = data.Length,
|
||||
StoredAs = store.SaveAttachment(data, fileName),
|
||||
});
|
||||
outgoing.Add(new OutgoingAttachment(fileName, contentType, data));
|
||||
}
|
||||
|
||||
var raw = Mime.Build(new ComposeRequest(
|
||||
user.Email,
|
||||
user.DisplayName,
|
||||
recipients,
|
||||
cc,
|
||||
subject,
|
||||
body.Text ?? "",
|
||||
body.Html,
|
||||
outgoing,
|
||||
MessageId: null,
|
||||
InReplyTo: body.InReplyTo ?? "",
|
||||
References: body.InReplyTo ?? ""), config);
|
||||
|
||||
var message = store.QueueOutbound(user.Email, recipients, subject, body.Text ?? "", raw,
|
||||
string.Join(", ", cc), body.Html ?? "", body.InReplyTo ?? "", stored);
|
||||
|
||||
AppLog.Info($"[接口] 已入队发信:{user.Email} → {string.Join(", ", recipients)},主题:{subject}");
|
||||
await Reply(response, new { queued = true, messageId = message.Id, recipients, cc }, 202);
|
||||
}
|
||||
|
||||
private async Task SaveDraftAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var body = await ReadJsonAsync<DraftRequest>(request) ?? new DraftRequest("", "", "");
|
||||
var message = new MailMessage
|
||||
{
|
||||
OwnerEmail = user.Email,
|
||||
Folder = "drafts",
|
||||
From = user.Email,
|
||||
To = body.To ?? "",
|
||||
Subject = string.IsNullOrWhiteSpace(body.Subject) ? "(无主题)" : body.Subject!,
|
||||
Text = body.Text ?? "",
|
||||
Unread = false,
|
||||
DeliveryStatus = "draft",
|
||||
};
|
||||
store.SaveMessage(message);
|
||||
await Reply(response, new { message = Summary(message) }, 201);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 长轮询
|
||||
|
||||
private async Task WatchAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var since = long.TryParse(request.QueryString["since"], out var parsed) ? parsed : store.Version;
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(config.Api.LongPollSeconds, 1, 120));
|
||||
var stats = store.Stats(user.Email);
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline && store.Version == since)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
await Reply(response, new
|
||||
{
|
||||
version = store.Version,
|
||||
changed = store.Version != since,
|
||||
stats = store.Stats(user.Email),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 账号与管理员
|
||||
|
||||
private async Task ChangePasswordAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
|
||||
var check = accounts.CheckPassword(body.GetValueOrDefault("password"), user.Email);
|
||||
if (!check.Ok)
|
||||
{
|
||||
await Reply(response, new { error = check.Error }, 400);
|
||||
return;
|
||||
}
|
||||
var password = body["password"];
|
||||
if (body.TryGetValue("currentPassword", out var current) && store.Authenticate(user.Email, current) is null)
|
||||
{
|
||||
await Reply(response, new { error = "当前密码不正确" }, 403);
|
||||
return;
|
||||
}
|
||||
store.ChangePassword(user.Email, password);
|
||||
// 改密后把其他设备踢下线(当前这个 token 保留,避免自己也被踢)
|
||||
var revoked = store.RemoveSessions(user.Email, ExtractToken(request));
|
||||
accounts.Record(user.Email, ClientIp(request), "password-changed", true, $"sessions revoked={revoked}", UserAgent(request));
|
||||
AppLog.Info($"[接口] {user.Email} 已修改密码,吊销其他会话 {revoked} 个。");
|
||||
await Reply(response, new { ok = true, revokedSessions = revoked });
|
||||
}
|
||||
|
||||
private async Task UpdateProfileAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var body = await ReadJsonAsync<ProfileRequest>(request);
|
||||
if (body is null || body.DisplayName is null)
|
||||
{
|
||||
await Reply(response, new { error = "没有可更新的字段(目前支持 displayName)" }, 400);
|
||||
return;
|
||||
}
|
||||
var name = body.DisplayName.Trim();
|
||||
if (name.Length > 64) { await Reply(response, new { error = "显示名最多 64 个字符" }, 400); return; }
|
||||
accounts.UpdateProfile(user.Email, name);
|
||||
accounts.Record(user.Email, ClientIp(request), "profile-updated", true, name, UserAgent(request));
|
||||
var fresh = store.FindUser(user.Email);
|
||||
await Reply(response, new { ok = true, user = Project(fresh ?? user) });
|
||||
}
|
||||
|
||||
private async Task RevokeSessionsAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string? currentToken)
|
||||
{
|
||||
var body = await ReadJsonAsync<Dictionary<string, JsonElement>>(request) ?? [];
|
||||
var all = body.TryGetValue("all", out var a) && a.ValueKind is JsonValueKind.True;
|
||||
if (all)
|
||||
{
|
||||
var removed = accounts.RevokeSessions(user.Email, null);
|
||||
accounts.Record(user.Email, ClientIp(request), "session-revoked", true, $"all={removed}", UserAgent(request));
|
||||
await Reply(response, new { ok = true, revoked = removed, selfRevoked = true });
|
||||
return;
|
||||
}
|
||||
if (body.TryGetValue("token", out var t) && t.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var target = t.GetString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(target)) { await Reply(response, new { error = "token 不能为空" }, 400); return; }
|
||||
if (target == currentToken)
|
||||
{
|
||||
// 允许「把我自己这个会话也吊销」= 等价于登出
|
||||
store.RemoveSession(target);
|
||||
await Reply(response, new { ok = true, revoked = 1, selfRevoked = true });
|
||||
return;
|
||||
}
|
||||
// 只允许吊销属于该账号的会话,避免越权
|
||||
var mine = store.ListSessions(user.Email).Any(s => s.Token == target);
|
||||
if (!mine) { await Reply(response, new { error = "该会话不属于当前账号" }, 403); return; }
|
||||
store.RemoveSession(target);
|
||||
accounts.Record(user.Email, ClientIp(request), "session-revoked", true, "one", UserAgent(request));
|
||||
await Reply(response, new { ok = true, revoked = 1, selfRevoked = false });
|
||||
return;
|
||||
}
|
||||
// 默认行为:退出其他设备(保留当前)
|
||||
var count = accounts.RevokeSessions(user.Email, currentToken);
|
||||
accounts.Record(user.Email, ClientIp(request), "session-revoked", true, $"others={count}", UserAgent(request));
|
||||
await Reply(response, new { ok = true, revoked = count, selfRevoked = false });
|
||||
}
|
||||
|
||||
private static object ProjectAudit(AuthEvent e) => new
|
||||
{
|
||||
e.Id, e.Email, e.Ip, e.Reason, e.Success, e.Detail, e.UserAgent, e.At,
|
||||
};
|
||||
|
||||
private async Task AdminUsersAsync(HttpListenerRequest request, HttpListenerResponse response, string method)
|
||||
{
|
||||
if (method == "GET")
|
||||
{
|
||||
await Reply(response, new
|
||||
{
|
||||
users = store.ListUsers().Select(x => new { x.Email, x.DisplayName, x.Role, x.Active, x.CreatedAt, x.LastLoginAt }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method == "POST")
|
||||
{
|
||||
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
|
||||
if (!body.TryGetValue("email", out var email) || !body.TryGetValue("password", out var password) || password.Length < 12)
|
||||
{
|
||||
await Reply(response, new { error = "需要 email 与至少 12 位 password" }, 400);
|
||||
return;
|
||||
}
|
||||
var created = store.CreateUser(email, password, body.GetValueOrDefault("displayName", "") ?? "");
|
||||
await Reply(response, new { user = new { created.Email, created.DisplayName, created.Role, created.Active } }, 201);
|
||||
return;
|
||||
}
|
||||
await Reply(response, new { error = "不支持的请求方法" }, 405);
|
||||
}
|
||||
|
||||
private async Task AdminUserPatchAsync(HttpListenerRequest request, HttpListenerResponse response, string email, string method)
|
||||
{
|
||||
if (method != "PATCH") { await Reply(response, new { error = "不支持的请求方法" }, 405); return; }
|
||||
var body = await ReadJsonAsync<Dictionary<string, JsonElement>>(request) ?? [];
|
||||
if (body.TryGetValue("active", out var active) && active.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
store.SetUserActive(email, active.GetBoolean());
|
||||
await Reply(response, new { ok = true, email, active = active.GetBoolean() });
|
||||
return;
|
||||
}
|
||||
if (body.TryGetValue("password", out var password) && password.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = password.GetString() ?? "";
|
||||
if (value.Length < 12) { await Reply(response, new { error = "密码至少需要 12 个字符" }, 400); return; }
|
||||
store.ChangePassword(email, value);
|
||||
await Reply(response, new { ok = true, email });
|
||||
return;
|
||||
}
|
||||
await Reply(response, new { error = "没有可更新的字段" }, 400);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTP 工具
|
||||
|
||||
private static async Task<T?> ReadJsonAsync<T>(HttpListenerRequest request)
|
||||
{
|
||||
if (!request.HasEntityBody) return default;
|
||||
using var reader = new StreamReader(request.InputStream, Encoding.UTF8);
|
||||
var text = await reader.ReadToEndAsync();
|
||||
if (string.IsNullOrWhiteSpace(text)) return default;
|
||||
try { return JsonSerializer.Deserialize<T>(text, new JsonSerializerOptions(JsonSerializerDefaults.Web)); }
|
||||
catch (JsonException) { return default; }
|
||||
}
|
||||
|
||||
private Task Reply(HttpListenerResponse response, object value, int status = 200)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, json);
|
||||
response.StatusCode = status;
|
||||
response.ContentType = "application/json; charset=utf-8";
|
||||
response.ContentLength64 = bytes.Length;
|
||||
return WriteAndCloseAsync(response, bytes);
|
||||
}
|
||||
|
||||
private static async Task ReplyBinary(HttpListenerResponse response, byte[] data, string contentType, string fileName)
|
||||
{
|
||||
response.StatusCode = 200;
|
||||
response.ContentType = contentType;
|
||||
response.ContentLength64 = data.Length;
|
||||
response.Headers["Content-Disposition"] = $"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
|
||||
await WriteAndCloseAsync(response, data);
|
||||
}
|
||||
|
||||
private static async Task WriteAndCloseAsync(HttpListenerResponse response, byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
await response.OutputStream.WriteAsync(data);
|
||||
response.OutputStream.Flush();
|
||||
}
|
||||
catch (Exception) { /* 客户端提前断开 */ }
|
||||
finally { response.Close(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>版本信息(客户端可据此判断兼容性)。</summary>
|
||||
public static class BuildInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 2.0.1:修正 DKIM 签名输入顺序(RFC 6376 §3.7)。2.0.0 把 DKIM-Signature 头放在
|
||||
/// 签名输入的最前面并多带一个结尾 CRLF,导致所有合规验证器(Gmail/Outlook/port25)判 dkim=fail。
|
||||
///
|
||||
/// 2.2.0:账号体系(自助注册 / 登录加固 / 邮箱验证码 / 找回密码 / 会话与资料管理 / 认证审计)。
|
||||
/// 两条关键设计见 README 第 6.2.2 节:本机托管的邮箱注册免验证(否则验证码邮件投不进
|
||||
/// 还没开通的信箱,死循环);未激活账号先建信箱行、密码只存在验证码记录里。
|
||||
/// 2.2.1:入站 SPF/DKIM/DMARC 校验 + 垃圾判定(默认标注并把失败件投垃圾箱,不拒收);
|
||||
/// 新增 --verify-inbound 维护命令;顺带修掉签名端用 ASCII 取签名输入字节的潜在缺陷。
|
||||
/// </summary>
|
||||
public const string Version = "2.2.1";
|
||||
public const string Product = "wpyw.mail.native";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>极简日志:控制台 + 文件(超过阈值自动滚动一次)。</summary>
|
||||
public static class AppLog
|
||||
{
|
||||
private const long MaxBytes = 20 * 1024 * 1024;
|
||||
private static readonly object Gate = new();
|
||||
private static string path = "";
|
||||
private static bool consoleEnabled = true;
|
||||
|
||||
public static void Configure(AppConfig config, bool enableConsole = true)
|
||||
{
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
path = Path.Combine(config.DataDirectory, "service.log");
|
||||
consoleEnabled = enableConsole;
|
||||
Roll();
|
||||
Info($"日志系统已启动(版本 {BuildInfo.Version})。");
|
||||
}
|
||||
|
||||
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}";
|
||||
if (consoleEnabled)
|
||||
{
|
||||
try { Console.WriteLine(line); } catch { }
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
try
|
||||
{
|
||||
lock (Gate) File.AppendAllText(path, line + Environment.NewLine, new UTF8Encoding(false));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void Roll()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length < MaxBytes) return;
|
||||
var backup = path + ".1";
|
||||
if (File.Exists(backup)) File.Delete(backup);
|
||||
File.Move(path, backup);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 发件队列。轮询待发任务 → 读取原始报文 →(可选)DKIM 签名 → 投递 → 记录结果。
|
||||
///
|
||||
/// 相比 v1 的改进:
|
||||
/// 1. 重试策略可配置(4xx 与 5xx 分开处理,指数退避带上限);
|
||||
/// 2. DKIM 在投递时签名,因此每次重试都会带上新的时间戳;
|
||||
/// 3. 彻底失败时给发件人投递一封退信(NDR),不再静默丢失。
|
||||
/// </summary>
|
||||
public sealed class DeliveryQueue
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly IMailStore store;
|
||||
private readonly DirectSmtpDelivery delivery;
|
||||
private readonly DkimSigner? signer;
|
||||
|
||||
public DeliveryQueue(AppConfig config, IMailStore store, DkimSigner? signer)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
this.signer = signer;
|
||||
delivery = new DirectSmtpDelivery(config, store);
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
AppLog.Info($"[发送] 投递模式:{(config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) ? "SMTP 中继" : "按 MX 直接投递")}");
|
||||
AppLog.Info($"[发送] 重试策略:最多 {config.Retry.MaxAttempts} 次,初始间隔 {config.Retry.InitialDelaySeconds}s,上限 {config.Retry.MaxDelaySeconds}s" +
|
||||
(config.Retry.RetryOnPermanentFailure ? $",5xx 也重试 {config.Retry.MaxAttemptsForPermanent} 次" : ",5xx 视为永久失败"));
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
foreach (var job in store.TakeDueQueue(10))
|
||||
{
|
||||
if (token.IsCancellationRequested) break;
|
||||
await ProcessAsync(job, token);
|
||||
}
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(5), token); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(QueueItem job, CancellationToken token)
|
||||
{
|
||||
var targets = string.Join(", ", job.Recipients);
|
||||
try
|
||||
{
|
||||
var message = store.GetById(job.MessageId) ?? throw new InvalidOperationException("发送队列中的邮件不存在。");
|
||||
|
||||
// 每次投递都重新读取原始报文。
|
||||
// 关键顺序:先把行尾规范化为 CRLF,再签名 —— 这样签名覆盖的字节
|
||||
// 与传输时写出的字节完全一致(传输阶段只做 dot-stuffing)。
|
||||
var raw = SmtpDataEncoder.Normalize(store.ReadRaw(message.RawPath));
|
||||
if (signer is not null)
|
||||
{
|
||||
raw = signer.Sign(raw);
|
||||
message.DkimSigned = true;
|
||||
}
|
||||
|
||||
await delivery.DeliverAsync(message, job.Recipients, raw, token);
|
||||
store.CompleteQueue(job);
|
||||
AppLog.Info($"[发送] 投递成功:{targets}(第 {job.Attempts + 1} 次尝试)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
store.FailQueue(job, ex, config.Retry, out var gaveUp);
|
||||
if (gaveUp)
|
||||
{
|
||||
AppLog.Error($"[发送] 已放弃投递:{targets} —— {ex.Message}");
|
||||
if (config.Retry.SendBounceNotification) SendBounce(job, ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLog.Warn($"[发送] 投递失败(将重试):{targets} —— {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SendBounce(QueueItem job, Exception error)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = store.GetById(job.MessageId);
|
||||
if (message is null) return;
|
||||
store.CreateBounce(job.OwnerEmail, message.Subject, job.Recipients, error.Message, message.RawPath);
|
||||
AppLog.Info($"[发送] 已向 {job.OwnerEmail} 投递退信通知。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[发送] 生成退信失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 出站投递。支持两种模式:
|
||||
/// direct —— 查 MX 直接投递(默认)
|
||||
/// relay —— 交给上游 SMTP 中继(可带认证与 STARTTLS)
|
||||
///
|
||||
/// 相比 v1 的关键修正:
|
||||
/// DATA 阶段按「字节」写出(v1 用 Encoding.ASCII 的 StreamWriter,导致所有中文变成 '?'),
|
||||
/// 并显式处理 dot-stuffing 与行尾规范化。
|
||||
/// </summary>
|
||||
public sealed class DirectSmtpDelivery
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly IMailStore store;
|
||||
|
||||
public DirectSmtpDelivery(AppConfig config, IMailStore store)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public async Task DeliverAsync(MailMessage message, string[] recipients, byte[] raw, CancellationToken token)
|
||||
{
|
||||
if (recipients.Length == 0) throw new InvalidOperationException("没有可投递的收件人。");
|
||||
|
||||
// 本地收件人直接入库,不必绕一圈 SMTP 连回自己(也避免自签名证书导致的 TLS 自校验失败)
|
||||
var local = recipients.Where(r => IsValidAddress(r) && store.IsLocalAddress(r)).ToArray();
|
||||
foreach (var recipient in local)
|
||||
{
|
||||
store.DeliverLocal(recipient, raw, message.From);
|
||||
AppLog.Info($"[发送] 本地投递完成:{recipient}");
|
||||
}
|
||||
|
||||
var remote = recipients.Where(r => IsValidAddress(r) && !store.IsLocalAddress(r)).ToArray();
|
||||
if (remote.Length == 0) return;
|
||||
|
||||
if (config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var helo = string.IsNullOrWhiteSpace(config.DirectDelivery.HeloName) ? config.Hostname : config.DirectDelivery.HeloName;
|
||||
await SendAsync(config.Relay.Host, config.Relay.Port, message.From, remote, raw,
|
||||
startTls: config.Relay.EnableSsl, requireStartTls: config.Relay.EnableSsl,
|
||||
user: config.Relay.User, password: config.Relay.Password, helo: helo, token: token);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var group in remote.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 SmtpDeliveryException("MX 查询", 550, $"找不到 {domain} 的 MX 记录。", permanent: true);
|
||||
|
||||
var helo = string.IsNullOrWhiteSpace(config.DirectDelivery.HeloName) ? config.Hostname : config.DirectDelivery.HeloName;
|
||||
Exception? last = null;
|
||||
|
||||
foreach (var mxHost in mxHosts)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendAsync(mxHost, 25, sender, recipients, raw,
|
||||
startTls: config.DirectDelivery.OpportunisticStartTls,
|
||||
requireStartTls: config.DirectDelivery.RequireStartTls,
|
||||
user: "", password: "", helo: helo, token: token);
|
||||
return;
|
||||
}
|
||||
catch (StartTlsFailedException ex) when (!config.DirectDelivery.RequireStartTls)
|
||||
{
|
||||
// 机会式 TLS:握手失败(例如对方证书不受信)时必须真正回退明文重连,
|
||||
// 否则一次证书问题就会导致永久投递失败。
|
||||
AppLog.Warn($"[发送] {mxHost} 的 STARTTLS 失败({ex.Message}),改用明文重试同一 MX。");
|
||||
try
|
||||
{
|
||||
await SendAsync(mxHost, 25, sender, recipients, raw,
|
||||
startTls: false, requireStartTls: false,
|
||||
user: "", password: "", helo: helo, token: token);
|
||||
return;
|
||||
}
|
||||
catch (Exception inner) when (inner is IOException or SocketException or TimeoutException
|
||||
or InvalidOperationException or AuthenticationException or SmtpDeliveryException)
|
||||
{
|
||||
last = inner;
|
||||
AppLog.Warn($"[发送] MX {mxHost} 明文重试失败:{inner.Message}");
|
||||
}
|
||||
}
|
||||
catch (SmtpDeliveryException ex) when (ex.Permanent)
|
||||
{
|
||||
// 永久性拒绝:换下一个 MX 没有意义,直接上报
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException
|
||||
or AuthenticationException or StartTlsFailedException)
|
||||
{
|
||||
last = ex;
|
||||
AppLog.Warn($"[发送] MX {mxHost} 失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (last is SmtpDeliveryException smtp) throw smtp;
|
||||
throw new SmtpDeliveryException("投递", 451, $"无法投递到 {domain}:{last?.Message ?? "所有 MX 服务器均失败"}", permanent: false);
|
||||
}
|
||||
|
||||
/// <summary>与某个 SMTP 服务器完成一次投递事务。</summary>
|
||||
private async Task SendAsync(string host, int port, string sender, string[] recipients, byte[] raw,
|
||||
bool startTls, bool requireStartTls, string user, string password, string helo, CancellationToken token)
|
||||
{
|
||||
var connectionTimeout = TimeSpan.FromSeconds(Math.Max(5, config.DirectDelivery.ConnectionTimeoutSeconds));
|
||||
var commandTimeout = TimeSpan.FromSeconds(Math.Max(5, config.DirectDelivery.CommandTimeoutSeconds));
|
||||
|
||||
using var client = new TcpClient { NoDelay = true };
|
||||
await client.ConnectAsync(host, port, token).AsTask().WaitAsync(connectionTimeout, token);
|
||||
|
||||
Stream stream = client.GetStream();
|
||||
var reader = NewReader(stream);
|
||||
var writer = NewWriter(stream);
|
||||
|
||||
try
|
||||
{
|
||||
Expect(await ReadReplyAsync(reader, commandTimeout, token), "连接欢迎语", 220);
|
||||
|
||||
var hello = await CommandAsync(reader, writer, $"EHLO {helo}", commandTimeout, token);
|
||||
if (hello.Code is < 200 or >= 300)
|
||||
Expect(await CommandAsync(reader, writer, $"HELO {helo}", commandTimeout, token), "HELO", 250);
|
||||
else if (startTls && HasCapability(hello, "STARTTLS"))
|
||||
{
|
||||
var startTlsReply = await CommandAsync(reader, writer, "STARTTLS", commandTimeout, token);
|
||||
if (startTlsReply.Code == 220)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, _, _, errors) => errors == SslPolicyErrors.None);
|
||||
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
|
||||
{
|
||||
TargetHost = host,
|
||||
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
|
||||
}, token).WaitAsync(commandTimeout, token);
|
||||
stream = ssl;
|
||||
reader = NewReader(stream);
|
||||
writer = NewWriter(stream);
|
||||
hello = await CommandAsync(reader, writer, $"EHLO {helo}", commandTimeout, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is AuthenticationException or IOException)
|
||||
{
|
||||
if (requireStartTls)
|
||||
throw new SmtpDeliveryException("STARTTLS", 451, $"{host} 的 TLS 握手失败(要求加密):{ex.Message}", permanent: false);
|
||||
throw new StartTlsFailedException(ex.Message);
|
||||
}
|
||||
}
|
||||
else if (requireStartTls)
|
||||
{
|
||||
throw new SmtpDeliveryException("STARTTLS", startTlsReply.Code, "对方拒绝 STARTTLS。", permanent: false);
|
||||
}
|
||||
}
|
||||
else if (requireStartTls)
|
||||
{
|
||||
throw new SmtpDeliveryException("STARTTLS", 451, "对方未广告 STARTTLS。", permanent: false);
|
||||
}
|
||||
|
||||
// 中继模式需要认证
|
||||
if (!string.IsNullOrWhiteSpace(user))
|
||||
{
|
||||
var authPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0" + user + "\0" + password));
|
||||
var auth = await CommandAsync(reader, writer, "AUTH PLAIN " + authPayload, commandTimeout, token);
|
||||
if (auth.Code != 235) throw new SmtpDeliveryException("AUTH", auth.Code, auth.Detail, auth.Code is >= 500 and < 600);
|
||||
}
|
||||
|
||||
Expect(await CommandAsync(reader, writer, $"MAIL FROM:<{NormalizeAddress(sender)}>", commandTimeout, token), "MAIL FROM", 250, 251);
|
||||
|
||||
var accepted = new List<string>();
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var reply = await CommandAsync(reader, writer, $"RCPT TO:<{NormalizeAddress(recipient)}>", commandTimeout, token);
|
||||
if (reply.Code is 250 or 251) { accepted.Add(recipient); continue; }
|
||||
// 收件人被拒:5xx 视为这封邮件对该收件人永久失败
|
||||
throw new SmtpDeliveryException($"RCPT TO {recipient}", reply.Code, reply.Detail, reply.Code is >= 500 and < 600);
|
||||
}
|
||||
if (accepted.Count == 0) throw new SmtpDeliveryException("RCPT TO", 550, "所有收件人都被拒绝。", permanent: true);
|
||||
|
||||
Expect(await CommandAsync(reader, writer, "DATA", commandTimeout, token), "DATA", 354);
|
||||
await WriteDataAsync(stream, raw, token);
|
||||
var result = await ReadReplyAsync(reader, commandTimeout, token);
|
||||
if (result.Code != 250)
|
||||
throw new SmtpDeliveryException("邮件正文", result.Code, result.Detail, result.Code is >= 500 and < 600);
|
||||
|
||||
await TryQuitAsync(reader, writer, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await stream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写出 DATA 段。
|
||||
///
|
||||
/// 这里是 v1 中文变 '?' 的根因所在(v1 用 Encoding.ASCII 的 StreamWriter 写),
|
||||
/// 现在改为按字节写出,且除 dot-stuffing 外不改动任何字节——
|
||||
/// 行尾规范化已在签名之前完成(见 SmtpDataEncoder.Normalize),
|
||||
/// 若在此处再改行尾会让 DKIM 正文哈希对不上。
|
||||
/// </summary>
|
||||
private static async Task WriteDataAsync(Stream stream, byte[] raw, CancellationToken token)
|
||||
{
|
||||
var payload = SmtpDataEncoder.Encode(SmtpDataEncoder.Normalize(raw));
|
||||
await stream.WriteAsync(payload, token);
|
||||
await stream.FlushAsync(token);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- SMTP 会话
|
||||
|
||||
private async Task<SmtpReply> CommandAsync(StreamReader reader, StreamWriter writer, string command, TimeSpan timeout, CancellationToken token)
|
||||
{
|
||||
await writer.WriteLineAsync(command).WaitAsync(timeout, token);
|
||||
return await ReadReplyAsync(reader, timeout, token);
|
||||
}
|
||||
|
||||
private static async Task<SmtpReply> ReadReplyAsync(StreamReader reader, TimeSpan timeout, CancellationToken token)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var first = await reader.ReadLineAsync(token).AsTask().WaitAsync(timeout, 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(timeout, 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.Detail, reply.Code is >= 500 and < 600);
|
||||
}
|
||||
|
||||
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 { }
|
||||
}
|
||||
|
||||
// SMTP 控制通道只传 ASCII 命令,读响应也只需 ASCII;DATA 走字节通道,与此无关
|
||||
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 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)
|
||||
{
|
||||
/// <summary>取最后一行去掉状态码后的文本,便于写入日志。</summary>
|
||||
public string Detail => Lines.Count == 0 ? "" : (Lines[^1].Length > 4 ? Lines[^1][4..] : Lines[^1]);
|
||||
}
|
||||
|
||||
/// <summary>STARTTLS 握手失败(机会式 TLS 场景下应回退明文重连)。</summary>
|
||||
private sealed class StartTlsFailedException(string message) : Exception(message);
|
||||
}
|
||||
|
||||
/// <summary>SMTP 阶段异常,带状态码与「是否永久失败」判定。</summary>
|
||||
public sealed class SmtpDeliveryException : Exception
|
||||
{
|
||||
public SmtpDeliveryException(string step, int code, string detail, bool permanent)
|
||||
: base($"{step} 失败:{code} {detail}")
|
||||
{
|
||||
Step = step;
|
||||
Code = code;
|
||||
Permanent = permanent;
|
||||
}
|
||||
|
||||
public string Step { get; }
|
||||
public int Code { get; }
|
||||
public bool Permanent { get; }
|
||||
}
|
||||
|
||||
/// <summary>极简 DNS MX 查询(不依赖第三方库,直接走 UDP 53)。</summary>
|
||||
internal static class MxResolver
|
||||
{
|
||||
public static async Task<IReadOnlyList<string>> ResolveAsync(string domain, DirectDeliveryConfig config, CancellationToken token)
|
||||
{
|
||||
foreach (var server in GetDnsServers(config.DnsServer))
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 MX 记录时按 RFC 5321 回退到 A 记录
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(domain, token);
|
||||
return addresses.Where(a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||
.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)); // MX
|
||||
writer.Write(ToNetwork((ushort)1)); // IN
|
||||
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)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
return system.Length > 0 ? system : [IPAddress.Parse("223.5.5.5"), IPAddress.Parse("1.1.1.1")];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// DKIM 签名(RFC 6376),算法 rsa-sha256,规范化 relaxed/relaxed。
|
||||
///
|
||||
/// 私钥不存在时会自动生成 2048 位 RSA 并保存为 PEM,同时把需要配置到 DNS 的
|
||||
/// TXT 记录打印到日志,方便直接复制到 Cloudflare。
|
||||
/// </summary>
|
||||
public sealed class DkimSigner
|
||||
{
|
||||
private const string Crlf = "\r\n";
|
||||
|
||||
private readonly RSA key;
|
||||
private readonly string domain;
|
||||
private readonly string selector;
|
||||
private readonly string[] signHeaders;
|
||||
|
||||
public string PrivateKeyPath { get; }
|
||||
|
||||
/// <summary>SubjectPublicKeyInfo 形式的公钥(自检与 DNS 记录生成使用)。</summary>
|
||||
public byte[] PublicKeyBytes => key.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>DKIM 公钥所在的 DNS 记录名(不含域后缀)。</summary>
|
||||
public string RecordName => $"{selector}._domainkey";
|
||||
|
||||
/// <summary>DKIM 公钥记录值(TXT)。</summary>
|
||||
public string RecordValue => "v=DKIM1; k=rsa; p=" + Convert.ToBase64String(PublicKeyBytes);
|
||||
|
||||
private DkimSigner(RSA key, string domain, string selector, string privateKeyPath, string[] headers)
|
||||
{
|
||||
this.key = key;
|
||||
this.domain = domain;
|
||||
this.selector = selector;
|
||||
PrivateKeyPath = privateKeyPath;
|
||||
signHeaders = headers;
|
||||
}
|
||||
|
||||
/// <summary>按配置创建签名器;未启用或初始化失败时返回 null(调用方继续正常发信)。</summary>
|
||||
public static DkimSigner? Create(AppConfig config)
|
||||
{
|
||||
if (!config.Dkim.Enabled) return null;
|
||||
try
|
||||
{
|
||||
var domain = string.IsNullOrWhiteSpace(config.Dkim.SigningDomain) ? config.Domain : config.Dkim.SigningDomain;
|
||||
var selector = string.IsNullOrWhiteSpace(config.Dkim.Selector) ? "mail" : config.Dkim.Selector.Trim();
|
||||
var path = config.Dkim.PrivateKeyPath;
|
||||
if (string.IsNullOrWhiteSpace(path)) path = Path.Combine(config.DataDirectory, "dkim", selector + ".private.pem");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
RSA rsa;
|
||||
if (File.Exists(path))
|
||||
{
|
||||
rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(path));
|
||||
AppLog.Info($"[DKIM] 已加载私钥:{path}({rsa.KeySize} 位,选择器 {selector},域 {domain})");
|
||||
}
|
||||
else
|
||||
{
|
||||
rsa = RSA.Create(2048);
|
||||
File.WriteAllText(path, rsa.ExportPkcs8PrivateKeyPem(), new UTF8Encoding(false));
|
||||
AppLog.Info($"[DKIM] 已生成新的 2048 位私钥:{path}");
|
||||
}
|
||||
|
||||
var signer = new DkimSigner(rsa, domain, selector, path, config.Dkim.Headers);
|
||||
signer.PrintDnsRecord();
|
||||
return signer;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[DKIM] 初始化失败,将不签名继续发信:{ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把需要配置到 DNS 的公钥记录打印出来(分段给 TXT 用)。</summary>
|
||||
public void PrintDnsRecord()
|
||||
{
|
||||
var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo());
|
||||
var name = $"{selector}._domainkey.{domain}";
|
||||
var value = "v=DKIM1; k=rsa; p=" + publicKey;
|
||||
AppLog.Info($"[DKIM] 请在 DNS 添加 TXT 记录 —— 名称:{name}");
|
||||
AppLog.Info($"[DKIM] 值(TXT,可直接整条粘贴):{value}");
|
||||
foreach (var chunk in Chunk(value, 255))
|
||||
{
|
||||
AppLog.Info($"[DKIM] (分段)\"{chunk}\"");
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<string> Chunk(string value, int size)
|
||||
{
|
||||
for (var index = 0; index < value.Length; index += size)
|
||||
{
|
||||
yield return value.Substring(index, Math.Min(size, value.Length - index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>对整封邮件签名,返回带 DKIM-Signature 头的新报文。失败时原样返回。</summary>
|
||||
public byte[] Sign(byte[] message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (rawHeaders, body) = SplitRaw(message);
|
||||
if (rawHeaders.Count == 0) return message;
|
||||
if (!rawHeaders.Any(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
AppLog.Warn("[DKIM] 报文缺少 From 头,跳过签名。");
|
||||
return message;
|
||||
}
|
||||
|
||||
var bodyHash = Convert.ToBase64String(SHA256.HashData(CanonicalizeBody(body)));
|
||||
|
||||
// 按配置顺序挑出实际存在的头(每个名字只签第一次出现)
|
||||
var chosen = new List<(string Name, string Value)>();
|
||||
foreach (var name in signHeaders)
|
||||
{
|
||||
var hit = rawHeaders.FirstOrDefault(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (hit.Name is not null && !chosen.Any(c => c.Name.Equals(hit.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
chosen.Add(hit);
|
||||
}
|
||||
if (!chosen.Any(c => c.Name.Equals("From", StringComparison.OrdinalIgnoreCase)))
|
||||
chosen.Insert(0, rawHeaders.First(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
var headerList = string.Join(":", chosen.Select(c => c.Name.ToLowerInvariant()));
|
||||
var baseValue = $"v=1; a=rsa-sha256; c=relaxed/relaxed; d={domain}; s={selector}; " +
|
||||
$"t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}; h={headerList}; bh={bodyHash}; b=";
|
||||
|
||||
// RFC 6376 §3.7「hash step 2」规定的顺序,必须严格照做:
|
||||
// 1. 先按 h= 标签里的顺序哈希各被签名头,**每个头后面跟一个 CRLF**;
|
||||
// 2. **最后**才哈希 DKIM-Signature 头本身(b= 视为空串),且它**结尾不带 CRLF**。
|
||||
// 把 DKIM-Signature 放在最前面、或给它补一个结尾 CRLF,都会让合规的验证器
|
||||
// (Gmail / Outlook / OpenDKIM / Mail::DKIM / dkimpy)算出不同的摘要 ——
|
||||
// 表现为对方直接判 dkim=fail,而自家自测却可能因为「签名端与验签端犯同一个错」
|
||||
// 而假通过。2026-09-13 的真实教训:port25 的验证器就是这样把这个 bug 揪出来的。
|
||||
var signingInput = new StringBuilder();
|
||||
foreach (var (name, value) in chosen)
|
||||
{
|
||||
signingInput.Append(CanonicalizeHeader(name, value)).Append(Crlf);
|
||||
}
|
||||
signingInput.Append(CanonicalizeHeader("DKIM-Signature", baseValue));
|
||||
|
||||
// ⚠ 必须用 Latin1(字节保真)取输入字节,**不能用 ASCII**:
|
||||
// ASCII 编码会把非 ASCII 字符替换成 '?',如果报文头是 8bit 裸 UTF-8(不是 RFC2047
|
||||
// 编码字),签名输入就与实际字节不一致 → 对方验签失败。
|
||||
// 我们自己发出的信因为头都经过 RFC2047 编码,所以一直是 ASCII(这条缺陷因此潜伏着),
|
||||
// 但验签端按真实字节算,一旦遇到 8bit 头就会对不上。自检的 DKIM 用例把它逼了出来。
|
||||
var signature = key.SignData(Encoding.Latin1.GetBytes(signingInput.ToString()),
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
|
||||
var signatureHeader = $"DKIM-Signature: {baseValue}{Convert.ToBase64String(signature)}{Crlf}";
|
||||
var output = new MemoryStream(message.Length + signatureHeader.Length + 64);
|
||||
output.Write(Encoding.ASCII.GetBytes(signatureHeader));
|
||||
output.Write(message);
|
||||
return output.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[DKIM] 签名失败,将发送未签名报文:{ex.Message}");
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 规范化
|
||||
|
||||
/// <summary>relaxed 头规范化:小写名、展开折行、多空白折成一个空格、去首尾空白。</summary>
|
||||
private static string CanonicalizeHeader(string name, string value)
|
||||
{
|
||||
var unfolded = value.Replace("\r\n", "").Replace("\n", "");
|
||||
var collapsed = CollapseWhitespace(unfolded).Trim();
|
||||
return name.Trim().ToLowerInvariant() + ":" + collapsed;
|
||||
}
|
||||
|
||||
/// <summary>relaxed 正文规范化:多空白折成一个空格、去行尾空白、去掉末尾空行。</summary>
|
||||
private static byte[] CanonicalizeBody(byte[] body)
|
||||
{
|
||||
var text = Encoding.Latin1.GetString(body).Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
var lines = text.Split('\n');
|
||||
var normalized = lines.Select(line => CollapseWhitespace(line).TrimEnd(' ', '\t'));
|
||||
var joined = string.Join(Crlf, normalized);
|
||||
joined = joined.TrimEnd('\r', '\n');
|
||||
if (joined.Length > 0) joined += Crlf;
|
||||
return Encoding.Latin1.GetBytes(joined);
|
||||
}
|
||||
|
||||
private static string CollapseWhitespace(string value)
|
||||
{
|
||||
var builder = new StringBuilder(value.Length);
|
||||
var inWhitespace = false;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c is ' ' or '\t')
|
||||
{
|
||||
if (!inWhitespace) builder.Append(' ');
|
||||
inWhitespace = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(c);
|
||||
inWhitespace = false;
|
||||
}
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>把报文拆成「原始头行(未展开)」与「正文字节」,保持字节忠实。</summary>
|
||||
private static (List<(string Name, string Value)> Headers, byte[] Body) SplitRaw(byte[] message)
|
||||
{
|
||||
var headers = new List<(string, string)>();
|
||||
var index = 0;
|
||||
string? currentName = null;
|
||||
var currentValue = new StringBuilder();
|
||||
|
||||
while (index < message.Length)
|
||||
{
|
||||
var lineEnd = Array.IndexOf(message, (byte)'\n', index);
|
||||
if (lineEnd < 0) lineEnd = message.Length;
|
||||
var line = Encoding.Latin1.GetString(message, index, lineEnd - index).TrimEnd('\r');
|
||||
index = lineEnd + 1;
|
||||
|
||||
if (line.Length == 0) break; // 头结束
|
||||
if ((line[0] == ' ' || line[0] == '\t') && currentName is not null)
|
||||
{
|
||||
currentValue.Append(Crlf).Append(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentName is not null) headers.Add((currentName, currentValue.ToString()));
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon <= 0) { currentName = null; currentValue.Clear(); continue; }
|
||||
currentName = line[..colon].Trim();
|
||||
currentValue.Clear();
|
||||
currentValue.Append(line[(colon + 1)..]);
|
||||
}
|
||||
if (currentName is not null) headers.Add((currentName, currentValue.ToString()));
|
||||
|
||||
var bodyStart = Math.Min(index, message.Length);
|
||||
return (headers, message[bodyStart..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 基于文件的存储:users.json / messages.json / queue.json / sessions.json + raw/ 与 attachments/。
|
||||
/// 单机个人邮箱场景下,这种实现的可靠性与可审计性优于引入数据库依赖。
|
||||
///
|
||||
/// 全量写入 + 原子替换(写 .tmp 再 Move),并对所有变更加锁。
|
||||
///
|
||||
/// ⚠️ 性能特征:**任何一次改动都会把全部邮件重新序列化并整文件重写**(O(N)),
|
||||
/// 邮件量上千以后单次「标记已读」也会变得明显昂贵。v2.1 起默认使用
|
||||
/// <see cref="SqliteStore"/>,本实现保留作为可回滚的后端(Storage.Provider=json)。
|
||||
/// </summary>
|
||||
public sealed class FileStore : IMailStore
|
||||
{
|
||||
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 sessionsPath;
|
||||
private readonly string verificationPath;
|
||||
private readonly string authEventsPath;
|
||||
private readonly string rawDirectory;
|
||||
private readonly string attachmentDirectory;
|
||||
private readonly AppConfig config;
|
||||
|
||||
private List<MailUser> users = [];
|
||||
private List<MailMessage> messages = [];
|
||||
private List<QueueItem> queue = [];
|
||||
private List<SessionRecord> sessions = [];
|
||||
private List<VerificationCode> codes = [];
|
||||
private List<AuthEvent> authEvents = [];
|
||||
|
||||
/// <summary>审计保留条数(由 ApiServer 按配置注入)。</summary>
|
||||
public int AuditKeep { get; set; } = 2000;
|
||||
|
||||
/// <summary>每次写入都会自增,供长轮询判断「有没有新变化」。</summary>
|
||||
private long version;
|
||||
|
||||
public FileStore(AppConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
rawDirectory = Path.Combine(config.DataDirectory, "raw");
|
||||
attachmentDirectory = Path.Combine(config.DataDirectory, "attachments");
|
||||
Directory.CreateDirectory(rawDirectory);
|
||||
Directory.CreateDirectory(attachmentDirectory);
|
||||
usersPath = Path.Combine(config.DataDirectory, "users.json");
|
||||
messagesPath = Path.Combine(config.DataDirectory, "messages.json");
|
||||
queuePath = Path.Combine(config.DataDirectory, "queue.json");
|
||||
sessionsPath = Path.Combine(config.DataDirectory, "sessions.json");
|
||||
verificationPath = Path.Combine(config.DataDirectory, "verification.json");
|
||||
authEventsPath = Path.Combine(config.DataDirectory, "auth-events.json");
|
||||
Load();
|
||||
EnsureAdmin();
|
||||
}
|
||||
|
||||
public long Version { get { lock (gate) return version; } }
|
||||
|
||||
private void Load()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
users = Read<List<MailUser>>(usersPath) ?? [];
|
||||
messages = Read<List<MailMessage>>(messagesPath) ?? [];
|
||||
queue = Read<List<QueueItem>>(queuePath) ?? [];
|
||||
sessions = Read<List<SessionRecord>>(sessionsPath) ?? [];
|
||||
codes = Read<List<VerificationCode>>(verificationPath) ?? [];
|
||||
authEvents = Read<List<AuthEvent>>(authEventsPath) ?? [];
|
||||
|
||||
// 上次异常退出时残留的 processing 状态回退成 retry
|
||||
foreach (var item in queue.Where(x => x.Status == "processing"))
|
||||
{
|
||||
item.Status = "retry";
|
||||
item.NextAttempt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
// 过期的会话直接清掉
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
sessions.RemoveAll(x => x.Expires < now);
|
||||
|
||||
// 给历史邮件补 IMAP UID(同一账号同一文件夹内按时间递增)
|
||||
var assigned = false;
|
||||
foreach (var group in messages.Where(m => m.Uid == 0).GroupBy(m => (m.OwnerEmail.ToLowerInvariant(), m.Folder.ToLowerInvariant())))
|
||||
{
|
||||
var uid = messages.Where(m => m.OwnerEmail.Equals(group.Key.Item1, StringComparison.OrdinalIgnoreCase)
|
||||
&& m.Folder.Equals(group.Key.Item2, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(m => m.Uid).DefaultIfEmpty(0).Max();
|
||||
foreach (var message in group.OrderBy(m => m.Date))
|
||||
{
|
||||
message.Uid = ++uid;
|
||||
assigned = true;
|
||||
}
|
||||
}
|
||||
if (assigned) Write(messagesPath, messages);
|
||||
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
private T? Read<T>(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return default;
|
||||
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), json); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Error($"[存储] 读取 {Path.GetFileName(path)} 失败,将从空数据继续:{ex.Message}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Write<T>(string path, T value)
|
||||
{
|
||||
var temp = path + ".tmp";
|
||||
File.WriteAllText(temp, JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }), new UTF8Encoding(false));
|
||||
File.Move(temp, path, true);
|
||||
}
|
||||
|
||||
private void EnsureAdmin()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var existing = users.FirstOrDefault(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is not null)
|
||||
{
|
||||
// 配置里的密码变了就同步(方便改密码后重启生效)
|
||||
if (!VerifyPassword(config.AdminPassword, existing.PasswordHash, existing.PasswordSalt))
|
||||
{
|
||||
existing.PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||
existing.PasswordHash = HashPassword(config.AdminPassword, existing.PasswordSalt);
|
||||
Write(usersPath, users);
|
||||
AppLog.Info($"[存储] 已按 appsettings.json 更新 {existing.Email} 的密码。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var user = new MailUser
|
||||
{
|
||||
Email = config.AdminEmail.ToLowerInvariant(),
|
||||
DisplayName = "Administrator",
|
||||
Role = "admin",
|
||||
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||
};
|
||||
user.PasswordHash = HashPassword(config.AdminPassword, user.PasswordSalt);
|
||||
users.Add(user);
|
||||
Write(usersPath, users);
|
||||
AppLog.Info($"[存储] 已创建管理员邮箱:{user.Email}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 用户
|
||||
|
||||
public MailUser? FindUser(string email) =>
|
||||
users.FirstOrDefault(x => x.Active && x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public MailUser? FindUserAnyState(string email) =>
|
||||
users.FirstOrDefault(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public MailUser? Authenticate(string email, string password)
|
||||
{
|
||||
var user = FindUser(email);
|
||||
if (user is null) return null;
|
||||
if (!VerifyPassword(password ?? "", user.PasswordHash, user.PasswordSalt)) return null;
|
||||
lock (gate)
|
||||
{
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
Write(usersPath, users);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
public bool IsLocalAddress(string email) =>
|
||||
FindUser(email) is not null || users.Any(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public IReadOnlyList<MailUser> ListUsers() => users.OrderBy(x => x.Email).ToArray();
|
||||
|
||||
public MailUser CreateUser(string email, string password, string displayName)
|
||||
{
|
||||
email = (email ?? "").Trim().ToLowerInvariant();
|
||||
if (!email.Contains('@')) throw new InvalidOperationException("邮箱地址不合法。");
|
||||
if (users.Any(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("用户已存在。");
|
||||
var user = new MailUser
|
||||
{
|
||||
Email = email,
|
||||
DisplayName = displayName ?? "",
|
||||
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
user.PasswordHash = HashPassword(password, user.PasswordSalt);
|
||||
lock (gate) { users.Add(user); Write(usersPath, users); version++; }
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>用已算好的哈希建号(注册验证通过时用,避免明文密码再走一遍内存)。</summary>
|
||||
public MailUser CreateUserWithHash(string email, string passwordHash, string passwordSalt, string displayName, bool active = true)
|
||||
{
|
||||
email = (email ?? "").Trim().ToLowerInvariant();
|
||||
if (!email.Contains('@')) throw new InvalidOperationException("邮箱地址不合法。");
|
||||
if (string.IsNullOrEmpty(passwordHash) || string.IsNullOrEmpty(passwordSalt))
|
||||
throw new InvalidOperationException("密码哈希不能为空。");
|
||||
lock (gate)
|
||||
{
|
||||
var existing = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is not null)
|
||||
{
|
||||
// 允许「上次注册没验证完」的账号重新注册:覆盖密码与显示名,保持未激活
|
||||
if (existing.Active) throw new InvalidOperationException("用户已存在。");
|
||||
existing.DisplayName = displayName ?? existing.DisplayName;
|
||||
existing.PasswordHash = passwordHash;
|
||||
existing.PasswordSalt = passwordSalt;
|
||||
Write(usersPath, users);
|
||||
version++;
|
||||
return existing;
|
||||
}
|
||||
var user = new MailUser
|
||||
{
|
||||
Email = email,
|
||||
DisplayName = displayName ?? "",
|
||||
PasswordHash = passwordHash,
|
||||
PasswordSalt = passwordSalt,
|
||||
Active = active,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
users.Add(user);
|
||||
Write(usersPath, users);
|
||||
version++;
|
||||
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 void SetUserActive(string email, bool active)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var user = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||
if (user is null) return;
|
||||
user.Active = active;
|
||||
Write(usersPath, users);
|
||||
}
|
||||
}
|
||||
|
||||
public bool DeleteUser(string email)
|
||||
{
|
||||
var key = (email ?? "").Trim();
|
||||
if (key.Length == 0) return false;
|
||||
lock (gate)
|
||||
{
|
||||
var user = users.FirstOrDefault(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
if (user is null) return false;
|
||||
users.Remove(user);
|
||||
sessions.RemoveAll(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
codes.RemoveAll(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
queue.RemoveAll(x => x.OwnerEmail.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
Write(usersPath, users);
|
||||
Write(sessionsPath, sessions);
|
||||
Write(verificationPath, codes);
|
||||
Write(queuePath, queue);
|
||||
version++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 会话
|
||||
|
||||
public SessionRecord CreateSession(string email, int days)
|
||||
{
|
||||
var record = new SessionRecord
|
||||
{
|
||||
Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(),
|
||||
Email = email,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(Math.Max(1, days)),
|
||||
};
|
||||
lock (gate)
|
||||
{
|
||||
sessions.RemoveAll(x => x.Expires < DateTimeOffset.UtcNow);
|
||||
sessions.Add(record);
|
||||
Write(sessionsPath, sessions);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
public SessionRecord? GetSession(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||
lock (gate)
|
||||
{
|
||||
var record = sessions.FirstOrDefault(x => x.Token.Equals(token, StringComparison.Ordinal));
|
||||
if (record is null) return null;
|
||||
if (record.Expires < DateTimeOffset.UtcNow) { sessions.Remove(record); Write(sessionsPath, sessions); return null; }
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveSession(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return;
|
||||
lock (gate) { sessions.RemoveAll(x => x.Token.Equals(token, StringComparison.Ordinal)); Write(sessionsPath, sessions); }
|
||||
}
|
||||
|
||||
public IReadOnlyList<SessionRecord> ListSessions(string email)
|
||||
{
|
||||
var key = (email ?? "").Trim();
|
||||
lock (gate)
|
||||
{
|
||||
return [.. sessions
|
||||
.Where(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) && x.Expires >= DateTimeOffset.UtcNow)
|
||||
.OrderByDescending(x => x.CreatedAt)];
|
||||
}
|
||||
}
|
||||
|
||||
public int RemoveSessions(string email, string? keepToken)
|
||||
{
|
||||
var key = (email ?? "").Trim();
|
||||
lock (gate)
|
||||
{
|
||||
var removed = sessions.RemoveAll(x =>
|
||||
x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||
(string.IsNullOrWhiteSpace(keepToken) || !x.Token.Equals(keepToken, StringComparison.Ordinal)));
|
||||
if (removed > 0) { Write(sessionsPath, sessions); version++; }
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 账号体系(注册 / 验证码 / 审计)
|
||||
|
||||
public void UpdateProfile(string email, string displayName)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var user = users.FirstOrDefault(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (user is null) return;
|
||||
user.DisplayName = displayName ?? "";
|
||||
Write(usersPath, users);
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLastLogin(string email)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var user = users.FirstOrDefault(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (user is null) return;
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
Write(usersPath, users);
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveVerificationCode(VerificationCode code)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
codes.RemoveAll(x => x.Email.Equals(code.Email, StringComparison.OrdinalIgnoreCase) &&
|
||||
x.Purpose.Equals(code.Purpose, StringComparison.OrdinalIgnoreCase));
|
||||
codes.Add(code);
|
||||
Write(verificationPath, codes);
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
public VerificationCode? FindVerificationCode(string email, string purpose)
|
||||
{
|
||||
var key = (email ?? "").Trim();
|
||||
lock (gate)
|
||||
{
|
||||
return codes.FirstOrDefault(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||
x.Purpose.Equals(purpose, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public int IncrementVerificationAttempts(string email, string purpose)
|
||||
{
|
||||
var key = (email ?? "").Trim();
|
||||
lock (gate)
|
||||
{
|
||||
var code = codes.FirstOrDefault(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||
x.Purpose.Equals(purpose, StringComparison.OrdinalIgnoreCase));
|
||||
if (code is null) return 0;
|
||||
code.Attempts++;
|
||||
Write(verificationPath, codes);
|
||||
version++;
|
||||
return code.Attempts;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveVerificationCode(string email, string purpose)
|
||||
{
|
||||
var key = (email ?? "").Trim();
|
||||
lock (gate)
|
||||
{
|
||||
var removed = codes.RemoveAll(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||
x.Purpose.Equals(purpose, StringComparison.OrdinalIgnoreCase));
|
||||
if (removed > 0) { Write(verificationPath, codes); version++; }
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordAuthEvent(AuthEvent entry)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
authEvents.Add(entry);
|
||||
// 超量就按时间淘汰(保留最新 AuditKeep 条)
|
||||
if (authEvents.Count > Math.Max(100, AuditKeep))
|
||||
{
|
||||
authEvents = [.. authEvents.OrderByDescending(x => x.At).Take(Math.Max(100, AuditKeep))];
|
||||
}
|
||||
Write(authEventsPath, authEvents);
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AuthEvent> ListAuthEvents(string? email, string? ip, string? reason, int limit)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
IEnumerable<AuthEvent> q = authEvents;
|
||||
if (!string.IsNullOrWhiteSpace(email)) q = q.Where(x => x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(ip)) q = q.Where(x => x.Ip.Equals(ip.Trim(), StringComparison.Ordinal));
|
||||
if (!string.IsNullOrWhiteSpace(reason)) q = q.Where(x => x.Reason.Equals(reason.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
return [.. q.OrderByDescending(x => x.At).Take(Math.Clamp(limit <= 0 ? 200 : limit, 1, 5000))];
|
||||
}
|
||||
}
|
||||
|
||||
public int CountAuthEvents(string? email, string? ip, string? reason, bool? success, int minutes)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddMinutes(-Math.Max(1, minutes));
|
||||
lock (gate)
|
||||
{
|
||||
IEnumerable<AuthEvent> q = authEvents.Where(x => x.At >= since);
|
||||
if (!string.IsNullOrWhiteSpace(email)) q = q.Where(x => x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(ip)) q = q.Where(x => x.Ip.Equals(ip.Trim(), StringComparison.Ordinal));
|
||||
if (!string.IsNullOrWhiteSpace(reason)) q = q.Where(x => x.Reason.Equals(reason.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (success.HasValue) q = q.Where(x => x.Success == success.Value);
|
||||
return q.Count();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 原始报文与附件
|
||||
|
||||
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) => ReadInside(rawDirectory, relativePath);
|
||||
|
||||
public string SaveAttachment(byte[] data, string suggestedName)
|
||||
{
|
||||
var extension = Path.GetExtension(suggestedName ?? "");
|
||||
if (extension.Length > 16) extension = "";
|
||||
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension}";
|
||||
var relative = Path.Combine("attachments", name);
|
||||
File.WriteAllBytes(Path.Combine(config.DataDirectory, relative), data);
|
||||
return relative;
|
||||
}
|
||||
|
||||
public byte[] ReadAttachment(string relativePath) => ReadInside(attachmentDirectory, relativePath);
|
||||
|
||||
private byte[] ReadInside(string allowedDirectory, string relativePath)
|
||||
{
|
||||
var full = Path.GetFullPath(Path.Combine(config.DataDirectory, relativePath ?? ""));
|
||||
var root = Path.GetFullPath(allowedDirectory);
|
||||
if (!full.StartsWith(root, 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);
|
||||
message.Size = raw.Length;
|
||||
}
|
||||
lock (gate)
|
||||
{
|
||||
if (message.Uid == 0) message.Uid = NextUidLocked(message.OwnerEmail, message.Folder);
|
||||
messages.Add(message);
|
||||
Write(messagesPath, messages);
|
||||
version++;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>同一账号 + 同一文件夹内的下一个 IMAP UID。</summary>
|
||||
private int NextUidLocked(string owner, string folder) =>
|
||||
messages.Where(m => m.OwnerEmail.Equals(owner ?? "", StringComparison.OrdinalIgnoreCase)
|
||||
&& m.Folder.Equals(folder ?? "", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(m => m.Uid).DefaultIfEmpty(0).Max() + 1;
|
||||
|
||||
// ---------------------------------------------------------------- IMAP 支持
|
||||
|
||||
/// <summary>IMAP 用的文件夹列表(固定集合,未使用也返回,便于客户端订阅)。</summary>
|
||||
public static readonly string[] ImapFolders = MailFolders.ImapFolders;
|
||||
|
||||
public static string? NormalizeFolder(string name) => MailFolders.Normalize(name);
|
||||
|
||||
/// <summary>按 UID 升序返回某文件夹的邮件(IMAP 要求的稳定顺序)。</summary>
|
||||
public IReadOnlyList<MailMessage> ListForImap(string owner, string folder)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return messages
|
||||
.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(x => x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(x => x.Uid)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public MailMessage? GetByUid(string owner, string folder, int uid) =>
|
||||
ListForImap(owner, folder).FirstOrDefault(x => x.Uid == uid);
|
||||
|
||||
/// <summary>IMAP STORE:设置 \Seen / \Flagged。</summary>
|
||||
public bool StoreFlags(string owner, string id, bool? seen, bool? flagged)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var item = GetMessage(owner, id);
|
||||
if (item is null) return false;
|
||||
if (seen.HasValue) item.Unread = !seen.Value;
|
||||
if (flagged.HasValue) item.Starred = flagged.Value;
|
||||
Write(messagesPath, messages);
|
||||
version++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>IMAP EXPUNGE:inbox 等移入垃圾箱;已在垃圾箱则彻底删除。</summary>
|
||||
public bool Expunge(string owner, string id)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var item = GetMessage(owner, id);
|
||||
if (item is null) return false;
|
||||
if (item.Folder.Equals("trash", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
messages.Remove(item);
|
||||
TryDelete(Path.Combine(config.DataDirectory, item.RawPath));
|
||||
foreach (var attachment in item.Attachments) TryDelete(Path.Combine(config.DataDirectory, attachment.StoredAs));
|
||||
}
|
||||
else
|
||||
{
|
||||
item.Folder = "trash";
|
||||
}
|
||||
Write(messagesPath, messages);
|
||||
version++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>IMAP APPEND:把客户端上传的报文存入指定文件夹。</summary>
|
||||
public MailMessage? Append(string owner, string folder, byte[] raw, bool seen)
|
||||
{
|
||||
var parsed = Mime.Parse(raw);
|
||||
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 = SaveAttachment(attachment.Data, attachment.FileName),
|
||||
ContentId = attachment.ContentId,
|
||||
Inline = attachment.Inline,
|
||||
});
|
||||
}
|
||||
|
||||
return SaveMessage(new MailMessage
|
||||
{
|
||||
OwnerEmail = owner,
|
||||
Folder = folder,
|
||||
From = parsed.From,
|
||||
To = parsed.To,
|
||||
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,
|
||||
Unread = !seen,
|
||||
DeliveryStatus = folder == "sent" ? "sent" : "received",
|
||||
Attachments = attachments,
|
||||
}, raw);
|
||||
}
|
||||
|
||||
public int CountUnseen(string owner, string folder) =>
|
||||
ListForImap(owner, folder).Count(x => x.Unread);
|
||||
|
||||
public int NextUidFor(string owner, string folder) { lock (gate) return NextUidLocked(owner, folder); }
|
||||
|
||||
/// <summary>把外发报文直接投递给本地收件人(同域发信不必绕 SMTP)。</summary>
|
||||
public MailMessage? DeliverLocal(string recipient, byte[] raw, string sender)
|
||||
{
|
||||
// 必须用 FindUserAnyState:未激活的信箱(注册后待验证)也要能收信,
|
||||
// 否则验证码邮件会被静默丢弃。SqliteStore 用的是同一套语义,两个后端契约必须一致。
|
||||
// —— 这条不一致就是自检里「验证码邮件真的投进了未激活信箱」抓出来的。
|
||||
var user = FindUserAnyState(recipient);
|
||||
if (user is null) return null;
|
||||
|
||||
var parsed = Mime.Parse(raw);
|
||||
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 = SaveAttachment(attachment.Data, attachment.FileName),
|
||||
ContentId = attachment.ContentId,
|
||||
Inline = attachment.Inline,
|
||||
});
|
||||
}
|
||||
|
||||
return SaveMessage(new MailMessage
|
||||
{
|
||||
OwnerEmail = user.Email.ToLowerInvariant(),
|
||||
Folder = "inbox",
|
||||
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 = "received",
|
||||
Unread = true,
|
||||
Attachments = attachments,
|
||||
}, raw);
|
||||
}
|
||||
|
||||
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 => folder.Length == 0 || x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(x => query.Length == 0 || $"{x.From} {x.To} {x.Cc} {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));
|
||||
|
||||
/// <summary>分页查询。文件实现只能先全表过滤再切片(这正是它随邮件量变慢的原因)。</summary>
|
||||
public (int Total, IReadOnlyList<MailMessage> Messages) ListMessagesPage(
|
||||
string owner, string folder, string query, bool unreadOnly, bool starredOnly, int limit, int offset)
|
||||
{
|
||||
query = (query ?? "").Trim();
|
||||
lock (gate)
|
||||
{
|
||||
var filtered = messages
|
||||
.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(x => folder.Length == 0 || x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(x => !unreadOnly || x.Unread)
|
||||
.Where(x => !starredOnly || x.Starred)
|
||||
.Where(x => query.Length == 0 || $"{x.From} {x.To} {x.Cc} {x.Subject} {x.Text}".Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(x => x.Date)
|
||||
.ToArray();
|
||||
return (filtered.Length,
|
||||
filtered.Skip(Math.Max(0, offset)).Take(Math.Max(1, limit)).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public MailMessage? GetById(string id) =>
|
||||
messages.FirstOrDefault(x => x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>迁移用:读出全部邮件与队列(返回的是引用,可就地修改后调用 Persist)。</summary>
|
||||
public IReadOnlyList<MailMessage> AllMessages() { lock (gate) return messages.ToArray(); }
|
||||
|
||||
public IReadOnlyList<MailUser> AllUsers() { lock (gate) return users.ToArray(); }
|
||||
|
||||
public void Persist() { lock (gate) { Write(messagesPath, messages); Write(queuePath, queue); Write(usersPath, users); Write(verificationPath, codes); Write(authEventsPath, authEvents); version++; } }
|
||||
|
||||
public bool MarkRead(string owner, string id, bool read)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var item = GetMessage(owner, id);
|
||||
if (item is null) return false;
|
||||
item.Unread = !read;
|
||||
Write(messagesPath, messages);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetStar(string owner, string id, bool starred)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var item = GetMessage(owner, id);
|
||||
if (item is null) return false;
|
||||
item.Starred = starred;
|
||||
Write(messagesPath, messages);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool MoveMessage(string owner, string id, string folder)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var item = GetMessage(owner, id);
|
||||
if (item is null) return false;
|
||||
item.Folder = folder;
|
||||
Write(messagesPath, messages);
|
||||
version++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>删除邮件。permanent=false 时只移到垃圾箱。</summary>
|
||||
public bool DeleteMessage(string owner, string id, bool permanent)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var item = GetMessage(owner, id);
|
||||
if (item is null) return false;
|
||||
if (permanent)
|
||||
{
|
||||
messages.Remove(item);
|
||||
TryDelete(Path.Combine(config.DataDirectory, item.RawPath));
|
||||
foreach (var attachment in item.Attachments) TryDelete(Path.Combine(config.DataDirectory, attachment.StoredAs));
|
||||
}
|
||||
else
|
||||
{
|
||||
item.Folder = "trash";
|
||||
}
|
||||
Write(messagesPath, messages);
|
||||
version++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||||
}
|
||||
|
||||
public MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text,
|
||||
byte[] raw, string cc = "", string html = "", string inReplyTo = "",
|
||||
IReadOnlyList<Attachment>? attachments = null)
|
||||
{
|
||||
var message = new MailMessage
|
||||
{
|
||||
OwnerEmail = owner,
|
||||
Folder = "sent",
|
||||
From = owner,
|
||||
To = string.Join(", ", recipients),
|
||||
Cc = cc ?? "",
|
||||
Subject = subject,
|
||||
Text = text,
|
||||
Html = html ?? "",
|
||||
InReplyTo = inReplyTo ?? "",
|
||||
DeliveryStatus = "queued",
|
||||
Unread = false,
|
||||
Attachments = attachments?.ToList() ?? [],
|
||||
Size = raw.Length,
|
||||
};
|
||||
message.RawPath = SaveRaw(raw);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (message.Uid == 0) message.Uid = NextUidLocked(owner, "sent");
|
||||
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);
|
||||
version++;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>生成一封本地退信(投递彻底失败时发给发件人自己)。</summary>
|
||||
public MailMessage CreateBounce(string owner, string originalSubject, string[] recipients, string error, string originalRawPath)
|
||||
{
|
||||
var subject = $"退信:无法投递到 {string.Join(", ", recipients)}";
|
||||
var text = $"""
|
||||
你的邮件未能投递成功。
|
||||
|
||||
原始主题:{originalSubject}
|
||||
收件人 :{string.Join(", ", recipients)}
|
||||
失败原因:{error}
|
||||
时间 :{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}
|
||||
|
||||
这封退信由 {config.Hostname} 自动生成。原邮件仍保留在「已发送」中,可稍后重试。
|
||||
""";
|
||||
var raw = Mime.Build(new ComposeRequest(
|
||||
config.AdminEmail, "邮件系统", [owner], [], subject, text), config);
|
||||
|
||||
var message = new MailMessage
|
||||
{
|
||||
OwnerEmail = owner,
|
||||
Folder = "inbox",
|
||||
From = $"{config.Hostname} <{config.AdminEmail}>",
|
||||
To = owner,
|
||||
Subject = subject,
|
||||
Text = text,
|
||||
DeliveryStatus = "received",
|
||||
Unread = true,
|
||||
};
|
||||
message.RawPath = SaveRaw(raw);
|
||||
message.Size = raw.Length;
|
||||
lock (gate)
|
||||
{
|
||||
if (message.Uid == 0) message.Uid = NextUidLocked(owner, "inbox");
|
||||
messages.Add(message);
|
||||
Write(messagesPath, messages);
|
||||
version++;
|
||||
}
|
||||
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)
|
||||
.OrderBy(x => x.NextAttempt)
|
||||
.Take(limit)
|
||||
.ToArray();
|
||||
foreach (var item in due) item.Status = "processing";
|
||||
if (due.Length > 0) Write(queuePath, queue);
|
||||
return due;
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteQueue(QueueItem item)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
item.Status = "sent";
|
||||
item.LastAttemptAt = DateTimeOffset.UtcNow;
|
||||
item.LastError = "";
|
||||
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";
|
||||
if (!remaining) message.LastError = "";
|
||||
}
|
||||
Write(queuePath, queue);
|
||||
Write(messagesPath, messages);
|
||||
}
|
||||
}
|
||||
|
||||
public void FailQueue(QueueItem item, Exception error, RetryConfig retry, out bool gaveUp)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
item.Attempts++;
|
||||
item.LastAttemptAt = DateTimeOffset.UtcNow;
|
||||
item.LastError = error.Message;
|
||||
item.LastCode = error is SmtpDeliveryException smtp ? smtp.Code : 0;
|
||||
|
||||
var permanent = error is SmtpDeliveryException { Permanent: true };
|
||||
var maxAttempts = permanent && retry.RetryOnPermanentFailure
|
||||
? Math.Max(1, retry.MaxAttemptsForPermanent)
|
||||
: Math.Max(1, retry.MaxAttempts);
|
||||
|
||||
gaveUp = item.Attempts >= maxAttempts;
|
||||
item.Status = gaveUp ? "failed" : "retry";
|
||||
|
||||
// 指数退避,带上限
|
||||
var delaySeconds = Math.Min(retry.MaxDelaySeconds, retry.InitialDelaySeconds * Math.Pow(2, item.Attempts - 1));
|
||||
item.NextAttempt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
|
||||
|
||||
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 = gaveUp && !remaining ? "failed" : "queued";
|
||||
message.LastError = error.Message;
|
||||
}
|
||||
|
||||
Write(queuePath, queue);
|
||||
Write(messagesPath, messages);
|
||||
}
|
||||
}
|
||||
|
||||
public void RetryQueueItem(string owner, string queueId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var item = queue.FirstOrDefault(x => x.Id.Equals(queueId, StringComparison.OrdinalIgnoreCase) && x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase));
|
||||
if (item is null) return;
|
||||
item.Status = "pending";
|
||||
item.Attempts = 0;
|
||||
item.NextAttempt = DateTimeOffset.UtcNow;
|
||||
item.LastError = "";
|
||||
Write(queuePath, queue);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<QueueItem> ListQueue(string owner) =>
|
||||
queue.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(x => x.CreatedAt).ToArray();
|
||||
|
||||
public object Stats(string owner)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var mine = messages.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
return new
|
||||
{
|
||||
inbox = mine.Count(x => x.Folder == "inbox"),
|
||||
unread = mine.Count(x => x.Folder == "inbox" && x.Unread),
|
||||
starred = mine.Count(x => x.Starred && x.Folder != "trash"),
|
||||
drafts = mine.Count(x => x.Folder == "drafts"),
|
||||
sent = mine.Count(x => x.Folder == "sent"),
|
||||
trash = mine.Count(x => x.Folder == "trash"),
|
||||
queue = queue.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Status is "pending" or "retry" or "processing"),
|
||||
failed = mine.Count(x => x.DeliveryStatus == "failed"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 密码
|
||||
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
return CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(hash), Convert.FromBase64String(HashPassword(password, salt)));
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>纯文件实现没有需要释放的资源(保留以满足统一接口)。</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 邮件存储的统一接口。
|
||||
///
|
||||
/// 之所以要抽出接口:v2.0.x 只有 <see cref="FileStore"/> 一种实现,任何一次改动
|
||||
/// (哪怕只是把一封邮件标记为已读)都要把**全部邮件**重新序列化并整文件重写,
|
||||
/// 且 UID 分配、搜索、统计全是 O(N) 全表扫描。引入 <see cref="SqliteStore"/> 后
|
||||
/// 上层(API / IMAP / SMTP / 投递队列)不需要知道底下是 JSON 还是 SQLite。
|
||||
/// </summary>
|
||||
public interface IMailStore : IDisposable
|
||||
{
|
||||
/// <summary>每次写入自增,供长轮询判断「有没有新变化」。</summary>
|
||||
long Version { get; }
|
||||
|
||||
// ---------------------------------------------------------------- 用户
|
||||
MailUser? FindUser(string email);
|
||||
/// <summary>
|
||||
/// 不看过滤 active 的查号。用途:① 投递(信箱先存在、访问才受控);
|
||||
/// ② 登录时区分「密码错」与「账号未激活/已停用」(真实原因只进审计)。
|
||||
/// </summary>
|
||||
MailUser? FindUserAnyState(string email);
|
||||
MailUser? Authenticate(string email, string password);
|
||||
bool IsLocalAddress(string email);
|
||||
IReadOnlyList<MailUser> ListUsers();
|
||||
MailUser CreateUser(string email, string password, string displayName);
|
||||
/// <summary>用已经算好的哈希建号(注册验证通过时用,避免明文密码再走一遍内存)。</summary>
|
||||
MailUser CreateUserWithHash(string email, string passwordHash, string passwordSalt, string displayName, bool active = true);
|
||||
void ChangePassword(string email, string password);
|
||||
void SetUserActive(string email, bool active);
|
||||
/// <summary>
|
||||
/// 彻底删除一个账号:用户行 + 它的会话 + 验证码 + 它名下的出站队列。
|
||||
/// **邮件不在这里删** —— 调用方要先 ListMessages + DeleteMessage(permanent:true) 逐封删,
|
||||
/// 那样才会顺带回收无引用的大对象。审计保留(删除动作本身也会写一条审计)。
|
||||
/// </summary>
|
||||
bool DeleteUser(string email);
|
||||
|
||||
// ---------------------------------------------------------------- 会话
|
||||
SessionRecord CreateSession(string email, int days);
|
||||
SessionRecord? GetSession(string? token);
|
||||
void RemoveSession(string token);
|
||||
/// <summary>列出某个账号的全部活跃会话(用于「在哪登录了 / 退出其他设备」)。</summary>
|
||||
IReadOnlyList<SessionRecord> ListSessions(string email);
|
||||
/// <summary>吊销该账号的会话;keepToken 非空时保留它(即「退出其他设备」)。返回吊销数量。</summary>
|
||||
int RemoveSessions(string email, string? keepToken);
|
||||
|
||||
// ---------------------------------------------------------------- 账号体系(注册 / 验证码 / 审计)
|
||||
/// <summary>把用户资料写回存储(目前只有显示名)。</summary>
|
||||
void UpdateProfile(string email, string displayName);
|
||||
/// <summary>记录一次成功登录时间。</summary>
|
||||
void SetLastLogin(string email);
|
||||
|
||||
/// <summary>保存验证码(同一 email+purpose 覆盖旧的)。只存哈希。</summary>
|
||||
void SaveVerificationCode(VerificationCode code);
|
||||
VerificationCode? FindVerificationCode(string email, string purpose);
|
||||
/// <summary>验证码试错次数 +1,返回自增后的次数。</summary>
|
||||
int IncrementVerificationAttempts(string email, string purpose);
|
||||
void RemoveVerificationCode(string email, string purpose);
|
||||
|
||||
/// <summary>写一条认证审计。</summary>
|
||||
void RecordAuthEvent(AuthEvent entry);
|
||||
/// <summary>按条件查审计(都为 null 表示不限制);limit 为 0 时用实现自己的默认上限。</summary>
|
||||
IReadOnlyList<AuthEvent> ListAuthEvents(string? email, string? ip, string? reason, int limit);
|
||||
/// <summary>统计窗口期内的认证事件数量(登录锁定、注册与重发限流都用它)。</summary>
|
||||
int CountAuthEvents(string? email, string? ip, string? reason, bool? success, int minutes);
|
||||
|
||||
// ---------------------------------------------------------------- 原始报文与附件
|
||||
string SaveRaw(byte[] raw);
|
||||
byte[] ReadRaw(string relativePath);
|
||||
string SaveAttachment(byte[] data, string suggestedName);
|
||||
byte[] ReadAttachment(string relativePath);
|
||||
|
||||
// ---------------------------------------------------------------- 邮件
|
||||
MailMessage SaveMessage(MailMessage message, byte[]? raw = null);
|
||||
MailMessage? DeliverLocal(string recipient, byte[] raw, string sender);
|
||||
MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text,
|
||||
byte[] raw, string cc = "", string html = "", string inReplyTo = "",
|
||||
IReadOnlyList<Attachment>? attachments = null);
|
||||
MailMessage CreateBounce(string owner, string originalSubject, string[] recipients, string error, string originalRawPath);
|
||||
IReadOnlyList<MailMessage> ListMessages(string owner, string folder, string query);
|
||||
/// <summary>分页查询:只取需要的一页(SQLite 直接下推到 SQL,不再把整个邮箱读进内存)。</summary>
|
||||
(int Total, IReadOnlyList<MailMessage> Messages) ListMessagesPage(
|
||||
string owner, string folder, string query, bool unreadOnly, bool starredOnly, int limit, int offset);
|
||||
MailMessage? GetMessage(string owner, string id);
|
||||
MailMessage? GetById(string id);
|
||||
bool MarkRead(string owner, string id, bool read);
|
||||
bool SetStar(string owner, string id, bool starred);
|
||||
bool MoveMessage(string owner, string id, string folder);
|
||||
bool DeleteMessage(string owner, string id, bool permanent);
|
||||
object Stats(string owner);
|
||||
|
||||
// ---------------------------------------------------------------- IMAP
|
||||
IReadOnlyList<MailMessage> ListForImap(string owner, string folder);
|
||||
MailMessage? GetByUid(string owner, string folder, int uid);
|
||||
bool StoreFlags(string owner, string id, bool? seen, bool? flagged);
|
||||
bool Expunge(string owner, string id);
|
||||
MailMessage? Append(string owner, string folder, byte[] raw, bool seen);
|
||||
int CountUnseen(string owner, string folder);
|
||||
int NextUidFor(string owner, string folder);
|
||||
|
||||
// ---------------------------------------------------------------- 出站队列
|
||||
IReadOnlyList<QueueItem> TakeDueQueue(int limit);
|
||||
void CompleteQueue(QueueItem item);
|
||||
void FailQueue(QueueItem item, Exception error, RetryConfig retry, out bool gaveUp);
|
||||
void RetryQueueItem(string owner, string queueId);
|
||||
IReadOnlyList<QueueItem> ListQueue(string owner);
|
||||
|
||||
// ---------------------------------------------------------------- 维护
|
||||
IReadOnlyList<MailMessage> AllMessages();
|
||||
IReadOnlyList<MailUser> AllUsers();
|
||||
/// <summary>把内存中的改动落盘。JSON 实现会整文件重写;SQLite 实现是空操作(写入即提交)。</summary>
|
||||
void Persist();
|
||||
}
|
||||
|
||||
/// <summary>存储占用统计。</summary>
|
||||
public sealed class StoreUsage
|
||||
{
|
||||
public string Provider { get; set; } = "";
|
||||
public string Database { get; set; } = "";
|
||||
public long DbBytes { get; set; }
|
||||
public long WalBytes { get; set; }
|
||||
public long Blobs { get; set; }
|
||||
public long BlobRawBytes { get; set; }
|
||||
public long BlobStoredBytes { get; set; }
|
||||
public long BlobGzipped { get; set; }
|
||||
public long Messages { get; set; }
|
||||
public long Orphans { get; set; }
|
||||
/// <summary>正文文本占用的字节数(text_body + html_body),用于判断「重复存储」的成本。</summary>
|
||||
public long TextBytes { get; set; }
|
||||
/// <summary>数据库空闲页字节数(未 VACUUM 时会被计入文件大小)。</summary>
|
||||
public long FreeBytes { get; set; }
|
||||
/// <summary>数据库实际使用的页字节数(page_count × page_size)。</summary>
|
||||
public long TotalBytes { get; set; }
|
||||
/// <summary>文件高水位(含已回收但未归还操作系统的空间)。</summary>
|
||||
public long PageCount { get; set; }
|
||||
public long PageSize { get; set; }
|
||||
/// <summary>最大一封邮件的正文长度与其主题(排查「空间被谁吃了」)。</summary>
|
||||
public long LargestTextBytes { get; set; }
|
||||
public string LargestTextSubject { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>文件夹常量与名称归一化(IMAP 与 API 共用)。</summary>
|
||||
public static class MailFolders
|
||||
{
|
||||
/// <summary>IMAP 用的文件夹列表(固定集合,未使用也返回,便于客户端订阅)。</summary>
|
||||
public static readonly string[] ImapFolders = ["inbox", "sent", "drafts", "archive", "trash", "spam"];
|
||||
|
||||
/// <summary>把客户端给的各种写法(含中文别名)归一化成内部文件夹名;无法识别返回 null。</summary>
|
||||
public static string? Normalize(string name)
|
||||
{
|
||||
var key = (name ?? "").Trim().Trim('"').ToLowerInvariant();
|
||||
if (key is "inbox" or "收件箱") return "inbox";
|
||||
if (key is "sent" or "sent items" or "sent messages" or "已发送") return "sent";
|
||||
if (key is "drafts" or "草稿") return "drafts";
|
||||
if (key is "archive" or "archives" or "归档") return "archive";
|
||||
if (key is "trash" or "deleted" or "deleted items" or "已删除" or "垃圾箱") return "trash";
|
||||
if (key is "junk" or "spam" or "垃圾邮件") return "spam";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 入站邮件身份校验:SPF(RFC 7208)、DKIM 验签(RFC 6376)、DMARC(RFC 7489)。
|
||||
///
|
||||
/// 为什么要有这一层:在此之前谁都能用 `From: [email protected]` 给这台服务器发信,
|
||||
/// 服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有任何区别。
|
||||
///
|
||||
/// 设计取舍:
|
||||
/// 1. **默认只标注不拒收**(`RejectOnDmarcReject=false`):校验实现自己也可能有 bug,
|
||||
/// 拒收是不可逆的,投进垃圾箱是可逆的。DMARC 判失败时按策略投 spam。
|
||||
/// 2. **DNS 查询做成可注入的**(<see cref="IDnsLookup"/>):SPF/DKIM 的判定逻辑必须能
|
||||
/// 用固定记录做确定性自检,否则自检依赖外网、结果不可复现。
|
||||
/// 3. DNS 查询次数按 RFC 限制(SPF 10 次、void 2 次),避免成为放大攻击的靶子。
|
||||
/// </summary>
|
||||
public sealed record InboundAuthVerdict
|
||||
{
|
||||
public string Spf { get; init; } = "none";
|
||||
public string SpfDomain { get; init; } = "";
|
||||
public string SpfDetail { get; init; } = "";
|
||||
public string Dkim { get; init; } = "none";
|
||||
public string DkimDomain { get; init; } = "";
|
||||
public string DkimDetail { get; init; } = "";
|
||||
public string Dmarc { get; init; } = "none";
|
||||
public string DmarcDomain { get; init; } = "";
|
||||
public string DmarcPolicy { get; init; } = "none";
|
||||
public int Score { get; init; }
|
||||
public string[] Reasons { get; init; } = [];
|
||||
public bool Spam { get; init; }
|
||||
public bool Reject { get; init; }
|
||||
/// <summary>可直接前置到报文里的 <c>Authentication-Results</c> 行(含 CRLF)。</summary>
|
||||
public string HeaderBlock { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>DNS 查询抽象:真实实现走 UDP/系统解析器,自检用固定记录的实现。</summary>
|
||||
public interface IDnsLookup
|
||||
{
|
||||
Task<IReadOnlyList<string>> TxtAsync(string name, CancellationToken token);
|
||||
Task<IReadOnlyList<string>> AddressesAsync(string name, CancellationToken token);
|
||||
Task<IReadOnlyList<string>> MxAsync(string name, CancellationToken token);
|
||||
}
|
||||
|
||||
/// <summary>真实 DNS:TXT 自己发 UDP 查询(系统解析器拿不到 TXT),A 用系统解析,MX 复用既有的 MxResolver。</summary>
|
||||
public sealed class UdpDnsLookup : IDnsLookup
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
public UdpDnsLookup(AppConfig config) => this.config = config;
|
||||
|
||||
public async Task<IReadOnlyList<string>> TxtAsync(string name, CancellationToken token)
|
||||
{
|
||||
var timeout = Math.Max(1, config.InboundAuth.DnsTimeoutSeconds);
|
||||
var target = name;
|
||||
// 跟着 CNAME 走:outlook.com 之类的 DKIM 公钥就是发布成 CNAME 的,
|
||||
// 自己发的原始 UDP 查询不会自动跟(系统解析器才会),不跟就永远取不到公钥。
|
||||
for (var depth = 0; depth < 5; depth++)
|
||||
{
|
||||
var moved = false;
|
||||
foreach (var server in DnsServers())
|
||||
{
|
||||
try
|
||||
{
|
||||
var (records, aliases) = await QueryAsync(target, server, timeout, token);
|
||||
if (records.Count > 0) return records;
|
||||
if (aliases.Count > 0)
|
||||
{
|
||||
target = aliases[0];
|
||||
moved = true;
|
||||
AppLog.Info($"[DNS] {name} 是 CNAME,继续查 {target}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException or OperationCanceledException)
|
||||
{
|
||||
AppLog.Warn($"[DNS] 查询 {target} 的 TXT 失败({server}):{ex.Message}");
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> AddressesAsync(string name, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(name, token);
|
||||
return addresses.Select(a => a.ToString()).ToArray();
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> MxAsync(string name, CancellationToken token)
|
||||
{
|
||||
try { return await MxResolver.ResolveAsync(name, config.DirectDelivery, token); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private IEnumerable<IPAddress> DnsServers()
|
||||
{
|
||||
var configured = (config.DirectDelivery.DnsServer ?? "").Trim();
|
||||
if (configured.Length > 0)
|
||||
{
|
||||
foreach (var part in configured.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries))
|
||||
if (IPAddress.TryParse(part.Trim(), out var ip)) yield return ip;
|
||||
yield break;
|
||||
}
|
||||
var system = new List<IPAddress>();
|
||||
try
|
||||
{
|
||||
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
|
||||
foreach (var dns in ni.GetIPProperties().DnsAddresses)
|
||||
if (dns.AddressFamily == AddressFamily.InterNetwork && !system.Contains(dns)) system.Add(dns);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (system.Count == 0) system.Add(IPAddress.Parse("1.1.1.1"));
|
||||
foreach (var ip in system) yield return ip;
|
||||
}
|
||||
|
||||
private static async Task<(IReadOnlyList<string> Txt, IReadOnlyList<string> Cname)> QueryAsync(string name, IPAddress server, int timeoutSeconds, CancellationToken token)
|
||||
{
|
||||
using var udp = new UdpClient(server.AddressFamily);
|
||||
var query = BuildTxtQuery(name, out var id);
|
||||
await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53));
|
||||
var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(timeoutSeconds), token);
|
||||
return ParseTxtResponse(result.Buffer, id);
|
||||
}
|
||||
|
||||
private static byte[] BuildTxtQuery(string name, 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 name.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)16)); // TXT
|
||||
writer.Write(ToNetwork((ushort)1));
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<string> Txt, IReadOnlyList<string> Cname) ParseTxtResponse(byte[] data, ushort expectedId)
|
||||
{
|
||||
var records = new List<string>();
|
||||
var aliases = new List<string>();
|
||||
if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return (records, aliases);
|
||||
var flags = ReadUInt16(data, 2);
|
||||
if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return (records, aliases);
|
||||
var questions = ReadUInt16(data, 4);
|
||||
var answers = ReadUInt16(data, 6);
|
||||
var offset = 12;
|
||||
for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; }
|
||||
for (var i = 0; i < answers && offset + 10 <= data.Length; i++)
|
||||
{
|
||||
ReadName(data, ref offset);
|
||||
if (offset + 10 > data.Length) break;
|
||||
var type = ReadUInt16(data, offset);
|
||||
var length = ReadUInt16(data, offset + 8);
|
||||
offset += 10;
|
||||
if (offset + length > data.Length) break;
|
||||
if (type == 5)
|
||||
{
|
||||
var cursor = offset;
|
||||
var alias = ReadName(data, ref cursor);
|
||||
if (alias.Length > 0) aliases.Add(alias.TrimEnd('.'));
|
||||
}
|
||||
else if (type == 16)
|
||||
{
|
||||
// TXT 的 RDATA 是若干「长度 + 字节」的片段,要拼起来(DKIM 公钥常被切成多段)
|
||||
var text = new StringBuilder();
|
||||
var cursor = offset;
|
||||
var end = offset + length;
|
||||
while (cursor < end)
|
||||
{
|
||||
var piece = data[cursor];
|
||||
cursor++;
|
||||
if (cursor + piece > end) break;
|
||||
text.Append(Encoding.UTF8.GetString(data, cursor, piece));
|
||||
cursor += piece;
|
||||
}
|
||||
if (text.Length > 0) records.Add(text.ToString());
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return (records, aliases);
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]);
|
||||
private static byte[] ToNetwork(ushort value) => [(byte)(value >> 8), (byte)(value & 0xFF)];
|
||||
|
||||
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) break;
|
||||
var pointer = ((length & 0x3F) << 8) | data[cursor++];
|
||||
if (!jumped) next = cursor; // ⚠ 必须记住「指针之后」的位置:
|
||||
cursor = pointer; // 否则跟着指针跳进 question 段后,
|
||||
jumped = true; // 后面读 type/class/length 就全错位了(TXT 一条都解析不出来)
|
||||
continue;
|
||||
}
|
||||
if (length > 63 || cursor + length > data.Length) break;
|
||||
labels.Add(Encoding.ASCII.GetString(data, cursor, length));
|
||||
cursor += length;
|
||||
}
|
||||
offset = next;
|
||||
return string.Join('.', labels);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SPF 求值(RFC 7208 的常用子集:all/include/a/mx/ip4/ip6/exists + 限定符 + redirect)。</summary>
|
||||
public static class Spf
|
||||
{
|
||||
public sealed record Result(string Outcome, string Domain, string Detail);
|
||||
private const string None = "none", Pass = "pass", Fail = "fail", SoftFail = "softfail", Neutral = "neutral",
|
||||
TempError = "temperror", PermError = "permerror";
|
||||
|
||||
public static async Task<Result> EvaluateAsync(string? ip, string? helo, string? mailFrom, IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token)
|
||||
{
|
||||
var senderDomain = DomainOf(mailFrom);
|
||||
if (senderDomain.Length == 0)
|
||||
{
|
||||
// 空 MAIL FROM(退信):按 RFC 7208 §2.4 用 HELO 域名
|
||||
var heloDomain = (helo ?? "").Trim().TrimEnd('.');
|
||||
if (heloDomain.Length == 0 || !heloDomain.Contains('.')) return new Result(None, "", "无发件人域,无法判定");
|
||||
senderDomain = heloDomain;
|
||||
}
|
||||
if (!IPAddress.TryParse(ip, out var address)) return new Result(None, senderDomain, "来源地址不可解析");
|
||||
|
||||
var state = new State(dns, cfg, token);
|
||||
var outcome = await state.CheckDomainAsync(senderDomain, address, mailFrom ?? "", helo ?? "", depth: 0);
|
||||
return new Result(outcome.Outcome, senderDomain, outcome.Detail);
|
||||
}
|
||||
|
||||
private sealed class State(IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token)
|
||||
{
|
||||
private int lookups;
|
||||
private int voids;
|
||||
|
||||
public async Task<(string Outcome, string Detail)> CheckDomainAsync(string domain, IPAddress ip, string mailFrom, string helo, int depth)
|
||||
{
|
||||
if (depth > 5) return (PermError, "include/redirect 嵌套过深");
|
||||
var records = await TxtAsync(domain);
|
||||
var spf = records.Where(r => r.TrimStart().StartsWith("v=spf1", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
if (spf.Length == 0) return (None, $"{domain} 没有 SPF 记录");
|
||||
if (spf.Length > 1) return (PermError, $"{domain} 有多条 SPF 记录");
|
||||
|
||||
var terms = Tokenize(spf[0]);
|
||||
var redirect = "";
|
||||
foreach (var raw in terms)
|
||||
{
|
||||
if (raw.Length == 0) continue;
|
||||
var term = raw;
|
||||
var qualifier = '+';
|
||||
if ("+-~?".Contains(term[0])) { qualifier = term[0]; term = term[1..]; }
|
||||
if (term.StartsWith("redirect=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
redirect = term["redirect=".Length..];
|
||||
continue;
|
||||
}
|
||||
if (term.Contains('=')) continue; // 其它修饰符(exp= 等)本实现不处理
|
||||
|
||||
var (name, value) = Split(term);
|
||||
var match = false;
|
||||
switch (name.ToLowerInvariant())
|
||||
{
|
||||
case "all":
|
||||
match = true;
|
||||
break;
|
||||
case "include":
|
||||
if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次");
|
||||
{
|
||||
var included = await CheckDomainAsync(value, ip, mailFrom, helo, depth + 1);
|
||||
if (included.Outcome == Pass) return (Pass, $"include:{value} 通过");
|
||||
if (included.Outcome is TempError or PermError) return (included.Outcome, included.Detail);
|
||||
}
|
||||
break;
|
||||
case "a":
|
||||
if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次");
|
||||
match = await MatchesAddressAsync(value.Length > 0 ? value : domain, ip);
|
||||
break;
|
||||
case "mx":
|
||||
if (!await CountLookupAsync(value.Length > 0 ? value : domain)) return (PermError, "SPF 查询次数超过 10 次");
|
||||
{
|
||||
var hosts = await SafeMxAsync(value.Length > 0 ? value : domain);
|
||||
foreach (var host in hosts)
|
||||
if (await MatchesAddressAsync(host, ip)) { match = true; break; }
|
||||
}
|
||||
break;
|
||||
case "ip4":
|
||||
match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetwork);
|
||||
break;
|
||||
case "ip6":
|
||||
match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetworkV6);
|
||||
break;
|
||||
case "exists":
|
||||
if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次");
|
||||
match = (await SafeAddressesAsync(Expand(value, domain, ip, mailFrom, helo))).Count > 0;
|
||||
break;
|
||||
case "ptr":
|
||||
// RFC 7208 §5.5:ptr 机制不推荐使用,本实现直接视为不匹配
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!match) continue;
|
||||
var outcome = qualifier switch
|
||||
{
|
||||
'-' => Fail,
|
||||
'~' => SoftFail,
|
||||
'?' => Neutral,
|
||||
_ => Pass,
|
||||
};
|
||||
return (outcome, $"{raw} 命中({domain})");
|
||||
}
|
||||
|
||||
if (redirect.Length > 0)
|
||||
{
|
||||
if (!await CountLookupAsync(redirect)) return (PermError, "SPF 查询次数超过 10 次");
|
||||
return await CheckDomainAsync(redirect, ip, mailFrom, helo, depth + 1);
|
||||
}
|
||||
return (Neutral, $"{domain} 的 SPF 没有匹配项");
|
||||
}
|
||||
|
||||
private async Task<bool> CountLookupAsync(string name)
|
||||
{
|
||||
lookups++;
|
||||
if (lookups > Math.Max(1, cfg.MaxSpfLookups)) return false;
|
||||
// void lookup(查了但没记录)超过 2 次即 permerror
|
||||
return await Task.FromResult(true);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> TxtAsync(string name)
|
||||
{
|
||||
var r = await SafeTxtAsync(name);
|
||||
if (r.Count == 0 && ++voids > 2) return r;
|
||||
return r;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> SafeTxtAsync(string name)
|
||||
{
|
||||
try { return await dns.TxtAsync(name.TrimEnd('.'), token); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) { return []; }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> SafeAddressesAsync(string name)
|
||||
{
|
||||
try { return await dns.AddressesAsync(name.TrimEnd('.'), token); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> SafeMxAsync(string name)
|
||||
{
|
||||
try { return await dns.MxAsync(name.TrimEnd('.'), token); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private async Task<bool> MatchesAddressAsync(string host, IPAddress ip)
|
||||
{
|
||||
foreach (var candidate in await SafeAddressesAsync(host))
|
||||
if (IPAddress.TryParse(candidate, out var parsed) && parsed.Equals(ip)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IpMatches(IPAddress ip, string value, AddressFamily family)
|
||||
{
|
||||
if (ip.AddressFamily != family) return false;
|
||||
var parts = value.Split('/', 2);
|
||||
if (!IPAddress.TryParse(parts[0], out var network)) return false;
|
||||
var bits = parts.Length > 1 && int.TryParse(parts[1], out var parsedBits)
|
||||
? parsedBits
|
||||
: (family == AddressFamily.InterNetwork ? 32 : 128);
|
||||
var networkBytes = network.GetAddressBytes();
|
||||
var ipBytes = ip.GetAddressBytes();
|
||||
if (networkBytes.Length != ipBytes.Length) return false;
|
||||
var fullBytes = bits / 8;
|
||||
for (var i = 0; i < fullBytes; i++) if (networkBytes[i] != ipBytes[i]) return false;
|
||||
var remainder = bits % 8;
|
||||
if (remainder == 0) return true;
|
||||
var mask = (byte)(0xFF << (8 - remainder));
|
||||
return (networkBytes[fullBytes] & mask) == (ipBytes[fullBytes] & mask);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tokenize(string record)
|
||||
{
|
||||
foreach (var piece in record.Split(' ', '\t', '\r', '\n'))
|
||||
{
|
||||
var term = piece.Trim();
|
||||
if (term.Length == 0) continue;
|
||||
yield return term;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Name, string Value) Split(string term)
|
||||
{
|
||||
var colon = term.IndexOf(':');
|
||||
if (colon < 0) return (term, "");
|
||||
var name = term[..colon];
|
||||
var value = term[(colon + 1)..];
|
||||
// ⚠ 注意:**不能在这里砍掉 `/`** —— ip4:203.0.113.0/24 的 CIDR 就是值的一部分。
|
||||
// a/mx 的 `a:domain/24` 形式由调用方自己拆(本实现不支持 a/mx 的 CIDR 修饰,
|
||||
// 这属于罕见的用法,但 ip4/ip6 的 CIDR 是必须的)。
|
||||
if (name.Equals("a", StringComparison.OrdinalIgnoreCase) || name.Equals("mx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var slash = value.IndexOf('/');
|
||||
if (slash >= 0) value = value[..slash];
|
||||
}
|
||||
return (name, value);
|
||||
}
|
||||
|
||||
/// <summary>SPF 宏的常用子集(%{d} %{s} %{o} %{i} %{h})。</summary>
|
||||
public static string Expand(string value, string domain, IPAddress ip, string mailFrom, string helo)
|
||||
{
|
||||
if (!value.Contains("%{")) return value;
|
||||
var sender = mailFrom.Split('@').LastOrDefault() ?? "";
|
||||
var local = mailFrom.Split('@').FirstOrDefault() ?? "";
|
||||
return value
|
||||
.Replace("%{d}", domain, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%{s}", mailFrom, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%{l}", local, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%{o}", sender, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%{i}", ip.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%{h}", helo, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string DomainOf(string? address)
|
||||
{
|
||||
var text = (address ?? "").Trim();
|
||||
var at = text.LastIndexOf('@');
|
||||
if (at < 0) return "";
|
||||
var domain = text[(at + 1)..];
|
||||
// 可能是 `[email protected]>` 或 `[email protected] (注释)` —— 截到第一个分隔符
|
||||
var stop = domain.IndexOfAny(['>', ' ', '\t', ')', ',', ';', '"']);
|
||||
if (stop >= 0) domain = domain[..stop];
|
||||
return domain.Trim().TrimEnd('.').ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// DKIM 验签(RFC 6376)。**独立按 RFC 实现,不复用签名端代码** ——
|
||||
/// 2026-09-13 那次 DKIM 顺序 bug 的教训就是「自己验自己」会一起错。
|
||||
/// </summary>
|
||||
public static class DkimVerifier
|
||||
{
|
||||
public sealed record Result(string Outcome, string Domain, string Selector, string Detail);
|
||||
|
||||
public static async Task<IReadOnlyList<Result>> VerifyAllAsync(byte[] raw, IDnsLookup dns, CancellationToken token)
|
||||
{
|
||||
var text = Encoding.Latin1.GetString(raw); // 1 字节 ↔ 1 字符,索引即字节偏移
|
||||
var headers = ParseHeaders(text);
|
||||
var results = new List<Result>();
|
||||
foreach (var header in headers.Where(h => h.Name.Equals("DKIM-Signature", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try { results.Add(await VerifyOneAsync(text, headers, header, dns, token)); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
results.Add(new Result("temperror", "", "", ex.Message));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private sealed record Header(string Name, string Raw, string Value)
|
||||
{
|
||||
public bool Used { get; set; }
|
||||
}
|
||||
|
||||
private static async Task<Result> VerifyOneAsync(string text, List<Header> headers, Header signature, IDnsLookup dns, CancellationToken token)
|
||||
{
|
||||
var tags = ParseTags(signature.Value);
|
||||
var domain = tags.GetValueOrDefault("d", "");
|
||||
var selector = tags.GetValueOrDefault("s", "");
|
||||
if (tags.GetValueOrDefault("v", "1") != "1") return new Result("fail", domain, selector, "v= 不是 1");
|
||||
if (domain.Length == 0 || selector.Length == 0) return new Result("fail", domain, selector, "缺 d= 或 s=");
|
||||
|
||||
var algorithm = tags.GetValueOrDefault("a", "rsa-sha256").ToLowerInvariant();
|
||||
var hashName = algorithm switch
|
||||
{
|
||||
"rsa-sha256" => HashAlgorithmName.SHA256,
|
||||
"rsa-sha1" => HashAlgorithmName.SHA1,
|
||||
"ed25519-sha256" => HashAlgorithmName.SHA256,
|
||||
_ => default,
|
||||
};
|
||||
if (hashName == default) return new Result("fail", domain, selector, $"不支持的算法 a={algorithm}");
|
||||
if (algorithm.StartsWith("ed25519", StringComparison.Ordinal))
|
||||
return new Result("neutral", domain, selector, "ed25519 本实现不支持(极少见)");
|
||||
|
||||
var canon = tags.GetValueOrDefault("c", "simple/simple").ToLowerInvariant().Split('/');
|
||||
var headerCanon = canon[0] is "relaxed" ? "relaxed" : "simple";
|
||||
var bodyCanon = canon.Length > 1 && canon[1] == "relaxed" ? "relaxed" : "simple";
|
||||
|
||||
var body = ExtractBody(text);
|
||||
var canonicalBody = CanonicalizeBody(body, bodyCanon);
|
||||
if (tags.TryGetValue("l", out var lengthText) && int.TryParse(lengthText, out var limit) && limit >= 0 && limit < canonicalBody.Length)
|
||||
canonicalBody = canonicalBody[..limit];
|
||||
|
||||
var expectedBodyHash = tags.GetValueOrDefault("bh", "");
|
||||
var actualBodyHash = Convert.ToBase64String(Hash(hashName, Encoding.Latin1.GetBytes(canonicalBody)));
|
||||
if (!string.Equals(expectedBodyHash, actualBodyHash, StringComparison.Ordinal))
|
||||
return new Result("fail", domain, selector, $"正文哈希不符(bh={Short(expectedBodyHash)} 实际={Short(actualBodyHash)})");
|
||||
|
||||
// 按 h= 的先后顺序取头,重名时从**下往上**取(RFC 6376 §5.4.2)
|
||||
var signedNames = tags.GetValueOrDefault("h", "").Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (signedNames.Length == 0) return new Result("fail", domain, selector, "h= 为空");
|
||||
var builder = new StringBuilder();
|
||||
foreach (var name in signedNames)
|
||||
{
|
||||
var picked = headers.LastOrDefault(h => !h.Used && !ReferenceEquals(h, signature)
|
||||
&& h.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (picked is not null) picked.Used = true;
|
||||
builder.Append(CanonicalizeHeader(picked?.Name ?? name.Trim(), picked?.Raw ?? "", headerCanon));
|
||||
}
|
||||
builder.Append(CanonicalizeHeader(signature.Name, StripSignatureValue(signature.Raw), headerCanon, trimCrLf: true));
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(tags.GetValueOrDefault("b", "").Trim());
|
||||
var data = Encoding.Latin1.GetBytes(builder.ToString());
|
||||
|
||||
var keyText = await LookupKeyAsync(domain, selector, dns, token);
|
||||
if (keyText is null) return new Result("temperror", domain, selector, $"取不到公钥 {selector}._domainkey.{domain}");
|
||||
if (keyText.Length == 0) return new Result("fail", domain, selector, "公钥记录里 p= 为空(已吊销)");
|
||||
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(keyText), out _);
|
||||
var ok = rsa.VerifyData(data, signatureBytes, hashName, RSASignaturePadding.Pkcs1);
|
||||
return ok
|
||||
? new Result("pass", domain, selector, $"a={algorithm} c={string.Join('/', canon)}")
|
||||
: new Result("fail", domain, selector, "签名不符(报文可能被改过)");
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return new Result("fail", domain, selector, "公钥不是合法的 SPKI base64");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return new Result("fail", domain, selector, $"验签异常:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> LookupKeyAsync(string domain, string selector, IDnsLookup dns, CancellationToken token)
|
||||
{
|
||||
var name = $"{selector}._domainkey.{domain}";
|
||||
var records = await dns.TxtAsync(name, token);
|
||||
foreach (var record in records)
|
||||
{
|
||||
var tags = ParseTags(record);
|
||||
if (!tags.ContainsKey("p")) continue;
|
||||
return tags["p"];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractBody(string text)
|
||||
{
|
||||
var crlf = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
|
||||
var lf = text.IndexOf("\n\n", StringComparison.Ordinal);
|
||||
var index = crlf >= 0 && (lf < 0 || crlf <= lf) ? crlf + 4 : lf >= 0 ? lf + 2 : -1;
|
||||
return index < 0 ? "" : text[index..];
|
||||
}
|
||||
|
||||
private static List<Header> ParseHeaders(string text)
|
||||
{
|
||||
var list = new List<Header>();
|
||||
var block = text;
|
||||
var crlf = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
|
||||
var lf = text.IndexOf("\n\n", StringComparison.Ordinal);
|
||||
if (crlf >= 0 && (lf < 0 || crlf <= lf)) block = text[..crlf];
|
||||
else if (lf >= 0) block = text[..lf];
|
||||
|
||||
var lines = block.Split('\n');
|
||||
StringBuilder? current = null;
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.TrimEnd('\r');
|
||||
if (line.Length == 0) continue;
|
||||
if ((line[0] == ' ' || line[0] == '\t') && current is not null)
|
||||
{
|
||||
current.Append("\r\n").Append(line);
|
||||
continue;
|
||||
}
|
||||
if (current is not null) list.Add(Finish(current.ToString()));
|
||||
current = new StringBuilder(line);
|
||||
}
|
||||
if (current is not null) list.Add(Finish(current.ToString()));
|
||||
return list;
|
||||
|
||||
static Header Finish(string raw)
|
||||
{
|
||||
var colon = raw.IndexOf(':');
|
||||
if (colon < 0) return new Header(raw.Trim(), raw, "");
|
||||
var name = raw[..colon].Trim();
|
||||
var value = raw[(colon + 1)..].Trim();
|
||||
return new Header(name, raw, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把 b= 的值抹掉(验签输入里的 DKIM-Signature 头不能带签名本身)。
|
||||
///
|
||||
/// ⚠ 必须**按标签边界**找 b=:直接 IndexOf("b=") 会被前面的 `bh=` 的 base64 内容误伤
|
||||
/// (base64 以 `b=` 结尾完全合法),一位之差就整封验不过 —— 自检里正是这一条抓出来的。
|
||||
/// </summary>
|
||||
private static string StripSignatureValue(string raw)
|
||||
{
|
||||
var index = TagValueStart(raw, "b");
|
||||
if (index < 0) return raw;
|
||||
var end = raw.IndexOf(';', index);
|
||||
var head = raw[..index];
|
||||
var tail = end < 0 ? "" : raw[end..];
|
||||
return head + tail;
|
||||
}
|
||||
|
||||
/// <summary>返回名为 name 的标签「值」的起始下标(-1 表示没有)。标签必须出现在 `;` 之后或开头。</summary>
|
||||
private static int TagValueStart(string raw, string name)
|
||||
{
|
||||
var position = raw.IndexOf(':');
|
||||
position = position < 0 ? 0 : position + 1;
|
||||
while (position < raw.Length)
|
||||
{
|
||||
while (position < raw.Length && (raw[position] is ' ' or '\t' or '\r' or '\n' or ';')) position++;
|
||||
if (position >= raw.Length) return -1;
|
||||
var equals = raw.IndexOf('=', position);
|
||||
if (equals < 0) return -1;
|
||||
var tag = raw[position..equals].Trim();
|
||||
var semicolon = raw.IndexOf(';', equals);
|
||||
if (tag.Equals(name, StringComparison.OrdinalIgnoreCase)) return equals + 1;
|
||||
if (semicolon < 0) return -1;
|
||||
position = semicolon + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string CanonicalizeHeader(string name, string raw, string mode, bool trimCrLf = false)
|
||||
{
|
||||
string result;
|
||||
if (mode == "relaxed")
|
||||
{
|
||||
var colon = raw.IndexOf(':');
|
||||
var value = colon < 0 ? "" : raw[(colon + 1)..];
|
||||
var unfolded = value.Replace("\r\n", " ");
|
||||
var collapsed = Collapse(unfolded).TrimEnd(' ', '\t');
|
||||
result = name.ToLowerInvariant().Trim() + ":" + collapsed + "\r\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
var text = raw.Length > 0 ? raw : name + ":";
|
||||
result = text + "\r\n";
|
||||
}
|
||||
return trimCrLf ? result[..^2] : result;
|
||||
}
|
||||
|
||||
private static string CanonicalizeBody(string body, string mode)
|
||||
{
|
||||
if (body.Length == 0) return "";
|
||||
var lines = new List<string>();
|
||||
var start = 0;
|
||||
while (start <= body.Length)
|
||||
{
|
||||
var end = body.IndexOf('\n', start);
|
||||
var hasTerminator = end >= 0;
|
||||
var line = hasTerminator ? body[start..end].TrimEnd('\r') : body[start..];
|
||||
lines.Add(line);
|
||||
if (!hasTerminator) break;
|
||||
start = end + 1;
|
||||
}
|
||||
// 去掉末尾的空行(保留最后一行的行尾)
|
||||
while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1);
|
||||
if (lines.Count == 0) return "";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (mode == "relaxed")
|
||||
{
|
||||
line = Collapse(line).TrimEnd(' ', '\t');
|
||||
}
|
||||
builder.Append(line).Append("\r\n");
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string Collapse(string value)
|
||||
{
|
||||
var builder = new StringBuilder(value.Length);
|
||||
var space = false;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c is ' ' or '\t')
|
||||
{
|
||||
space = true;
|
||||
continue;
|
||||
}
|
||||
if (space && builder.Length > 0) builder.Append(' ');
|
||||
space = false;
|
||||
builder.Append(c);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static byte[] Hash(HashAlgorithmName name, byte[] data) =>
|
||||
name == HashAlgorithmName.SHA1 ? SHA1.HashData(data) : SHA256.HashData(data);
|
||||
|
||||
private static string Short(string value) => value.Length <= 10 ? value : value[..10] + "…";
|
||||
|
||||
/// <summary>解析 `k=v; k=v` 形式的标签(DKIM 签名头与 DNS 公钥记录共用)。</summary>
|
||||
public static Dictionary<string, string> ParseTags(string text)
|
||||
{
|
||||
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var part in text.Split(';'))
|
||||
{
|
||||
var equals = part.IndexOf('=');
|
||||
if (equals <= 0) continue;
|
||||
var key = part[..equals].Trim().ToLowerInvariant();
|
||||
var value = part[(equals + 1)..];
|
||||
// 值里的换行/空白都要去掉(b=、p= 常被折成多行)
|
||||
value = new string(value.Where(c => c is not (' ' or '\t' or '\r' or '\n')).ToArray());
|
||||
if (key.Length > 0) tags[key] = value;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DMARC 求值(RFC 7489 的常用子集)。</summary>
|
||||
public static class Dmarc
|
||||
{
|
||||
public sealed record Result(string Outcome, string Policy, string Domain, string Detail);
|
||||
|
||||
/// <summary>公共后缀的常用子集(判断「组织域」用;没列到的按最后两段算)。</summary>
|
||||
private static readonly HashSet<string> MultiPartSuffixes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp",
|
||||
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "com.hk", "com.tw", "com.au",
|
||||
"com.br", "com.sg", "co.kr", "com.mx", "co.in", "com.tr",
|
||||
};
|
||||
|
||||
/// <summary>组织域(relaxed 对齐用):`mail.example.co.uk` → `example.co.uk`。</summary>
|
||||
public static string OrganizationalDomain(string domain)
|
||||
{
|
||||
var parts = (domain ?? "").Trim().TrimEnd('.').ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length <= 2) return string.Join('.', parts);
|
||||
var lastTwo = string.Join('.', parts[^2..]);
|
||||
return MultiPartSuffixes.Contains(lastTwo) && parts.Length >= 3
|
||||
? string.Join('.', parts[^3..])
|
||||
: lastTwo;
|
||||
}
|
||||
|
||||
public static async Task<Result> EvaluateAsync(string fromDomain, (string Outcome, string Domain) spf,
|
||||
IReadOnlyList<(string Outcome, string Domain)> dkims, IDnsLookup dns, CancellationToken token)
|
||||
{
|
||||
fromDomain = (fromDomain ?? "").Trim().TrimEnd('.').ToLowerInvariant();
|
||||
if (fromDomain.Length == 0) return new Result("none", "none", "", "报文没有可用的 From 域名");
|
||||
|
||||
var records = await dns.TxtAsync("_dmarc." + fromDomain, token);
|
||||
var record = records.FirstOrDefault(r => r.TrimStart().StartsWith("v=DMARC1", StringComparison.OrdinalIgnoreCase));
|
||||
if (record is null) return new Result("none", "none", fromDomain, $"_dmarc.{fromDomain} 没有 DMARC 记录");
|
||||
|
||||
var tags = DkimVerifier.ParseTags(record);
|
||||
var policy = tags.GetValueOrDefault("p", "").ToLowerInvariant();
|
||||
if (policy is not ("none" or "quarantine" or "reject"))
|
||||
return new Result("none", "none", fromDomain, $"DMARC 记录里的 p= 无效:{policy}");
|
||||
var strictSpf = tags.GetValueOrDefault("aspf", "r").Equals("s", StringComparison.OrdinalIgnoreCase);
|
||||
var strictDkim = tags.GetValueOrDefault("adkim", "r").Equals("s", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool Aligned(string candidate, bool strict)
|
||||
{
|
||||
if (candidate.Length == 0) return false;
|
||||
return strict
|
||||
? candidate.Equals(fromDomain, StringComparison.OrdinalIgnoreCase)
|
||||
: OrganizationalDomain(candidate) == OrganizationalDomain(fromDomain);
|
||||
}
|
||||
|
||||
if (spf.Outcome == "pass" && Aligned(spf.Domain, strictSpf))
|
||||
return new Result("pass", policy, fromDomain, $"SPF 对齐通过({spf.Domain},aspf={(strictSpf ? "s" : "r")})");
|
||||
foreach (var dkim in dkims.Where(d => d.Outcome == "pass"))
|
||||
if (Aligned(dkim.Domain, strictDkim))
|
||||
return new Result("pass", policy, fromDomain, $"DKIM 对齐通过({dkim.Domain},adkim={(strictDkim ? "s" : "r")})");
|
||||
|
||||
// 没有任何对齐的通过项 → 失败(temperror 时按 RFC 也不该直接判失败,这里如实标注)
|
||||
var temperror = dkims.Any(d => d.Outcome == "temperror") || spf.Outcome == "temperror";
|
||||
var detail = $"SPF={spf.Outcome}({spf.Domain}) DKIM=[{string.Join(",", dkims.Select(d => $"{d.Outcome}:{d.Domain}"))}] 与 From 域 {fromDomain} 无对齐";
|
||||
return new Result(temperror ? "temperror" : "fail", policy, fromDomain, detail);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把三件事串起来:SPF → DKIM → DMARC,产出可写进报文的结论与是否判为垃圾。</summary>
|
||||
public static class InboundAuth
|
||||
{
|
||||
public static async Task<InboundAuthVerdict> CheckAsync(byte[] raw, string clientIp, string helo, string mailFrom,
|
||||
AppConfig config, CancellationToken token, IDnsLookup? dns = null)
|
||||
{
|
||||
dns ??= new UdpDnsLookup(config);
|
||||
var cfg = config.InboundAuth;
|
||||
|
||||
var fromDomain = "";
|
||||
try
|
||||
{
|
||||
var parsed = Mime.Parse(raw);
|
||||
// 注意:服务端 Mime.Parse 的 From 是**字符串**(不是地址数组),要先用 Mime.Addresses 拆
|
||||
fromDomain = Spf.DomainOf(Mime.Addresses(parsed.From).FirstOrDefault() ?? parsed.From);
|
||||
}
|
||||
catch { /* 报文解析失败就按无 From 处理 */ }
|
||||
|
||||
var spf = await Spf.EvaluateAsync(clientIp, helo, mailFrom, dns, cfg, token);
|
||||
|
||||
var dkimResults = new List<(string Outcome, string Domain)>();
|
||||
var dkimDetail = "";
|
||||
if (cfg.VerifyDkim)
|
||||
{
|
||||
var verified = await DkimVerifier.VerifyAllAsync(raw, dns, token);
|
||||
foreach (var v in verified) dkimResults.Add((v.Outcome, v.Domain));
|
||||
var best = verified.OrderBy(v => v.Outcome == "pass" ? 0 : v.Outcome == "temperror" ? 1 : 2).FirstOrDefault();
|
||||
if (best is not null) dkimDetail = $"d={best.Domain} s={best.Selector} {best.Detail}";
|
||||
}
|
||||
|
||||
var dmarc = await Dmarc.EvaluateAsync(fromDomain, (spf.Outcome, spf.Domain), dkimResults, dns, token);
|
||||
|
||||
// ── 打分(0 = 干净;阈值默认 3)
|
||||
var score = 0;
|
||||
var reasons = new List<string>();
|
||||
switch (dmarc.Outcome)
|
||||
{
|
||||
case "fail":
|
||||
score += 4;
|
||||
reasons.Add($"DMARC 失败(p={dmarc.Policy}):{dmarc.Detail}");
|
||||
if (dmarc.Policy == "reject") score += 2;
|
||||
break;
|
||||
case "none":
|
||||
if (spf.Outcome == "fail") { score += 2; reasons.Add("SPF 硬失败且该域没有 DMARC 记录"); }
|
||||
else if (spf.Outcome == "softfail") { score += 1; reasons.Add("SPF 软失败"); }
|
||||
else if (spf.Outcome == "permerror") { score += 1; reasons.Add("SPF 记录有错(permerror)"); }
|
||||
var anyDkim = dkimResults.Count > 0;
|
||||
if (anyDkim && !dkimResults.Any(d => d.Outcome == "pass")) { score += 1; reasons.Add("带了 DKIM 签名但验不过"); }
|
||||
break;
|
||||
}
|
||||
if (spf.Outcome is "none" && dkimResults.Count == 0 && dmarc.Outcome == "none")
|
||||
reasons.Add("既没有 SPF 也没有 DKIM(小发件人常见,仅作提示)");
|
||||
|
||||
var hardFail = dmarc.Outcome == "fail" || score >= Math.Max(1, cfg.SpamScoreThreshold);
|
||||
var spam = hardFail && cfg.SpamFolderOnFail;
|
||||
var reject = dmarc.Outcome == "fail" && dmarc.Policy == "reject" && cfg.RejectOnDmarcReject;
|
||||
|
||||
var verdict = new InboundAuthVerdict
|
||||
{
|
||||
Spf = spf.Outcome,
|
||||
SpfDomain = spf.Domain,
|
||||
SpfDetail = spf.Detail,
|
||||
Dkim = dkimResults.Count == 0 ? "none"
|
||||
: dkimResults.Any(d => d.Outcome == "pass") ? "pass"
|
||||
: dkimResults.Any(d => d.Outcome == "temperror") ? "temperror" : "fail",
|
||||
DkimDomain = dkimResults.FirstOrDefault().Domain ?? "",
|
||||
DkimDetail = dkimDetail,
|
||||
Dmarc = dmarc.Outcome,
|
||||
DmarcDomain = dmarc.Domain,
|
||||
DmarcPolicy = dmarc.Policy,
|
||||
Score = score,
|
||||
Reasons = reasons.ToArray(),
|
||||
Spam = spam,
|
||||
Reject = reject,
|
||||
};
|
||||
return cfg.AddAuthenticationResults ? verdict with { HeaderBlock = BuildHeader(verdict, config) } : verdict;
|
||||
}
|
||||
|
||||
private static string BuildHeader(InboundAuthVerdict v, AppConfig config)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Authentication-Results: ").Append(config.Hostname).Append(";\r\n");
|
||||
builder.Append("\tspf=").Append(v.Spf);
|
||||
if (v.SpfDomain.Length > 0) builder.Append(" smtp.mailfrom=").Append(v.SpfDomain);
|
||||
builder.Append(";\r\n");
|
||||
builder.Append("\tdkim=").Append(v.Dkim);
|
||||
if (v.DkimDomain.Length > 0) builder.Append(" header.d=").Append(v.DkimDomain);
|
||||
builder.Append(";\r\n");
|
||||
builder.Append("\tdmarc=").Append(v.Dmarc);
|
||||
if (v.DmarcDomain.Length > 0) builder.Append(" header.from=").Append(v.DmarcDomain);
|
||||
if (v.DmarcPolicy != "none") builder.Append(" policy=").Append(v.DmarcPolicy);
|
||||
builder.Append("\r\n");
|
||||
builder.Append("X-Spam-Score: ").Append(v.Score).Append("\r\n");
|
||||
if (v.Reasons.Length > 0)
|
||||
builder.Append("X-Spam-Reason: ").Append(string.Join(" / ", v.Reasons)).Append("\r\n");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>把校验收到的头前置到报文最前面(不碰原有字节,避免破坏对方 DKIM 签名)。</summary>
|
||||
public static byte[] PrependHeaders(byte[] raw, string headerBlock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(headerBlock)) return raw;
|
||||
var prefix = Encoding.Latin1.GetBytes(headerBlock);
|
||||
var output = new byte[prefix.Length + raw.Length];
|
||||
Buffer.BlockCopy(prefix, 0, output, 0, prefix.Length);
|
||||
Buffer.BlockCopy(raw, 0, output, prefix.Length, raw.Length);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 一次性数据迁移(用法:WpywMail.Native.exe --migrate)。
|
||||
///
|
||||
/// 1.x 版本有两个存储层缺陷:
|
||||
/// · Mime.Parse 不解码 RFC 2047 编码字,也不解 base64/QP 正文 ——
|
||||
/// 导致 messages.json 里的主题是「=?utf-8?b?...?=」、正文是 base64 乱码;
|
||||
/// · 换域名后历史邮件的 ownerEmail 仍指向旧域名,登录新账号后看不到。
|
||||
/// 本迁移用新解析器重新解析 raw/*.eml 修好字段,并把旧域名归到当前配置的账号下。
|
||||
/// </summary>
|
||||
public static class Migration
|
||||
{
|
||||
public static int Run(AppConfig config, IMailStore store)
|
||||
{
|
||||
Console.WriteLine("=== 数据迁移 ===");
|
||||
|
||||
// 旧域名 → 当前域名的账号映射
|
||||
var users = store.AllUsers();
|
||||
var oldAdmin = users.FirstOrDefault(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase));
|
||||
var currentEmail = oldAdmin?.Email ?? config.AdminEmail;
|
||||
|
||||
var messages = store.AllMessages();
|
||||
var fixedSubjects = 0;
|
||||
var reParsed = 0;
|
||||
var reOwnered = 0;
|
||||
var missingRaw = 0;
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
// ---- 1) 重新解析原始报文 ----
|
||||
byte[]? raw = null;
|
||||
if (!string.IsNullOrWhiteSpace(message.RawPath))
|
||||
{
|
||||
try { raw = store.ReadRaw(message.RawPath); }
|
||||
catch { missingRaw++; }
|
||||
}
|
||||
|
||||
if (raw is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = Mime.Parse(raw);
|
||||
var changed = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parsed.Subject) && parsed.Subject != "(无主题)" && parsed.Subject != message.Subject)
|
||||
{
|
||||
message.Subject = parsed.Subject;
|
||||
changed = true;
|
||||
}
|
||||
if (parsed.Text.Length > 0 && parsed.Text != message.Text)
|
||||
{
|
||||
message.Text = parsed.Text;
|
||||
changed = true;
|
||||
}
|
||||
if (parsed.Html.Length > 0 && parsed.Html != message.Html)
|
||||
{
|
||||
message.Html = parsed.Html;
|
||||
changed = true;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(message.MessageId) && parsed.MessageId.Length > 0)
|
||||
{
|
||||
message.MessageId = parsed.MessageId;
|
||||
changed = true;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(message.From) && parsed.From.Length > 0)
|
||||
{
|
||||
message.From = parsed.From;
|
||||
changed = true;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(message.Cc) && parsed.Cc.Length > 0)
|
||||
{
|
||||
message.Cc = parsed.Cc;
|
||||
changed = true;
|
||||
}
|
||||
// 附件补齐(旧版本没有存附件)
|
||||
if (message.Attachments.Count == 0 && parsed.Attachments.Count > 0)
|
||||
{
|
||||
foreach (var attachment in parsed.Attachments)
|
||||
{
|
||||
if (attachment.Data.Length == 0) continue;
|
||||
message.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,
|
||||
});
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
reParsed++;
|
||||
if (message.Subject.Length > 0) fixedSubjects++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" [跳过] {message.Id}:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2) 旧域名归属改到当前账号 ----
|
||||
if (!string.IsNullOrWhiteSpace(message.OwnerEmail) &&
|
||||
!message.OwnerEmail.EndsWith("@" + config.Domain, StringComparison.OrdinalIgnoreCase) &&
|
||||
store.FindUser(message.OwnerEmail) is null)
|
||||
{
|
||||
var localPart = message.OwnerEmail.Split('@')[0];
|
||||
var target = users.FirstOrDefault(x => x.Email.StartsWith(localPart + "@", StringComparison.OrdinalIgnoreCase))?.Email
|
||||
?? currentEmail;
|
||||
message.OwnerEmail = target;
|
||||
// 归属改了以后,发件人地址也一并按新域名改写,避免列表里显示成不存在的账号
|
||||
if (message.From.Equals(message.OwnerEmail, StringComparison.OrdinalIgnoreCase) == false &&
|
||||
message.From.Contains('@') && !message.From.Contains('@' + config.Domain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 仅当 From 就是旧账号本身时才改写
|
||||
}
|
||||
reOwnered++;
|
||||
}
|
||||
}
|
||||
|
||||
store.Persist();
|
||||
|
||||
Console.WriteLine($" 重新解析并修正:{reParsed} 封");
|
||||
Console.WriteLine($" 归属域名改写 :{reOwnered} 封");
|
||||
if (missingRaw > 0) Console.WriteLine($" 原始文件缺失 :{missingRaw} 封(仅修正索引字段)");
|
||||
Console.WriteLine("迁移完成。");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>一个解析出来的附件。</summary>
|
||||
public sealed record ParsedAttachment(string FileName, string ContentType, byte[] Data, string ContentId, bool Inline);
|
||||
|
||||
/// <summary>解析结果。Text / Html 已经解码成可直接展示的字符串。</summary>
|
||||
public sealed record ParsedMime(
|
||||
string From,
|
||||
string To,
|
||||
string Cc,
|
||||
string Subject,
|
||||
string MessageId,
|
||||
string InReplyTo,
|
||||
string References,
|
||||
DateTimeOffset? Date,
|
||||
string Text,
|
||||
string Html,
|
||||
IReadOnlyList<ParsedAttachment> Attachments);
|
||||
|
||||
/// <summary>待发送的附件。</summary>
|
||||
public sealed record OutgoingAttachment(string FileName, string ContentType, byte[] Data, string ContentId = "", bool Inline = false);
|
||||
|
||||
/// <summary>组装一封待发送邮件的入参。</summary>
|
||||
public sealed record ComposeRequest(
|
||||
string From,
|
||||
string? FromDisplay,
|
||||
string[] To,
|
||||
string[] Cc,
|
||||
string Subject,
|
||||
string Text,
|
||||
string? Html = null,
|
||||
IReadOnlyList<OutgoingAttachment>? Attachments = null,
|
||||
string? MessageId = null,
|
||||
string InReplyTo = "",
|
||||
string References = "");
|
||||
|
||||
/// <summary>
|
||||
/// MIME 组装与解析。
|
||||
///
|
||||
/// 相比 v1 的关键修正:
|
||||
/// 1. 组装:Message-ID 的域来自配置(不再写死),正文与附件一律 base64,
|
||||
/// 非 ASCII 头一律 RFC 2047 编码并按 75 字符上限切分。
|
||||
/// 2. 解析:正确解码 RFC 2047 编码字、Content-Transfer-Encoding(base64/QP)、
|
||||
/// charset(含 GBK/GB18030),并支持 multipart 与附件。
|
||||
/// </summary>
|
||||
public static class Mime
|
||||
{
|
||||
private const string Crlf = "\r\n";
|
||||
|
||||
// ---------------------------------------------------------------- 解析
|
||||
|
||||
public static ParsedMime Parse(byte[] raw)
|
||||
{
|
||||
var (headers, body) = SplitMessage(raw);
|
||||
return ParseEntity(headers, body);
|
||||
}
|
||||
|
||||
private static ParsedMime ParseEntity(List<KeyValuePair<string, string>> headers, byte[] body)
|
||||
{
|
||||
string Header(string name) => headers.FirstOrDefault(h => h.Key.Equals(name, StringComparison.OrdinalIgnoreCase)).Value ?? "";
|
||||
|
||||
var from = AddressText(DecodeHeader(Header("From")));
|
||||
var to = AddressText(DecodeHeader(Header("To")));
|
||||
var cc = AddressText(DecodeHeader(Header("Cc")));
|
||||
var subject = DecodeHeader(Header("Subject"));
|
||||
if (string.IsNullOrWhiteSpace(subject)) subject = "(无主题)";
|
||||
|
||||
var contentType = Header("Content-Type");
|
||||
var (mediaType, parameters) = ParseContentType(contentType);
|
||||
var transferEncoding = Header("Content-Transfer-Encoding").Trim().ToLowerInvariant();
|
||||
|
||||
var text = new StringBuilder();
|
||||
var html = new StringBuilder();
|
||||
var attachments = new List<ParsedAttachment>();
|
||||
|
||||
if (mediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) &&
|
||||
parameters.TryGetValue("boundary", out var boundary) && !string.IsNullOrEmpty(boundary))
|
||||
{
|
||||
foreach (var part in SplitMultipart(body, boundary))
|
||||
{
|
||||
var (partHeaders, partBody) = SplitMessage(part);
|
||||
var nested = ParseEntity(partHeaders, partBody);
|
||||
// 嵌套的多部分(例如 mixed 里套 alternative)
|
||||
if (nested.Text.Length > 0) text.Append(nested.Text);
|
||||
if (nested.Html.Length > 0) html.Append(nested.Html);
|
||||
attachments.AddRange(nested.Attachments);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var decoded = DecodeTransfer(body, transferEncoding);
|
||||
var charset = parameters.TryGetValue("charset", out var cs) ? cs : null;
|
||||
var content = DecodeCharset(decoded, charset);
|
||||
|
||||
var isAttachment = parameters.TryGetValue("name", out var name) && !string.IsNullOrWhiteSpace(name);
|
||||
var disposition = Header("Content-Disposition");
|
||||
var (dispType, dispParams) = ParseContentType(disposition);
|
||||
if (dispType.Equals("attachment", StringComparison.OrdinalIgnoreCase) ||
|
||||
(dispType.Equals("inline", StringComparison.OrdinalIgnoreCase) && !mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)))
|
||||
isAttachment = true;
|
||||
|
||||
if (isAttachment)
|
||||
{
|
||||
var fileName = DecodeParameter(dispParams.GetValueOrDefault("filename*"))
|
||||
?? DecodeHeader(dispParams.GetValueOrDefault("filename") ?? "")
|
||||
?? DecodeParameter(parameters.GetValueOrDefault("name*"))
|
||||
?? DecodeHeader(parameters.GetValueOrDefault("name") ?? "")
|
||||
?? "attachment.bin";
|
||||
if (string.IsNullOrWhiteSpace(fileName)) fileName = "attachment.bin";
|
||||
attachments.Add(new ParsedAttachment(
|
||||
fileName,
|
||||
mediaType,
|
||||
decoded,
|
||||
Header("Content-ID").Trim('<', '>'),
|
||||
dispType.Equals("inline", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
else if (mediaType.Equals("text/html", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
html.Append(content);
|
||||
}
|
||||
else
|
||||
{
|
||||
text.Append(content);
|
||||
}
|
||||
}
|
||||
|
||||
return new ParsedMime(
|
||||
from, to, cc, subject,
|
||||
Header("Message-ID").Trim(),
|
||||
Header("In-Reply-To").Trim(),
|
||||
Header("References").Trim(),
|
||||
ParseDate(Header("Date")),
|
||||
text.ToString().TrimEnd(),
|
||||
html.ToString().TrimEnd(),
|
||||
attachments);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDate(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
if (DateTimeOffset.TryParse(value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var parsed)) return parsed;
|
||||
// 常见变体:缺秒、单数字日等
|
||||
var cleaned = value.Replace("GMT", "+0000").Replace("UT", "+0000").Trim();
|
||||
if (DateTimeOffset.TryParse(cleaned, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>把原始字节切成「头(已展开)」与「正文」。</summary>
|
||||
internal static (List<KeyValuePair<string, string>> Headers, byte[] Body) SplitMessage(byte[] raw)
|
||||
{
|
||||
var separator = IndexOf(raw, Crlf + Crlf);
|
||||
var separatorLength = 4;
|
||||
if (separator < 0)
|
||||
{
|
||||
separator = IndexOf(raw, "\n\n");
|
||||
separatorLength = 2;
|
||||
}
|
||||
|
||||
var headerBytes = separator >= 0 ? raw[..separator] : raw;
|
||||
var body = separator >= 0 ? raw[(separator + separatorLength)..] : [];
|
||||
var headerText = DecodeHeaderBlock(headerBytes);
|
||||
|
||||
var headers = new List<KeyValuePair<string, string>>();
|
||||
string? currentName = null;
|
||||
var currentValue = new StringBuilder();
|
||||
void Flush()
|
||||
{
|
||||
if (currentName is not null) headers.Add(new KeyValuePair<string, string>(currentName, currentValue.ToString().Trim()));
|
||||
currentName = null;
|
||||
currentValue.Clear();
|
||||
}
|
||||
|
||||
foreach (var line in headerText.Split('\n'))
|
||||
{
|
||||
if (line.Length == 0) continue;
|
||||
if ((line[0] == ' ' || line[0] == '\t') && currentName is not null)
|
||||
{
|
||||
currentValue.Append(' ').Append(line.Trim());
|
||||
continue;
|
||||
}
|
||||
Flush();
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon <= 0) continue;
|
||||
currentName = line[..colon].Trim();
|
||||
currentValue.Append(line[(colon + 1)..].Trim());
|
||||
}
|
||||
Flush();
|
||||
return (headers, body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解码头部块。
|
||||
///
|
||||
/// 头部按标准应当是 ASCII(非 ASCII 必须用 RFC 2047 编码字),但现实中不少客户端
|
||||
/// 直接把裸 UTF-8 写进头里(8bit 头)。这里优先按 UTF-8 严格解码,失败再回退
|
||||
/// Latin-1(保证字节不丢);否则裸 UTF-8 的中文主题会变成 «æµè¯» 这种乱码。
|
||||
/// </summary>
|
||||
private static string DecodeHeaderBlock(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(bytes).Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return Encoding.Latin1.GetString(bytes).Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>按 boundary 切分 multipart 正文。返回每个子部分的原始字节。</summary>
|
||||
private static List<byte[]> SplitMultipart(byte[] body, string boundary)
|
||||
{
|
||||
var parts = new List<byte[]>();
|
||||
var delimiter = Encoding.ASCII.GetBytes("--" + boundary);
|
||||
var positions = new List<int>();
|
||||
for (var i = 0; i + delimiter.Length <= body.Length; i++)
|
||||
{
|
||||
if (body[i] != (byte)'-') continue;
|
||||
var match = true;
|
||||
for (var j = 0; j < delimiter.Length; j++)
|
||||
{
|
||||
if (body[i + j] != delimiter[j]) { match = false; break; }
|
||||
}
|
||||
if (match) { positions.Add(i); i += delimiter.Length - 1; }
|
||||
}
|
||||
if (positions.Count < 2) return parts;
|
||||
|
||||
for (var index = 0; index < positions.Count - 1; index++)
|
||||
{
|
||||
var start = positions[index];
|
||||
// 跳过边界行自身
|
||||
var lineEnd = IndexOf(body, start, "\n");
|
||||
if (lineEnd < 0) continue;
|
||||
start = lineEnd + 1;
|
||||
var end = positions[index + 1];
|
||||
// 去掉结尾的 CRLF
|
||||
while (end > start && (body[end - 1] == (byte)'\n' || body[end - 1] == (byte)'\r')) end--;
|
||||
if (end > start) parts.Add(body[start..end]);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// <summary>解码 Content-Transfer-Encoding。</summary>
|
||||
private static byte[] DecodeTransfer(byte[] body, string encoding) => encoding switch
|
||||
{
|
||||
"base64" => TryBase64(body),
|
||||
"quoted-printable" => DecodeQuotedPrintable(body),
|
||||
_ => body, // 7bit / 8bit / binary / 空
|
||||
};
|
||||
|
||||
private static byte[] TryBase64(byte[] body)
|
||||
{
|
||||
// 去掉所有空白字符后解码;容错处理不完整的 base64
|
||||
var buffer = new StringBuilder(body.Length);
|
||||
foreach (var b in body)
|
||||
{
|
||||
if (b is (byte)' ' or (byte)'\r' or (byte)'\n' or (byte)'\t') continue;
|
||||
if (b == (byte)'=') { buffer.Append('='); continue; }
|
||||
buffer.Append((char)b);
|
||||
}
|
||||
var text = buffer.ToString();
|
||||
var padding = text.Length % 4;
|
||||
if (padding == 1) text = text[..^1];
|
||||
else if (padding is 2 or 3) text += new string('=', 4 - padding);
|
||||
try { return Convert.FromBase64String(text); }
|
||||
catch (FormatException) { return []; }
|
||||
}
|
||||
|
||||
private static byte[] DecodeQuotedPrintable(byte[] body)
|
||||
{
|
||||
using var output = new MemoryStream(body.Length);
|
||||
for (var i = 0; i < body.Length; i++)
|
||||
{
|
||||
var b = body[i];
|
||||
if (b != (byte)'=') { output.WriteByte(b); continue; }
|
||||
|
||||
// 软换行 =CRLF / =LF
|
||||
if (i + 1 < body.Length && body[i + 1] == (byte)'\n') { i += 1; continue; }
|
||||
if (i + 2 < body.Length && body[i + 1] == (byte)'\r' && body[i + 2] == (byte)'\n') { i += 2; continue; }
|
||||
if (i + 2 < body.Length && IsHex(body[i + 1]) && IsHex(body[i + 2]))
|
||||
{
|
||||
output.WriteByte((byte)((HexValue(body[i + 1]) << 4) | HexValue(body[i + 2])));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
output.WriteByte(b);
|
||||
}
|
||||
return output.ToArray();
|
||||
|
||||
static bool IsHex(byte b) => (b >= '0' && b <= '9') || (b >= 'A' && b <= 'F') || (b >= 'a' && b <= 'f');
|
||||
static int HexValue(byte b) => b switch
|
||||
{
|
||||
>= (byte)'0' and <= (byte)'9' => b - '0',
|
||||
>= (byte)'A' and <= (byte)'F' => b - 'A' + 10,
|
||||
_ => b - 'a' + 10,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 头编码
|
||||
|
||||
/// <summary>解码 RFC 2047 编码字;相邻编码字之间的空白会被丢弃。</summary>
|
||||
public static string DecodeHeader(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || !value.Contains("=?")) return value ?? "";
|
||||
|
||||
var result = new StringBuilder();
|
||||
var index = 0;
|
||||
var lastWasEncoded = false;
|
||||
while (index < value.Length)
|
||||
{
|
||||
var start = value.IndexOf("=?", index, StringComparison.Ordinal);
|
||||
if (start < 0) { result.Append(value, index, value.Length - index); break; }
|
||||
|
||||
// 编码字之间的空格应被忽略
|
||||
var gap = value[index..start];
|
||||
if (!(lastWasEncoded && gap.Trim().Length == 0)) result.Append(gap);
|
||||
else if (lastWasEncoded) { /* 丢弃 */ }
|
||||
|
||||
var firstQuestion = value.IndexOf('?', start + 2);
|
||||
var secondQuestion = firstQuestion < 0 ? -1 : value.IndexOf('?', firstQuestion + 1);
|
||||
var end = secondQuestion < 0 ? -1 : value.IndexOf("?=", secondQuestion + 1, StringComparison.Ordinal);
|
||||
if (firstQuestion < 0 || secondQuestion < 0 || end < 0)
|
||||
{
|
||||
result.Append(value, start, value.Length - start);
|
||||
break;
|
||||
}
|
||||
|
||||
var charset = value[(start + 2)..firstQuestion];
|
||||
var encoding = value[(firstQuestion + 1)..secondQuestion];
|
||||
var payload = value[(secondQuestion + 1)..end];
|
||||
try
|
||||
{
|
||||
byte[] bytes;
|
||||
if (encoding.Equals("B", StringComparison.OrdinalIgnoreCase))
|
||||
bytes = Convert.FromBase64String(payload);
|
||||
else if (encoding.Equals("Q", StringComparison.OrdinalIgnoreCase))
|
||||
bytes = DecodeQ(payload);
|
||||
else { result.Append(value, start, end + 2 - start); index = end + 2; lastWasEncoded = false; continue; }
|
||||
|
||||
result.Append(DecodeCharset(bytes, charset));
|
||||
lastWasEncoded = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result.Append(value, start, end + 2 - start);
|
||||
lastWasEncoded = false;
|
||||
}
|
||||
index = end + 2;
|
||||
}
|
||||
return result.ToString();
|
||||
|
||||
static byte[] DecodeQ(string payload)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
var c = payload[i];
|
||||
if (c == '_') { output.WriteByte((byte)' '); continue; }
|
||||
if (c == '=' && i + 2 < payload.Length)
|
||||
{
|
||||
try { output.WriteByte(Convert.ToByte(payload.Substring(i + 1, 2), 16)); i += 2; continue; }
|
||||
catch { }
|
||||
}
|
||||
output.WriteByte((byte)c);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>解码 RFC 2231 参数值(形如 UTF-8''%E4%B8%AD%文)。</summary>
|
||||
private static string? DecodeParameter(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var text = value.Trim();
|
||||
var separator = text.IndexOf("''", StringComparison.Ordinal);
|
||||
if (separator < 0) return null;
|
||||
var charset = text[..separator];
|
||||
var encoded = text[(separator + 2)..];
|
||||
try
|
||||
{
|
||||
using var buffer = new MemoryStream(encoded.Length);
|
||||
for (var i = 0; i < encoded.Length; i++)
|
||||
{
|
||||
if (encoded[i] == '%' && i + 2 < encoded.Length)
|
||||
{
|
||||
buffer.WriteByte(Convert.ToByte(encoded.Substring(i + 1, 2), 16));
|
||||
i += 2;
|
||||
}
|
||||
else buffer.WriteByte((byte)encoded[i]);
|
||||
}
|
||||
return DecodeCharset(buffer.ToArray(), charset);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>对外暴露的 RFC 2047 编码入口(IMAP ENVELOPE 等需要把非 ASCII 头值变成 ASCII)。</summary>
|
||||
public static string EncodeHeaderValue(string value) =>
|
||||
EncodeHeader(value ?? "").Replace("\r\n", " ").Replace("\n", " ");
|
||||
|
||||
/// <summary>需要时把文本编码成 RFC 2047 编码字(按 75 字符上限切分)。</summary>
|
||||
private static string EncodeHeader(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
if (value.All(c => c is >= ' ' and <= '~')) return value;
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var chunks = new List<string>();
|
||||
var offset = 0;
|
||||
while (offset < bytes.Length)
|
||||
{
|
||||
// 每个编码字最多 75 字符;"=?UTF-8?B?" + "?=" 占 12 个字符 → base64 最多 63
|
||||
var take = Math.Min(45, bytes.Length - offset);
|
||||
// 不要把多字节字符切开
|
||||
while (take > 1 && offset + take < bytes.Length && (bytes[offset + take] & 0xC0) == 0x80) take--;
|
||||
chunks.Add("=?UTF-8?B?" + Convert.ToBase64String(bytes, offset, take) + "?=");
|
||||
offset += take;
|
||||
}
|
||||
return chunks.Count == 1 ? chunks[0] : string.Join(Crlf + " ", chunks);
|
||||
}
|
||||
|
||||
/// <summary>解析地址列表,抽出纯地址。</summary>
|
||||
public static string[] Addresses(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return [];
|
||||
var result = new List<string>();
|
||||
var depth = 0;
|
||||
var current = new StringBuilder();
|
||||
foreach (var c in value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '<': depth++; current.Append(c); break;
|
||||
case '>': depth = Math.Max(0, depth - 1); current.Append(c); break;
|
||||
case ',' or ';' when depth == 0: Add(); break;
|
||||
default: current.Append(c); break;
|
||||
}
|
||||
}
|
||||
Add();
|
||||
return result.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
void Add()
|
||||
{
|
||||
var item = current.ToString().Trim();
|
||||
current.Clear();
|
||||
if (item.Length == 0) return;
|
||||
var start = item.IndexOf('<');
|
||||
var end = item.IndexOf('>', start + 1);
|
||||
var address = start >= 0 && end > start ? item[(start + 1)..end].Trim() : item;
|
||||
if (address.Contains('@') && address.IndexOf('@') > 0 && address.IndexOf('@') < address.Length - 1) result.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
private static string AddressText(string value) => value ?? "";
|
||||
|
||||
internal static (string MediaType, Dictionary<string, string> Parameters) ParseContentType(string value)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(value)) return ("text/plain", parameters);
|
||||
var segments = value.Split(';');
|
||||
var mediaType = segments[0].Trim().ToLowerInvariant();
|
||||
for (var i = 1; i < segments.Length; i++)
|
||||
{
|
||||
var part = segments[i].Trim();
|
||||
var equals = part.IndexOf('=');
|
||||
if (equals <= 0) continue;
|
||||
var name = part[..equals].Trim().ToLowerInvariant();
|
||||
var val = part[(equals + 1)..].Trim().Trim('"');
|
||||
parameters[name] = val;
|
||||
}
|
||||
return (mediaType, parameters);
|
||||
}
|
||||
|
||||
/// <summary>按 charset 解码字节;未知字符集回退 UTF-8。</summary>
|
||||
public static string DecodeCharset(byte[] bytes, string? charset)
|
||||
{
|
||||
if (bytes.Length == 0) return "";
|
||||
var encoding = ResolveEncoding(charset);
|
||||
try { return encoding.GetString(bytes); }
|
||||
catch { return Encoding.UTF8.GetString(bytes); }
|
||||
}
|
||||
|
||||
private static Encoding ResolveEncoding(string? charset)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(charset)) return Encoding.UTF8;
|
||||
var name = charset.Trim().Trim('"').ToLowerInvariant();
|
||||
if (name is "utf8" or "utf-8" or "unicode-1-1-utf-8") return new UTF8Encoding(false);
|
||||
if (name is "us-ascii" or "ascii") return Encoding.ASCII;
|
||||
if (name is "latin1" or "iso-8859-1" or "iso8859-1" or "windows-1252") return Encoding.Latin1;
|
||||
try { return Encoding.GetEncoding(name); }
|
||||
catch
|
||||
{
|
||||
if (name is "gb2312" or "gbk" or "gb18030" or "csgb2312" or "x-gbk")
|
||||
{
|
||||
var provider = CodePagesShim.Provider;
|
||||
if (provider is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Encoding.RegisterProvider(provider);
|
||||
var codePage = name == "gb18030" ? 54936 : 936;
|
||||
return Encoding.GetEncoding(codePage);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
AppLog.Warn($"[MIME] 不支持的字符集 {charset},按 UTF-8 处理。");
|
||||
return Encoding.UTF8;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 组装
|
||||
|
||||
public static byte[] Build(ComposeRequest request, AppConfig config)
|
||||
{
|
||||
var messageId = string.IsNullOrWhiteSpace(request.MessageId)
|
||||
? $"<{Guid.NewGuid():N}@{config.Domain}>"
|
||||
: (request.MessageId.StartsWith('<') ? request.MessageId : $"<{request.MessageId}>");
|
||||
|
||||
var attachments = request.Attachments ?? [];
|
||||
var hasHtml = !string.IsNullOrWhiteSpace(request.Html);
|
||||
var textPartBytes = Encoding.UTF8.GetBytes(request.Text ?? "");
|
||||
var htmlPartBytes = hasHtml ? Encoding.UTF8.GetBytes(request.Html!) : [];
|
||||
|
||||
var headers = new StringBuilder();
|
||||
headers.Append("Date: ").Append(FormatDate(DateTimeOffset.UtcNow)).Append(Crlf);
|
||||
headers.Append("From: ").Append(FormatAddress(request.From, request.FromDisplay)).Append(Crlf);
|
||||
headers.Append("To: ").Append(string.Join(", ", request.To.Select(a => FormatAddress(a, null)))).Append(Crlf);
|
||||
if (request.Cc.Length > 0) headers.Append("Cc: ").Append(string.Join(", ", request.Cc.Select(a => FormatAddress(a, null)))).Append(Crlf);
|
||||
headers.Append("Subject: ").Append(EncodeHeader(request.Subject ?? "")).Append(Crlf);
|
||||
headers.Append("Message-ID: ").Append(messageId).Append(Crlf);
|
||||
if (!string.IsNullOrWhiteSpace(request.InReplyTo)) headers.Append("In-Reply-To: ").Append(request.InReplyTo.Trim()).Append(Crlf);
|
||||
if (!string.IsNullOrWhiteSpace(request.References)) headers.Append("References: ").Append(request.References.Trim()).Append(Crlf);
|
||||
headers.Append("MIME-Version: 1.0").Append(Crlf);
|
||||
|
||||
var body = new MemoryStream();
|
||||
if (attachments.Count > 0)
|
||||
{
|
||||
var mixedBoundary = "mix-" + Guid.NewGuid().ToString("N")[..16];
|
||||
headers.Append("Content-Type: multipart/mixed; boundary=\"").Append(mixedBoundary).Append('"').Append(Crlf);
|
||||
headers.Append(Crlf);
|
||||
|
||||
WriteBoundary(body, mixedBoundary, false);
|
||||
body.Write(hasHtml ? BuildAlternativePart(textPartBytes, htmlPartBytes) : BuildTextPart(textPartBytes));
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
WriteBoundary(body, mixedBoundary, false);
|
||||
var part = new StringBuilder();
|
||||
part.Append("Content-Type: ").Append(attachment.ContentType).Append("; ")
|
||||
.Append(FileNameParameters(attachment.FileName, "name")).Append(Crlf);
|
||||
part.Append("Content-Transfer-Encoding: base64").Append(Crlf);
|
||||
part.Append("Content-Disposition: ").Append(attachment.Inline ? "inline" : "attachment").Append("; ")
|
||||
.Append(FileNameParameters(attachment.FileName, "filename")).Append(Crlf);
|
||||
if (!string.IsNullOrWhiteSpace(attachment.ContentId))
|
||||
part.Append("Content-ID: <").Append(attachment.ContentId.Trim('<', '>')).Append('>').Append(Crlf);
|
||||
part.Append(Crlf);
|
||||
part.Append(WrapBase64(attachment.Data));
|
||||
body.Write(Encoding.ASCII.GetBytes(part.ToString()));
|
||||
}
|
||||
WriteBoundary(body, mixedBoundary, true);
|
||||
}
|
||||
else if (hasHtml)
|
||||
{
|
||||
var boundary = "alt-" + Guid.NewGuid().ToString("N")[..16];
|
||||
headers.Append("Content-Type: multipart/alternative; boundary=\"").Append(boundary).Append('"').Append(Crlf);
|
||||
headers.Append(Crlf);
|
||||
body.Write(BuildAlternativeWithBoundary(textPartBytes, htmlPartBytes, boundary));
|
||||
}
|
||||
else
|
||||
{
|
||||
headers.Append("Content-Type: text/plain; charset=UTF-8").Append(Crlf);
|
||||
headers.Append("Content-Transfer-Encoding: base64").Append(Crlf);
|
||||
headers.Append(Crlf);
|
||||
body.Write(Encoding.ASCII.GetBytes(WrapBase64(textPartBytes)));
|
||||
}
|
||||
|
||||
var output = new MemoryStream();
|
||||
output.Write(Encoding.ASCII.GetBytes(headers.ToString()));
|
||||
body.Position = 0;
|
||||
body.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildTextPart(byte[] textBytes)
|
||||
{
|
||||
var part = new StringBuilder();
|
||||
part.Append("Content-Type: text/plain; charset=UTF-8").Append(Crlf);
|
||||
part.Append("Content-Transfer-Encoding: base64").Append(Crlf);
|
||||
part.Append(Crlf);
|
||||
part.Append(WrapBase64(textBytes));
|
||||
return Encoding.ASCII.GetBytes(part.ToString());
|
||||
}
|
||||
|
||||
/// <summary>作为 multipart/mixed 的子部分时,必须带上自己的 Content-Type 头。</summary>
|
||||
private static byte[] BuildAlternativePart(byte[] textBytes, byte[] htmlBytes)
|
||||
{
|
||||
var boundary = "alt-" + Guid.NewGuid().ToString("N")[..16];
|
||||
var output = new MemoryStream();
|
||||
output.Write(Encoding.ASCII.GetBytes("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"" + Crlf + Crlf));
|
||||
output.Write(BuildAlternativeWithBoundary(textBytes, htmlBytes, boundary));
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>附件名参数:ASCII 回退 + RFC 2231 扩展写法,保证中文文件名不乱码。</summary>
|
||||
private static string FileNameParameters(string fileName, string parameterName)
|
||||
{
|
||||
var ascii = new string(fileName.Select(c => c is >= ' ' and <= '~' && c != '"' && c != '\\' ? c : '_').ToArray());
|
||||
if (ascii.Length == 0) ascii = "attachment";
|
||||
return $"{parameterName}=\"{ascii}\"; {parameterName}*=UTF-8''{Uri.EscapeDataString(fileName)}";
|
||||
}
|
||||
|
||||
private static byte[] BuildAlternativeWithBoundary(byte[] textBytes, byte[] htmlBytes, string boundary)
|
||||
{
|
||||
var output = new MemoryStream();
|
||||
WriteBoundary(output, boundary, false);
|
||||
output.Write(BuildTextPart(textBytes));
|
||||
WriteBoundary(output, boundary, false);
|
||||
var html = new StringBuilder();
|
||||
html.Append("Content-Type: text/html; charset=UTF-8").Append(Crlf);
|
||||
html.Append("Content-Transfer-Encoding: base64").Append(Crlf);
|
||||
html.Append(Crlf);
|
||||
html.Append(WrapBase64(htmlBytes));
|
||||
output.Write(Encoding.ASCII.GetBytes(html.ToString()));
|
||||
WriteBoundary(output, boundary, true);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteBoundary(Stream stream, string boundary, bool closing)
|
||||
{
|
||||
var line = "--" + boundary + (closing ? "--" : "") + Crlf;
|
||||
stream.Write(Encoding.ASCII.GetBytes(line));
|
||||
}
|
||||
|
||||
private static string FormatAddress(string address, string? display)
|
||||
{
|
||||
address = address.Trim().Trim('<', '>');
|
||||
if (string.IsNullOrWhiteSpace(display)) return address;
|
||||
return $"{EncodeHeader(display)} <{address}>";
|
||||
}
|
||||
|
||||
/// <summary>RFC 5322 日期(必须是 ±HHMM 形式的时区)。</summary>
|
||||
public static string FormatDate(DateTimeOffset value)
|
||||
{
|
||||
var offset = value.Offset;
|
||||
var sign = offset < TimeSpan.Zero ? "-" : "+";
|
||||
var absolute = offset.Duration();
|
||||
return value.ToString("ddd, dd MMM yyyy HH:mm:ss ", CultureInfo.InvariantCulture) +
|
||||
$"{sign}{absolute.Hours:D2}{absolute.Minutes:D2}";
|
||||
}
|
||||
|
||||
private static string WrapBase64(byte[] bytes)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(bytes);
|
||||
var builder = new StringBuilder(encoded.Length + encoded.Length / 76 * 2 + 2);
|
||||
for (var index = 0; index < encoded.Length; index += 76)
|
||||
{
|
||||
builder.Append(encoded, index, Math.Min(76, encoded.Length - index)).Append(Crlf);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
private static int IndexOf(byte[] data, string text) => IndexOf(data, 0, text);
|
||||
|
||||
private static int IndexOf(byte[] data, int start, string text)
|
||||
{
|
||||
var pattern = Encoding.ASCII.GetBytes(text);
|
||||
for (var i = start; i + pattern.Length <= data.Length; i++)
|
||||
{
|
||||
var match = true;
|
||||
for (var j = 0; j < pattern.Length; j++)
|
||||
{
|
||||
if (data[i + j] != pattern[j]) { match = false; break; }
|
||||
}
|
||||
if (match) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可选注册 .NET 的代码页编码提供程序(用于 GBK/GB18030)。
|
||||
/// 未引用 System.Text.Encoding.CodePages 包时静默降级为 UTF-8。
|
||||
/// </summary>
|
||||
internal static class CodePagesShim
|
||||
{
|
||||
private static EncodingProvider? provider;
|
||||
private static bool resolved;
|
||||
|
||||
public static EncodingProvider? Provider
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!resolved)
|
||||
{
|
||||
resolved = true;
|
||||
try
|
||||
{
|
||||
var type = Type.GetType("System.Text.CodePagesEncodingProvider, System.Text.Encoding.CodePages", throwOnError: false);
|
||||
provider = type?.GetProperty("Instance", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)?.GetValue(null) as EncodingProvider;
|
||||
if (provider is not null) AppLog.Info("[MIME] 已启用代码页编码支持(GBK/GB18030 可正常解码)。");
|
||||
else AppLog.Warn("[MIME] 未找到代码页编码支持;GBK/GB2312 编码的中文邮件可能解码异常。");
|
||||
}
|
||||
catch { provider = null; }
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>应用配置。对应 appsettings.json 的根对象。</summary>
|
||||
public sealed class AppConfig
|
||||
{
|
||||
public string Domain { get; set; } = "";
|
||||
public string Hostname { get; set; } = "";
|
||||
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; } = "";
|
||||
public string AdminEmail { get; set; } = "";
|
||||
public string AdminPassword { get; set; } = "";
|
||||
public string TlsCertificatePath { get; set; } = "";
|
||||
public string TlsCertificatePassword { get; set; } = "";
|
||||
|
||||
/// <summary>direct = 按 MX 直接投递;relay = 走上游 SMTP 中继。</summary>
|
||||
public string DeliveryMode { get; set; } = "direct";
|
||||
|
||||
public DirectDeliveryConfig DirectDelivery { get; set; } = new();
|
||||
public RelayConfig Relay { get; set; } = new();
|
||||
public RetryConfig Retry { get; set; } = new();
|
||||
public DkimConfig Dkim { get; set; } = new();
|
||||
public ApiConfig Api { get; set; } = new();
|
||||
public ImapConfig Imap { get; set; } = new();
|
||||
public SmtpConfig Smtp { get; set; } = new();
|
||||
public StorageConfig Storage { get; set; } = new();
|
||||
public AccountsConfig Accounts { get; set; } = new();
|
||||
public InboundAuthConfig InboundAuth { get; set; } = new();
|
||||
|
||||
/// <summary>启动时做基本校验,尽早暴露配置错误。</summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Domain)) throw new InvalidOperationException("appsettings.json 必须设置 Domain。");
|
||||
if (string.IsNullOrWhiteSpace(Hostname)) throw new InvalidOperationException("appsettings.json 必须设置 Hostname。");
|
||||
if (string.IsNullOrWhiteSpace(AdminEmail) || !AdminEmail.Contains('@')) throw new InvalidOperationException("AdminEmail 必须是完整的邮箱地址。");
|
||||
if (!AdminEmail.EndsWith("@" + Domain, StringComparison.OrdinalIgnoreCase))
|
||||
AppLog.Warn($"[配置] AdminEmail({AdminEmail})不在 Domain({Domain})之下,请确认这是有意的。");
|
||||
if (AdminPassword.Length < 12) throw new InvalidOperationException("请在 appsettings.json 设置至少 12 位 AdminPassword。");
|
||||
if (AdminPassword.Contains("replace-with", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AdminPassword 还是示例值,请改成真实密码。");
|
||||
if (string.IsNullOrWhiteSpace(DataDirectory)) throw new InvalidOperationException("appsettings.json 必须设置 DataDirectory。");
|
||||
if (!DeliveryMode.Equals("direct", StringComparison.OrdinalIgnoreCase) && !DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("DeliveryMode 只能是 direct 或 relay。");
|
||||
if (DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(Relay.Host))
|
||||
throw new InvalidOperationException("DeliveryMode=relay 时必须设置 Relay.Host。");
|
||||
if (SmtpPort is < 1 or > 65535 || SubmissionPort is < 1 or > 65535) throw new InvalidOperationException("SMTP 端口配置非法。");
|
||||
if (SmtpPort == SubmissionPort) throw new InvalidOperationException("SmtpPort 与 SubmissionPort 不能相同。");
|
||||
if (!Storage.Provider.Equals("json", StringComparison.OrdinalIgnoreCase) &&
|
||||
!Storage.Provider.Equals("sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Storage.Provider 只能是 json 或 sqlite。");
|
||||
|
||||
var registration = (Accounts.Registration ?? "").Trim().ToLowerInvariant();
|
||||
if (registration is not ("open" or "invite" or "closed"))
|
||||
throw new InvalidOperationException("Accounts.Registration 只能是 open / invite / closed。");
|
||||
if (registration == "invite" && string.IsNullOrWhiteSpace(Accounts.InviteCode))
|
||||
throw new InvalidOperationException("Accounts.Registration=invite 时必须设置 Accounts.InviteCode。");
|
||||
if (Accounts.MinPasswordLength < 8)
|
||||
throw new InvalidOperationException("Accounts.MinPasswordLength 不能小于 8。");
|
||||
if (Accounts.CodeMinutes < 1 || Accounts.CodeMinutes > 24 * 60)
|
||||
throw new InvalidOperationException("Accounts.CodeMinutes 应在 1..1440 之间。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账号体系配置:自助注册策略、邮箱验证、密码强度、登录锁定、限流。
|
||||
///
|
||||
/// 默认值刻意偏保守:**注册默认 invite(需要邀请码)**,且注册的邮箱域名默认只允许
|
||||
/// 服务器自己的 Domain —— 公网上的邮件服务器一旦开放注册,很快就会变成垃圾邮件跳板。
|
||||
/// 要真正开放,请显式改 Registration=open 并配置 AllowedDomains。
|
||||
/// </summary>
|
||||
public sealed class AccountsConfig
|
||||
{
|
||||
/// <summary>open = 任何人可注册;invite = 需要邀请码;closed = 关闭注册(只能管理员建号)。</summary>
|
||||
public string Registration { get; set; } = "invite";
|
||||
|
||||
/// <summary>invite 模式下的邀请码。</summary>
|
||||
public string InviteCode { get; set; } = "";
|
||||
|
||||
/// <summary>允许注册的邮箱域名(含服务器自身域名)。留空表示只允许 Domain。</summary>
|
||||
public string[] AllowedDomains { get; set; } = [];
|
||||
|
||||
/// <summary>注册后是否必须用邮箱里的验证码激活(强烈建议 true)。</summary>
|
||||
public bool RequireEmailVerification { get; set; } = true;
|
||||
|
||||
/// <summary>密码最小长度(同时会检查:不能是纯数字、不能与邮箱相同)。</summary>
|
||||
public int MinPasswordLength { get; set; } = 12;
|
||||
|
||||
/// <summary>验证码有效期(分钟)。</summary>
|
||||
public int CodeMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>同一个验证码最多尝试几次(超过即作废,需重新获取)。</summary>
|
||||
public int MaxCodeAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>同一账号在窗口期内连续登录失败多少次后锁定。</summary>
|
||||
public int MaxLoginFailures { get; set; } = 8;
|
||||
|
||||
/// <summary>登录失败统计窗口与锁定时长(分钟)。</summary>
|
||||
public int LockoutMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>同一 IP 每小时最多发起几次注册 / 重发验证码(防刷)。</summary>
|
||||
public int RegisterPerHourPerIp { get; set; } = 5;
|
||||
|
||||
/// <summary>同一邮箱每小时最多重发几次验证码。</summary>
|
||||
public int ResendPerHourPerEmail { get; set; } = 5;
|
||||
|
||||
/// <summary>审计日志最多保留多少条(超出后按时间淘汰)。</summary>
|
||||
public int AuditLimit { get; set; } = 2000;
|
||||
|
||||
/// <summary>把配置里的域名规则解析成实际允许的域名集合。</summary>
|
||||
public string[] EffectiveDomains(string serverDomain)
|
||||
{
|
||||
var list = AllowedDomains
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim().TrimStart('@').ToLowerInvariant())
|
||||
.ToList();
|
||||
if (list.Count == 0 && !string.IsNullOrWhiteSpace(serverDomain))
|
||||
list.Add(serverDomain.Trim().TrimStart('@').ToLowerInvariant());
|
||||
return [.. list.Distinct()];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存储后端选择。
|
||||
///
|
||||
/// - json :v2.0.x 的原始实现,users/messages/queue/sessions 各一个 JSON 文件,
|
||||
/// **任何一次改动都会整文件重写**,随邮件量增长呈 O(N) 放大。
|
||||
/// - sqlite :SQLite 单文件数据库(元数据 + 索引 + 事务),原始报文仍落在 raw/ 目录。
|
||||
/// 默认值,也是推荐值;改回 json 即可一键回滚(两套数据互不覆盖)。
|
||||
/// </summary>
|
||||
public sealed class StorageConfig
|
||||
{
|
||||
public string Provider { get; set; } = "sqlite";
|
||||
|
||||
/// <summary>SQLite 数据库文件路径;留空则用 DataDirectory/wpywmail.db。</summary>
|
||||
public string DatabasePath { get; set; } = "";
|
||||
|
||||
/// <summary>WAL 模式下定期检查点阈值(页数),0 表示交给 SQLite 默认策略。</summary>
|
||||
public int WalAutoCheckpointPages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为正文建立 FTS5(trigram)全文索引。
|
||||
/// 打开后搜索从「全表 LIKE 扫描」变成索引命中,代价是**索引本身会额外占用接近正文大小的磁盘**
|
||||
/// (trigram 索引通常与正文同量级)。默认关闭,因为本机磁盘偏紧、而 LIKE 在数千封量级仍是毫秒级。
|
||||
/// </summary>
|
||||
public bool FullTextSearch { get; set; }
|
||||
}
|
||||
|
||||
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; } = "";
|
||||
/// <summary>投递时使用的 HELO 名称,留空则用 Hostname。</summary>
|
||||
public string HeloName { 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;
|
||||
}
|
||||
|
||||
/// <summary>失败重投策略。4xx(临时)与 5xx(永久)分开处理。</summary>
|
||||
public sealed class RetryConfig
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 12;
|
||||
public int InitialDelaySeconds { get; set; } = 60;
|
||||
public int MaxDelaySeconds { get; set; } = 3600;
|
||||
/// <summary>5xx 默认也重试若干次:封锁/策略类 5xx 往往是临时的。</summary>
|
||||
public bool RetryOnPermanentFailure { get; set; } = true;
|
||||
public int MaxAttemptsForPermanent { get; set; } = 3;
|
||||
/// <summary>彻底失败时给发件人投递退信(NDR)。</summary>
|
||||
public bool SendBounceNotification { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>DKIM 签名配置。私钥不存在时会自动生成并打印需要配置的 DNS 记录。</summary>
|
||||
public sealed class DkimConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string Selector { get; set; } = "mail";
|
||||
/// <summary>留空则用 Domain。</summary>
|
||||
public string SigningDomain { get; set; } = "";
|
||||
/// <summary>留空则放在 DataDirectory/dkim/<selector>.private.pem。</summary>
|
||||
public string PrivateKeyPath { get; set; } = "";
|
||||
public string[] Headers { get; set; } =
|
||||
["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type", "Content-Transfer-Encoding"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入站邮件身份校验(SPF / DKIM / DMARC)与垃圾邮件判定。
|
||||
///
|
||||
/// 默认策略:**标注 + 投垃圾箱,不拒收** —— 校验实现自身也可能有 bug,拒收不可逆,
|
||||
/// 投进垃圾箱可逆。要严格拒收把 <see cref="RejectOnDmarcReject"/> 打开。
|
||||
/// </summary>
|
||||
public sealed class InboundAuthConfig
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
/// <summary>是否往报文里写 Authentication-Results / X-Spam-Score 头(标准做法,保留证据)。</summary>
|
||||
public bool AddAuthenticationResults { get; set; } = true;
|
||||
/// <summary>判定为垃圾时投进 spam 文件夹而不是收件箱。</summary>
|
||||
public bool SpamFolderOnFail { get; set; } = true;
|
||||
/// <summary>DMARC p=reject 且校验失败时直接在 SMTP 阶段 550 拒收。默认关(怕误杀)。</summary>
|
||||
public bool RejectOnDmarcReject { get; set; } = false;
|
||||
/// <summary>DKIM 验签(含 DNS 取公钥)开关;关掉只做 SPF/DMARC 的 SPF 部分。</summary>
|
||||
public bool VerifyDkim { get; set; } = true;
|
||||
/// <summary>判为垃圾的分数阈值(DMARC 失败固定 +4)。</summary>
|
||||
public int SpamScoreThreshold { get; set; } = 3;
|
||||
public int DnsTimeoutSeconds { get; set; } = 5;
|
||||
/// <summary>SPF 的 DNS 查询次数上限(RFC 7208 规定 10)。</summary>
|
||||
public int MaxSpfLookups { get; set; } = 10;
|
||||
}
|
||||
|
||||
public sealed class ApiConfig
|
||||
{
|
||||
public int SessionDays { get; set; } = 30;
|
||||
/// <summary>允许的跨域来源;默认 * 便于本机客户端调试,公网使用建议收紧。</summary>
|
||||
public string CorsOrigin { get; set; } = "*";
|
||||
/// <summary>推送新邮件的长轮询上限(秒)。</summary>
|
||||
public int LongPollSeconds { get; set; } = 25;
|
||||
/// <summary>
|
||||
/// 可选的公网 HTTPS 前缀(例如 <c>https://mail.example.com:9443/</c>),只为客户端在公网
|
||||
/// 自助注册 / 找回密码 / 管理会话资料而开。**只放行账号类接口**,邮件读写与管理接口不在这里暴露。
|
||||
/// 留空 = 不开(默认)。HTTPS 前缀必须先绑定证书:<c>netsh http add sslcert hostnameport=mail.example.com:9443 ...</c>
|
||||
/// </summary>
|
||||
public string PublicPrefix { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>IMAP 服务配置(让标准邮件客户端也能接入)。</summary>
|
||||
public sealed class ImapConfig
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
/// <summary>143:明文 + STARTTLS。</summary>
|
||||
public int Port { get; set; } = 143;
|
||||
/// <summary>993:隐式 TLS。设为 0 表示不监听。</summary>
|
||||
public int TlsPort { get; set; } = 993;
|
||||
/// <summary>是否要求先建立 TLS 才允许 LOGIN(推荐 true)。</summary>
|
||||
public bool RequireTlsForLogin { get; set; } = true;
|
||||
/// <summary>允许未加密登录的来源地址(默认仅本机,便于自检/调试)。</summary>
|
||||
public string[] PlaintextLoginAllowFrom { get; set; } = ["127.0.0.1", "::1"];
|
||||
}
|
||||
|
||||
public sealed class SmtpConfig
|
||||
{
|
||||
/// <summary>单封邮件最大字节数。</summary>
|
||||
public int MaxMessageBytes { get; set; } = 25 * 1024 * 1024;
|
||||
/// <summary>是否始终广告 STARTTLS(只要加载到证书就广告,含自签名)。</summary>
|
||||
public bool AdvertiseStartTls { get; set; } = true;
|
||||
/// <summary>25 端口也允许 AUTH(默认否;587 端口始终允许)。</summary>
|
||||
public bool AllowAuthOnInbound { get; set; }
|
||||
/// <summary>给收到的邮件补 Received 头。</summary>
|
||||
public bool AddReceivedHeader { get; set; } = true;
|
||||
/// <summary>同一 IP 连续认证失败多少次后临时封禁。</summary>
|
||||
public int AuthFailuresBeforeBan { get; set; } = 8;
|
||||
public int BanMinutes { get; set; } = 15;
|
||||
/// <summary>已认证用户是否必须使用自己的地址作为发件人。</summary>
|
||||
public bool EnforceSenderMatch { 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 DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Attachment
|
||||
{
|
||||
public string FileName { get; set; } = "";
|
||||
public string ContentType { get; set; } = "application/octet-stream";
|
||||
public long Size { get; set; }
|
||||
/// <summary>相对 DataDirectory 的存储路径,例如 attachments/xxx.bin。</summary>
|
||||
public string StoredAs { get; set; } = "";
|
||||
public string ContentId { get; set; } = "";
|
||||
public bool Inline { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MailMessage
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string OwnerEmail { get; set; } = "";
|
||||
/// <summary>inbox / sent / drafts / archive / trash / spam</summary>
|
||||
public string Folder { get; set; } = "inbox";
|
||||
public string From { get; set; } = "";
|
||||
public string To { get; set; } = "";
|
||||
public string Cc { get; set; } = "";
|
||||
public string Subject { get; set; } = "(无主题)";
|
||||
public string Text { get; set; } = "";
|
||||
public string Html { get; set; } = "";
|
||||
public string RawPath { get; set; } = "";
|
||||
public string MessageId { get; set; } = "";
|
||||
public string InReplyTo { get; set; } = "";
|
||||
public string References { get; set; } = "";
|
||||
public DateTimeOffset Date { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset ReceivedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public bool Unread { get; set; } = true;
|
||||
public bool Starred { get; set; }
|
||||
/// <summary>IMAP UID:在同一文件夹内单调递增且稳定,首次入库时分配。</summary>
|
||||
public int Uid { get; set; }
|
||||
/// <summary>received / queued / sent / failed</summary>
|
||||
public string DeliveryStatus { get; set; } = "received";
|
||||
public string LastError { get; set; } = "";
|
||||
public long Size { get; set; }
|
||||
public List<Attachment> Attachments { get; set; } = [];
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
/// <summary>DKIM 是否签名成功(发件侧)。</summary>
|
||||
public bool DkimSigned { get; set; }
|
||||
}
|
||||
|
||||
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 CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset NextAttempt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? LastAttemptAt { get; set; }
|
||||
/// <summary>pending / processing / retry / sent / failed</summary>
|
||||
public string Status { get; set; } = "pending";
|
||||
public string LastError { get; set; } = "";
|
||||
public int LastCode { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SessionRecord
|
||||
{
|
||||
public string Token { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public DateTimeOffset Expires { get; set; } = DateTimeOffset.UtcNow.AddDays(30);
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(string Email, string Password);
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 账号体系
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱验证码(注册激活 / 密码重置共用一个表)。
|
||||
///
|
||||
/// 只存**验证码的哈希**,不存明文 —— 数据库被人拿到也不能直接拿来激活账号或改密码。
|
||||
/// 注册场景下,密码的哈希与显示名先暂存在 Payload 里,验证通过后才真正建号,
|
||||
/// 这样「未验证的注册」不会在用户表里留下垃圾数据。
|
||||
/// </summary>
|
||||
public sealed class VerificationCode
|
||||
{
|
||||
public string Email { get; set; } = "";
|
||||
/// <summary>register = 注册激活;reset = 重置密码。</summary>
|
||||
public string Purpose { get; set; } = "register";
|
||||
public string CodeHash { get; set; } = "";
|
||||
public string Salt { get; set; } = "";
|
||||
/// <summary>register 时是 JSON:{ displayName, passwordHash, passwordSalt }。</summary>
|
||||
public string Payload { get; set; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
public int Attempts { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? SentAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 认证事件审计:登录成功/失败、注册、验证码发送与校验、密码重置、会话吊销。
|
||||
/// 用途有三个:排查问题、登录锁定判定、按 IP/邮箱做限流。
|
||||
/// </summary>
|
||||
public sealed class AuthEvent
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Email { get; set; } = "";
|
||||
public string Ip { get; set; } = "";
|
||||
/// <summary>login-ok / login-failed / login-locked / register / register-verify / code-sent / reset-ok / session-revoked</summary>
|
||||
public string Reason { get; set; } = "";
|
||||
public bool Success { get; set; }
|
||||
public string Detail { get; set; } = "";
|
||||
public string UserAgent { get; set; } = "";
|
||||
public DateTimeOffset At { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed record RegisterRequest(string Email, string Password, string? DisplayName = null, string? InviteCode = null);
|
||||
|
||||
public sealed record VerifyCodeRequest(string Email, string Code);
|
||||
|
||||
public sealed record ResetPasswordRequest(string Email, string Code, string Password);
|
||||
|
||||
public sealed record ProfileRequest(string? DisplayName = null);
|
||||
|
||||
/// <summary>附件上传:内容用 base64 传递。</summary>
|
||||
public sealed record AttachmentRequest(string FileName, string ContentType, string Base64);
|
||||
|
||||
public sealed record SendRequest(
|
||||
string To,
|
||||
string Subject,
|
||||
string? Text,
|
||||
string? Html = null,
|
||||
string? Cc = null,
|
||||
string? InReplyTo = null,
|
||||
List<AttachmentRequest>? Attachments = null);
|
||||
|
||||
public sealed record DraftRequest(string To, string Subject, string Text);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
# WpywMail.Native v2
|
||||
|
||||
自建中文邮件服务(Windows / .NET 8,单文件进程,零外部依赖)。
|
||||
本目录是 v2 重写版本;v1 原样保留在 `../server-native`,部署现场备份在 `C:\Program Files\WpywMail.v1-backup`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 它做什么
|
||||
|
||||
```
|
||||
┌──────────────── WpywMail.Native.exe ────────────────┐
|
||||
公网 25 ─────────►│ SmtpServer 收信(只收本地收件人,绝不中继) │
|
||||
客户端 587 ──────►│ SmtpServer 发信(STARTTLS + AUTH 后提交) │
|
||||
Webmail/客户端 ──►│ ApiServer REST + 长轮询(默认仅监听 127.0.0.1) │
|
||||
│ DeliveryQueue 出站队列:DKIM 签名 → MX 直投/中继 │
|
||||
│ FileStore users/messages/queue/sessions + raw/ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `Program.cs` | 入口、配置校验、启动/停止、`--selftest` / `--check-config` / `--migrate` |
|
||||
| `Models.cs` | 配置与数据模型 |
|
||||
| `Mime.cs` | MIME 组装与解析(RFC 5322 / 2047 / 2231、base64、QP、multipart、附件、字符集) |
|
||||
| `Dkim.cs` | DKIM 签名(RFC 6376,rsa-sha256,relaxed/relaxed,自动生成密钥并打印 DNS 记录) |
|
||||
| `SmtpServer.cs` | 25 收信 / 587 提交服务端 |
|
||||
| `SmtpReader.cs` | 字节级 SMTP 行读取器(命令与 DATA 共用缓冲区,保证 8bit 正文不被破坏) |
|
||||
| `SmtpDataEncoder.cs` | DATA 段编码/还原(行尾规范化、dot-stuffing),与 DKIM 签名顺序严格配合 |
|
||||
| `DirectSmtpDelivery.cs` | 出站投递(MX 直投 / relay 中继)+ 极简 DNS MX 解析 |
|
||||
| `DeliveryQueue.cs` | 出站队列:重试、退避、退信通知 |
|
||||
| `FileStore.cs` | 文件存储与全部状态变更 |
|
||||
| `ApiServer.cs` | REST API(Webmail 与桌面客户端共用) |
|
||||
| `Migration.cs` | 从 v1 数据一次性迁移/修复 |
|
||||
|
||||
---
|
||||
|
||||
## 2. v1 的问题与本版修法
|
||||
|
||||
| # | v1 问题(位置) | 后果 | v2 处理 |
|
||||
|---|---|---|---|
|
||||
| 1 | `DirectSmtpDelivery.cs:192` 用 `Encoding.ASCII` 的 StreamWriter 写 DATA | **所有非 ASCII 变 `?`**,中文彻底不可用 | 按字节写出;`SmtpDataEncoder` 只做 dot-stuffing |
|
||||
| 2 | `Mime.cs:28` 写死 `Message-ID: <[email protected]>` | 声明域 ≠ 发信域,垃圾邮件特征 | 取配置 `Domain` |
|
||||
| 3 | `Mime.Parse` 不解码 RFC 2047、不解 base64/QP 正文 | 收件显示 `=?utf-8?b?...?=`、正文乱码 | 完整解码(含量化可打印、软换行合并) |
|
||||
| 4 | 头部按 Latin-1 解码 | 裸 UTF-8 主题变 `[æµè¯]` | UTF-8 优先、失败回退 Latin-1 |
|
||||
| 5 | `SmtpServer.ReadData` 用 StreamReader 读文本再拼回 | 8bit 内容/行尾被破坏、O(n²) 长度统计、无上限 | 字节级读取 + 大小上限 + dot-unstuffing |
|
||||
| 6 | 自签名证书时不广告 STARTTLS,但 AUTH 又要求加密 | **587 完全死锁(538)** | 只要加载到证书就广告 STARTTLS;AUTH 在 TLS 后提供 |
|
||||
| 7 | 无 DKIM | 进垃圾箱 | 新增 DKIM 签名(含密钥生成与 DNS 记录输出) |
|
||||
| 8 | 4xx/5xx 一律按 8 次封顶,无退信 | 封锁类 5xx 直接丢信且无通知 | 可配置重试策略 + 彻底失败发退信 |
|
||||
| 9 | 无 multipart / 无附件 | 前端已在读 `attachments` 但后端没有 | 完整 multipart 收/发 + 附件存储与下载 |
|
||||
| 10 | 会话仅存内存 | 每次重启把客户端踢下线 | 会话落盘 `sessions.json` |
|
||||
| 11 | 无文件夹/星标/删除/队列接口 | 客户端只能读收件箱 | 补齐(见第 6 节) |
|
||||
| 12 | 签名后才改行尾 | DKIM 正文哈希对不上,签名失效 | **先规范化 → 再签名 → 传输不改字节**(自检覆盖) |
|
||||
|
||||
### 2.1 v2.0.0 自身的 DKIM 缺陷(2026-09-13 由外部验证器揪出,v2.0.1 修复)
|
||||
|
||||
| # | 问题(位置) | 后果 | 修法 |
|
||||
|---|---|---|---|
|
||||
| 13 | `Dkim.cs` 构造签名输入时,把 `DKIM-Signature` 头放在**最前面**且**多带一个结尾 CRLF** | 违反 RFC 6376 §3.7 第 2 步 → **Gmail / Outlook / port25 等所有合规验证器一律判 `dkim=fail`**,等于白签 | 改为:各被签名头按 `h=` 顺序、每个后跟一个 CRLF;`DKIM-Signature` 头**放最后且结尾不带 CRLF** |
|
||||
|
||||
**这条为什么差点被漏掉(重要教训)**:v2.0.0 的 `SelfTest.VerifyDkim` 与本机 Python 验签脚本
|
||||
当初都是**照着签名端的实现写的**,两边犯了同一个错,于是自检 38/38、加上手写验签脚本全部"通过"——
|
||||
属于典型的**假通过**。真正把它暴露出来的是**外部独立验证器**(把信发给
|
||||
`[email protected]`,报告里明确写着 `dkim=fail reason="signature doesn't verify"`,
|
||||
并且它打印的「Canonicalized Headers」里签名头的顺序与我们对不上,一眼看出顺序错误)。
|
||||
|
||||
因此本版把「验签」当成**独立实现**来写,并加了两道防线:
|
||||
|
||||
1. `SelfTest` 新增**反向对照**用例:故意用非规范顺序(签名头在前 + 带结尾 CRLF)重建签名输入,
|
||||
断言它**必须验不过**。若哪天验签器又被写成与签名端"同错",这项会立刻失败。
|
||||
2. `tools/verify_published_dkim.py`:只吃 **DNS 上已发布的 TXT**、不接触私钥,
|
||||
对真实投递报文按 RFC 6376 顺序验签 —— 直接复现收件方会看到的结果。
|
||||
|
||||
修好之后的实测证据(同一把 DNS 公钥、同一个验签器):
|
||||
|
||||
* v2.0.0 签的报文(`14:09` 落盘)→ **失败**(`DigestInfo 不匹配`)
|
||||
* v2.0.1 签的报文(`14:24` 落盘)→ **通过**
|
||||
* port25 外部验证器第三次报告 → `DKIM check: pass`,对方写入
|
||||
`Authentication-Results: ... dkim=pass (matches From: [email protected]) header.d=wpy.email`
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置(appsettings.json)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Domain": "wpy.email",
|
||||
"Hostname": "mail.example.com",
|
||||
"HttpPrefix": "http://127.0.0.1:8787/",
|
||||
"SmtpPort": 25,
|
||||
"SubmissionPort": 587,
|
||||
"DataDirectory": "C:\\WpywMailData",
|
||||
"AdminEmail": "wpy@wpy.email",
|
||||
"AdminPassword": "至少12位;会同步为该账号的登录密码",
|
||||
"TlsCertificatePath": "C:\\WpywMailData\\certs\\mail.example.com.pfx",
|
||||
"TlsCertificatePassword": "...",
|
||||
"DeliveryMode": "direct", // direct=MX直投 | relay=上游中继
|
||||
|
||||
"Storage": {
|
||||
"Provider": "sqlite", // sqlite(默认,推荐)| json(旧实现,可一键回滚)
|
||||
"DatabasePath": "", // 留空 = DataDirectory\\wpywmail.db
|
||||
"WalAutoCheckpointPages": 0, // 0 = 用 SQLite 默认(约 1000 页)
|
||||
"FullTextSearch": false // 见第 10 节:打开后搜索快约 20 倍,但索引要占正文量级的空间
|
||||
},
|
||||
|
||||
"DirectDelivery": {
|
||||
"ConnectionTimeoutSeconds": 30,
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"DnsTimeoutSeconds": 5,
|
||||
"OpportunisticStartTls": true, // 对方支持就加密,握手失败自动回退明文重连
|
||||
"RequireStartTls": false,
|
||||
"DnsServer": "", // 留空用系统 DNS
|
||||
"HeloName": "" // 留空用 Hostname
|
||||
},
|
||||
|
||||
"Relay": { "Host": "", "Port": 587, "User": "", "Password": "", "EnableSsl": true },
|
||||
|
||||
"Retry": {
|
||||
"MaxAttempts": 12,
|
||||
"InitialDelaySeconds": 60,
|
||||
"MaxDelaySeconds": 3600,
|
||||
"RetryOnPermanentFailure": true, // 5xx 也重试(封锁/策略类常是临时的)
|
||||
"MaxAttemptsForPermanent": 3,
|
||||
"SendBounceNotification": true // 彻底失败给发件人发退信
|
||||
},
|
||||
|
||||
"Dkim": {
|
||||
"Enabled": true,
|
||||
"Selector": "mail", // DNS 名:mail._domainkey.<Domain>
|
||||
"SigningDomain": "", // 留空用 Domain
|
||||
"PrivateKeyPath": "", // 留空 = DataDirectory/dkim/<selector>.private.pem
|
||||
"Headers": ["From","To","Subject","Date","Message-ID","MIME-Version","Content-Type","Content-Transfer-Encoding"]
|
||||
},
|
||||
|
||||
"Api": { "SessionDays": 30, "CorsOrigin": "*", "LongPollSeconds": 25 },
|
||||
|
||||
"Accounts": {
|
||||
"Registration": "invite", // open=自助注册 | invite=需要邀请码 | closed=关闭注册
|
||||
"InviteCode": "换成一串只有你知道的随机串",
|
||||
"AllowedDomains": ["wpy.email"], // 允许注册的域名;留空 = 只允许本机域
|
||||
"RequireEmailVerification": true, // ⚠ 本机托管的域名会自动跳过,见第 6.2.2 节
|
||||
"MinPasswordLength": 12,
|
||||
"CodeMinutes": 30, // 验证码有效期
|
||||
"MaxCodeAttempts": 5,
|
||||
"MaxLoginFailures": 8, // 连续失败多少次后临时锁定
|
||||
"LockoutMinutes": 15,
|
||||
"RegisterPerHourPerIp": 5, // 严格配额只算「真的建出的账号」
|
||||
"ResendPerHourPerEmail": 5,
|
||||
"AuditLimit": 2000 // 单账号保留的审计条数
|
||||
},
|
||||
|
||||
"InboundAuth": {
|
||||
"Enabled": true, // 收信时做 SPF/DKIM/DMARC 校验
|
||||
"AddAuthenticationResults": true, // 把结论写进报文的 Authentication-Results 头(标准做法)
|
||||
"SpamFolderOnFail": true, // 判定为垃圾 → 投 spam 文件夹(可逆)
|
||||
"RejectOnDmarcReject": false, // DMARC p=reject 且失败时直接 550 拒收(默认关:拒收不可逆)
|
||||
"VerifyDkim": true, // DKIM 验签(要查发件域 DNS 公钥;公钥发布成 CNAME 也会跟)
|
||||
"SpamScoreThreshold": 3, // 判垃圾的分数阈值(DMARC 失败固定 +4)
|
||||
"DnsTimeoutSeconds": 5,
|
||||
"MaxSpfLookups": 10 // RFC 7208 规定 10
|
||||
},
|
||||
|
||||
"Smtp": {
|
||||
"MaxMessageBytes": 26214400,
|
||||
"AdvertiseStartTls": true,
|
||||
"AllowAuthOnInbound": false, // 25 端口默认不允许认证
|
||||
"AddReceivedHeader": true,
|
||||
"AuthFailuresBeforeBan": 8,
|
||||
"BanMinutes": 15,
|
||||
"EnforceSenderMatch": true // 已认证用户必须用自己的地址发件
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建与部署
|
||||
|
||||
```powershell
|
||||
# 构建 + 自检(202 项;含 DKIM 可验证性、传输一致性、存储双后端契约、账号体系全套)
|
||||
dotnet build -c Release
|
||||
.\bin\Release\net8.0\win-x64\WpywMail.Native.exe --selftest
|
||||
|
||||
# 发布自包含版本(目标机无需安装 .NET)
|
||||
dotnet publish -c Release -r win-x64 --self-contained true -o ..\work\publish-v2
|
||||
```
|
||||
|
||||
增量部署(只换主程序集,不动配置与数据):
|
||||
|
||||
```powershell
|
||||
# ⚠ 顺序不能变:Stop-ScheduledTask 会触发任务的失败重启策略,把服务又拉起来并锁住 DLL
|
||||
Disable-ScheduledTask -TaskName WpywMail
|
||||
Stop-ScheduledTask -TaskName WpywMail
|
||||
Get-Process -Name 'WpywMail.Native' -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Copy-Item .\bin\Release\net8.0\win-x64\WpywMail.Native.dll 'C:\Program Files\WpywMail\WpywMail.Native.dll' -Force
|
||||
Enable-ScheduledTask -TaskName WpywMail
|
||||
Start-ScheduledTask -TaskName WpywMail
|
||||
```
|
||||
|
||||
部署(计划任务名 `WpywMail`,动作指向 `C:\Program Files\WpywMail\WpywMail.Native.exe`):
|
||||
|
||||
```powershell
|
||||
Stop-ScheduledTask -TaskName WpywMail
|
||||
Copy-Item ..\work\publish-v2\* 'C:\Program Files\WpywMail\' -Recurse -Force # 运行时首次需全量,之后只需 *.dll/*.exe
|
||||
Start-ScheduledTask -TaskName WpywMail
|
||||
```
|
||||
|
||||
**回滚**:删掉 `C:\Program Files\WpywMail`,把 `C:\Program Files\WpywMail.v1-backup` 改回来,重启计划任务。
|
||||
|
||||
维护命令:
|
||||
|
||||
```powershell
|
||||
WpywMail.Native.exe --version # 版本
|
||||
WpywMail.Native.exe --check-config # 校验配置并打印关键项(含账号策略与「本机域免验证」提醒)
|
||||
WpywMail.Native.exe --selftest # 202 项自检(中文编解码、附件、DKIM、传输一致性、存储双后端契约、账号体系)
|
||||
WpywMail.Native.exe --migrate # 从 v1 数据迁移:重解析 raw/*.eml 修正主题/正文/附件、改写旧域名归属
|
||||
```
|
||||
|
||||
### 4.1 真机验收脚本(账号体系)
|
||||
|
||||
`tools/account-acceptance.ps1` —— 在邮件服务器本机上跑,打**真实 HTTP API + 真实 IMAP(993)**:
|
||||
|
||||
```powershell
|
||||
# 上传后在服务器上执行(脚本必须存成「UTF-8 带 BOM」,否则 PowerShell 5.1 会把中文按 GBK 解)
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1
|
||||
# 退出码 = 失败项数;报告默认写到 C:\Windows\Temp\wpyw-acct-verify.txt(UTF-8)
|
||||
```
|
||||
|
||||
覆盖 53 项:策略接口 → 邀请码/域名/弱密码拦截 → 注册即开通 → 会话/资料/审计 →
|
||||
真实 IMAP 登录与收件箱计数 → 自己发信并被本地投递 → 忘记密码(**真去邮箱里读验证码**)→ 重置 →
|
||||
改密踢其他会话 → 登录失败锁定(423 + retryAfterSeconds)→ 管理员视角 → 停用与「不能靠重新注册复活」。
|
||||
|
||||
⚠ 脚本一小时内重复跑会撞到 `RegisterPerHourPerIp` 配额:此时它会把自助注册那几项标成 `[SKIP]`
|
||||
并改用管理员接口建号,其余用例照常跑完(断言的是产品行为,不是配额余量)。
|
||||
测试会留下两个 `selftest-*@` / `locktest-*@` 账号,脚本结束前会**停用**它们。
|
||||
|
||||
---
|
||||
|
||||
## 5. DNS 配置(决定能否进收件箱)
|
||||
|
||||
以 `wpy.email` + IP `<SERVER_IP>` 为例:
|
||||
|
||||
| 类型 | 名称 | 值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| A | `mail` | `<SERVER_IP>` | **必须灰云(DNS only)**,MX 指向的主机不能走代理 |
|
||||
| MX | `@` | `mail.example.com`(优先级 10) | |
|
||||
| TXT | `@` | `v=spf1 ip4:<SERVER_IP> -all` | 注意是**半角**冒号;`-all` 比 `~all` 严格 |
|
||||
| TXT | `mail._domainkey` | 服务启动日志里打印的 `v=DKIM1; k=rsa; p=...` | 一字不能改,Cloudflare 会自动分段 |
|
||||
| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:[email protected]` | 先 `p=none` 观察,再收紧 |
|
||||
| PTR | `56.55.236.103`(反向) | `mail.example.com` | **只能由 IDC 设置**,无 PTR 时大厂几乎必判垃圾 |
|
||||
|
||||
---
|
||||
|
||||
## 6. REST API
|
||||
|
||||
- 基址:`config.HttpPrefix`(默认 `http://127.0.0.1:8787/`,仅供反向代理/本机访问)
|
||||
- 认证:`POST /api/login` 换 `token`,之后所有请求带 `Authorization: Bearer <token>`
|
||||
- 编码:请求与响应均为 UTF-8 JSON;错误统一为 `{ "error": "说明" }`
|
||||
- 会话有效期 `Api.SessionDays` 天,落盘保存,重启不掉线
|
||||
- 所有路径保持 v1 兼容(`/api/login`、`/api/messages`、`/api/send`、`/api/config`、`/api/me`、`/api/logout`、`/api/account/password`、`/api/admin/users`)
|
||||
|
||||
### 6.1 无需认证
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/health` | `{ ok, service, version, domain, hostname }` |
|
||||
| GET | `/api/version` | `{ version, domain, hostname, dkim }` |
|
||||
| POST | `/api/login` | 入参 `{ email, password }` → `{ token, expiresAt, user }` |
|
||||
|
||||
### 6.2 账号
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/me` | `{ user, stats }`;stats 含 inbox/unread/starred/drafts/sent/trash/queue/failed |
|
||||
| GET | `/api/config` | 域名、主机名、协议端口、特性开关(客户端据此决定是否显示附件/草稿入口) |
|
||||
| POST | `/api/logout` | 使当前 token 失效 |
|
||||
| POST | `/api/account/password` | `{ password, currentPassword? }`(新密码 ≥ 12 位);成功后**吊销其他会话**,返回 `{ ok, revokedSessions }` |
|
||||
| PATCH | `/api/account/profile` | `{ displayName }`(≤64 字)→ `{ ok, user }` |
|
||||
| GET | `/api/account/sessions` | `{ sessions: [{ tokenPrefix, token, current, createdAt, expiresAt }] }` |
|
||||
| POST | `/api/account/sessions/revoke` | 空体/`{}` = 退出其他设备(保留当前);`{ all: true }` = 全部退出;`{ token }` = 指定会话 |
|
||||
| GET | `/api/account/audit?limit=50` | `{ events: [{ at, email, ip, reason, success, detail, userAgent }] }`(自己的登录/改密/注册轨迹) |
|
||||
|
||||
### 6.2.1 自助注册与密码找回(v2.2 新增,无需认证)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/auth/policy` | 注册策略:`{ registration, inviteRequired, requireEmailVerification, minPasswordLength, allowedDomains, codeMinutes, maxLoginFailures, lockoutMinutes, selfHostedDomain, verificationNote }` |
|
||||
| POST | `/api/register` | `{ email, password, displayName?, inviteCode? }` → `201 { ok, verificationRequired:false, session }`(直接开通)或 `202 { ok, verificationRequired:true, email, expiresInMinutes }`(等验证码) |
|
||||
| POST | `/api/register/verify` | `{ email, code }` → `201 { ok, session }`(验证通过即登录) |
|
||||
| POST | `/api/register/resend` | `{ email, purpose:"register"\|"reset" }` → `{ ok }` |
|
||||
| POST | `/api/auth/forgot` | `{ email }` → 恒返回 `200 { ok, expiresInMinutes }`(**不暴露邮箱是否存在**;命中则发验证码邮件) |
|
||||
| POST | `/api/auth/reset` | `{ email, code, password }` → `{ ok }`;成功后**吊销该账号全部会话**;若账号此前未激活(注册没验证完)顺带激活 |
|
||||
|
||||
登录失败的状态码(客户端要按码分支,别只看文案):
|
||||
|
||||
| 码 | 何时 | 响应 |
|
||||
|---|---|---|
|
||||
| `401` | 账号不存在 / 密码错 / **账号被停用** | `{ error: "邮箱或密码不正确" }`(一句话,不区分原因,防账号枚举) |
|
||||
| `403` | 账号存在但**注册的邮箱验证没做完** | `{ error: "...请用验证码完成验证,或用「忘记密码」重设密码", pendingVerification: true }` |
|
||||
| `423` | 连续失败达到 `maxLoginFailures` 后的锁定期 | `{ error, retryAfterSeconds }`(客户端应显示倒计时并禁用提交) |
|
||||
|
||||
### 6.2.2 两条必须知道的设计决策(踩过坑才定下来的)
|
||||
|
||||
**① 本机托管的邮箱注册后免邮箱验证码。**
|
||||
理由是个死循环:验证码邮件只能投进「这个」信箱,而这个信箱在验证通过前不允许登录(IMAP / Webmail / API 全进不去),
|
||||
用户永远拿不到码。所以域名等于本服务器自己的域(`config.Domain` / `config.Hostname` 的域)时,
|
||||
`RequireEmailVerification` 会被自动跳过,注册授权凭据是**邀请码**(管理员发放)。
|
||||
`GET /api/auth/policy` 的 `verificationNote` 会把这件事讲给用户听;`--check-config` 也会打印同一提醒。
|
||||
要让邮箱验证真正生效,得把 `Accounts.AllowedDomains` 换成托管在别处的域名(如 `gmail.com`)。
|
||||
判定逻辑:`AccountService.IsHostedDomain(email, Domain, Hostname)`。
|
||||
|
||||
**② 未激活的账号先建信箱行,但密码只存在验证码记录里。**
|
||||
注册时就把用户行建出来(`active=0`,密码是随机不可用值),否则本地投递看不到收件人、验证码邮件会被静默丢弃;
|
||||
真正的密码哈希随验证码一起存进验证码记录的 Payload,**验证码通过那一刻才写进用户行并激活**。
|
||||
于是「谁能读到验证码,谁才能决定这个账号的密码」—— 抢注者单独无法在别人的邮箱上留下自己的密码。
|
||||
未激活的账号不能登录 IMAP/SMTP/API(各处 `FindUser` 只取 `active=1`),但**能收信**(投递用 `FindUserAnyState`)。
|
||||
|
||||
其它已实现的加固:注册/登录限流(严格配额只算**真的建出的账号**,填错表单不吃配额,另有宽松上限防邀请码爆破)、
|
||||
验证码只存 PBKDF2 哈希 + 试错上限 + 过期、审计表(可按账号/IP/原因查询)、
|
||||
**管理员停用是权威状态**(被停用的账号不能靠重新注册复活)、改密/重置后踢掉其他会话。
|
||||
|
||||
### 6.3 邮件
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/messages` | 查询:`folder`(inbox/sent/drafts/archive/trash/spam,留空=全部)、`q`、`unread=1`、`starred=1`、`limit`(≤500,默认 100)、`offset` → `{ total, offset, limit, messages[] }` |
|
||||
| GET | `/api/messages/{id}` | 详情;查询 `markRead=false` 可只看不改已读状态 |
|
||||
| PATCH | `/api/messages/{id}` | `{ unread?, read?, starred?, folder? }`(`folder` 即移动/归档/删除到垃圾箱) |
|
||||
| DELETE | `/api/messages/{id}` | 默认移入垃圾箱;`?permanent=true` 彻底删除(同时删除原始报文与附件文件) |
|
||||
| GET | `/api/messages/{id}/raw` | 下载原始 `.eml` |
|
||||
| GET | `/api/messages/{id}/attachments/{index}` | 下载附件(`Content-Disposition` 为 RFC 5987 编码,中文名正常) |
|
||||
| POST | `/api/send` | `{ to, subject, text, html?, cc?, inReplyTo?, attachments?[] }` → `202 { queued, messageId, recipients, cc }` |
|
||||
| POST | `/api/drafts` | `{ to, subject, text }` → `201`,存入 drafts |
|
||||
|
||||
`messages[]` 摘要字段:`id, folder, from, to, cc, subject, date, receivedAt, unread, starred, deliveryStatus, lastError, size, attachmentCount, hasAttachments, preview`。
|
||||
|
||||
详情额外含:`text, html, messageId, inReplyTo, references, dkimSigned, attachments[{ index, fileName, contentType, size, inline, url }], rawUrl`。
|
||||
|
||||
`attachments` 元素格式:`{ fileName, contentType, base64 }`(单封上限见 `Smtp.MaxMessageBytes`)。
|
||||
|
||||
### 6.4 出站队列
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/queue` | `{ queue: [{ id, messageId, recipients, attempts, status, lastError, lastCode, nextAttempt, createdAt, lastAttemptAt }] }` |
|
||||
| POST | `/api/queue/{id}/retry` | 立即重试(重置 attempts 与退避) |
|
||||
|
||||
`status`:`pending` / `processing` / `retry` / `sent` / `failed`。
|
||||
|
||||
### 6.5 新邮件推送(长轮询)
|
||||
|
||||
```
|
||||
GET /api/watch?since=<version>
|
||||
→ 挂起至多 Api.LongPollSeconds 秒,直到数据发生变化
|
||||
→ { version, changed, stats }
|
||||
```
|
||||
|
||||
客户端流程:启动时 `version = (await /api/watch?since=0).version`,之后循环
|
||||
`/api/watch?since=<上次的 version>`;`changed=true` 时刷新列表,并把返回的 `version` 作为下次的 `since`。
|
||||
|
||||
### 6.6 管理员
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/users` | 用户列表(含 active / createdAt / lastLoginAt) |
|
||||
| POST | `/api/admin/users` | `{ email, password(≥12), displayName? }` → `201` |
|
||||
| PATCH | `/api/admin/users/{email}` | `{ active? }` 或 `{ password? }` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 客户端接入要点
|
||||
|
||||
**登录**
|
||||
|
||||
```http
|
||||
POST /api/login {"email":"wpy@wpy.email","password":"..."}
|
||||
→ 200 {"token":"...","expiresAt":"...","user":{"email":"...","displayName":"...","role":"admin","domain":"wpy.email"}}
|
||||
```
|
||||
之后每个请求加 `Authorization: Bearer <token>`;收到 `401` 即视为需要重新登录(本地清 token)。
|
||||
|
||||
**SMTP 客户端(手机/电脑上的邮件 App)**:`587` + STARTTLS + 普通密码认证(`AUTH LOGIN/PLAIN`),发件人必须等于登录账号;`25` 端口只用于收信,不接受认证与中继。
|
||||
|
||||
**WinUI 客户端建议**:走 REST + `/api/watch` 长轮询即可,无需实现 IMAP/SMTP;
|
||||
若要支持系统级邮件 App,则需另加 IMAP(v2 尚未实现,见第 8 节)。
|
||||
|
||||
**错误处理**:`400` 参数问题、`401` 未登录、`403` 权限/密码错误、`404` 对象不存在、`405` 方法不支持、`500` 服务端异常;响应体固定为 `{ error }`。
|
||||
|
||||
**并发**:长轮询会占用一个连接,建议单个客户端同时最多 1 个 watch 请求。
|
||||
|
||||
---
|
||||
|
||||
## 8. 用标准邮件客户端接入(IMAP)
|
||||
|
||||
v2 内置 IMAP4rev1 服务端,Thunderbird / Outlook / Foxmail / 手机邮件 App 可直接连接。
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| 明文 + STARTTLS | 端口 `143` |
|
||||
| 隐式 TLS | 端口 `993` |
|
||||
| 用户名 | 完整邮箱地址,如 `[email protected]` |
|
||||
| 密码 | `appsettings.json` 里的 `AdminPassword` |
|
||||
| 发信(SMTP) | `587` + STARTTLS + 同一套账号密码 |
|
||||
| 收信(SMTP) | 无需配置(`25` 由公网直接投递) |
|
||||
|
||||
文件夹映射(客户端里显示名 → 内部名):
|
||||
`INBOX`→inbox、`Sent`→sent、`Drafts`→drafts、`Archive`→archive、`Trash`→trash、`Junk`→spam。
|
||||
中文名(收件箱/已发送/草稿/归档/垃圾箱/垃圾邮件)也可被识别。
|
||||
|
||||
已实现的命令:`CAPABILITY`、`NOOP`、`LOGOUT`、`STARTTLS`、`LOGIN`、`AUTHENTICATE PLAIN`、
|
||||
`LIST`/`LSUB`、`SELECT`/`EXAMINE`、`STATUS`、`CLOSE`、`UNSELECT`、`EXPUNGE`、`SEARCH`、
|
||||
`FETCH`/`UID FETCH`、`STORE`/`UID STORE`、`COPY`/`UID COPY`、`APPEND`、`IDLE`、
|
||||
`CREATE`/`DELETE`/`RENAME`/`SUBSCRIBE`(接受但文件夹集合固定)。
|
||||
未实现(不影响常规使用):`SORT`、`THREAD`、`CONDSTORE`、`QRESYNC`、`ACL`。
|
||||
|
||||
行为约定:
|
||||
|
||||
- `RequireTlsForLogin=true` 时,**必须先 STARTTLS 或用 993** 才能 LOGIN;仅 `PlaintextLoginAllowFrom` 列表内的地址(默认本机)可明文登录。
|
||||
- `SELECT` 后 `FETCH` 的序号按 **UID 升序**(稳定顺序)。
|
||||
- 非 `PEEK` 的正文请求会把邮件标记为已读;`\Flagged` ↔ 星标;`\Deleted` 在 `EXPUNGE` 时生效(inbox 等移入垃圾箱,垃圾箱内则彻底删除)。
|
||||
- `EXPUNGE` 的序号会随删除动态变化(符合 RFC 3501)。
|
||||
- 自签名证书下客户端会提示证书不受信;换成受信任证书后即无提示。
|
||||
|
||||
配置示例(appsettings.json):
|
||||
|
||||
```jsonc
|
||||
"Imap": {
|
||||
"Enabled": true,
|
||||
"Port": 143,
|
||||
"TlsPort": 993,
|
||||
"RequireTlsForLogin": true,
|
||||
"PlaintextLoginAllowFrom": ["127.0.0.1", "::1"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 存储后端(SQLite / JSON)
|
||||
|
||||
### 为什么要有这一节
|
||||
|
||||
v2.0.x 只有一种存储:`users.json / messages.json / queue.json / sessions.json` 四个文件,
|
||||
**任何一次改动都会把全部邮件重新序列化并整文件重写**。把一封邮件标记为已读也是 O(N),
|
||||
UID 分配、未读计数、统计、搜索全是全表扫描,而且全部邮件常驻内存。
|
||||
|
||||
v2.1 起默认使用 **SQLite**(元数据进库 + 索引 + 事务),原始报文与附件以
|
||||
**gzip 压缩 + 按内容 SHA256 去重**的形式存进 `blobs` 表 —— **不再写 `raw/` 文件**。
|
||||
|
||||
### 基准数据(同一份负载,`--bench-store`)
|
||||
|
||||
| 指标 | JSON(整文件重写) | SQLite | 结论 |
|
||||
|---|---|---|---|
|
||||
| 入库 每封 @400 封 | 3.74 ms | 0.27 ms | **快 14×** |
|
||||
| 入库 每封 @2000 封 | 9.94 ms | 0.36 ms | **快 28×**(JSON 随规模劣化) |
|
||||
| 标记已读 每次 @2000 | 16.50 ms | 0.10 ms | **快 167×** |
|
||||
| 收件箱首页(50 条) | 0.42 ms | 0.97 ms | 都在毫秒级 |
|
||||
| 未读数 | 0.13 ms | 0.06 ms | 快 2× |
|
||||
| 统计 | 0.17 ms | 0.18 ms | 持平 |
|
||||
| 搜索(LIKE 扫描) | 6.27 ms | 7.30 ms | 持平;**开 FTS5 后 0.35 ms(快 18×)** |
|
||||
| 常驻内存增量 @2000 | 24.5 MB | 7.5 MB | **省 69%** |
|
||||
| 磁盘 @2000 | 10.56 MB | 7.03 MB | **省 33%**(raw 808 KB→库内 273 KB) |
|
||||
|
||||
关键不是单点快多少,而是**增长曲线**:JSON 的单次改动成本随邮件数线性上涨(400→2000 封时
|
||||
每封入库从 3.7 ms 涨到 9.9 ms),SQLite 基本恒定。
|
||||
|
||||
### 命令
|
||||
|
||||
```powershell
|
||||
WpywMail.Native.exe --storage-status # 当前后端、各表占用、大对象压缩率、有无孤儿
|
||||
WpywMail.Native.exe --migrate-to-sqlite # JSON → SQLite(逐封 SHA256 校验,默认保留源文件)
|
||||
WpywMail.Native.exe --migrate-to-sqlite --delete-source # 校验通过后删除 raw/ 与 attachments/ 文件
|
||||
WpywMail.Native.exe --migrate-to-json # SQLite → JSON(回滚用,把 blobs 还原成文件)
|
||||
WpywMail.Native.exe --compact # 清理没有引用的大对象
|
||||
WpywMail.Native.exe --vacuum # 合并 WAL + 回收空闲页
|
||||
WpywMail.Native.exe --bench-store 2000 # 两套后端的基准对比
|
||||
```
|
||||
|
||||
### 迁移与回滚
|
||||
|
||||
1. 停服务(**先 `Disable-ScheduledTask WpywMail`**,否则任务的失败重启策略会把它拉起来、锁住 DLL);
|
||||
2. 备份 `C:\WpywMailData`;
|
||||
3. `--migrate-to-sqlite --delete-source`:迁移会**逐封比对 SHA256**,任何一封不一致就中止并保留源文件;
|
||||
4. 改 `Storage.Provider` 为 `sqlite`(默认值即是),启服务。
|
||||
|
||||
**回滚**:把 `Storage.Provider` 改回 `json` 即可 —— 四个 JSON 索引文件在迁移时**故意保留**。
|
||||
若源文件已被 `--delete-source` 删除导致 JSON 侧缺 `raw/`,先跑 `--migrate-to-json`
|
||||
把 blobs 还原成文件,再切回 json。
|
||||
|
||||
### ⚠️ `--vacuum` 必须在停服时做
|
||||
|
||||
VACUUM 需要约 **2 倍**临时空间,且 **WAL 无法在别的连接持有数据库时截断** ——
|
||||
在服务运行时执行会把文件撑大而收不回来(实测 241 KB → 1.75 MB,停服重做后回到 274 KB)。
|
||||
|
||||
### 空间账(本机实测,66 封邮件)
|
||||
|
||||
```
|
||||
raw/ 0 B(0 个文件) ← 报文字节已压缩进库
|
||||
attachments/ 0 B(0 个文件)
|
||||
wpywmail.db 274,432 B(67 页 × 4096 B,空闲页 0)
|
||||
大对象 59 个:原始 88,917 B → 存储 49,986 B(57 个启用压缩)
|
||||
正文列合计 47,853 B(最大一封 8,908 B:port25 的 Authentication Report)
|
||||
```
|
||||
|
||||
平均约 4.2 KB/封(含索引)。索引不是免费的:`ux_messages_owner_folder_uid`、
|
||||
`ix_messages_owner_folder_date`、`ix_messages_owner_unread`、`ix_messages_owner_starred`、
|
||||
`ix_messages_owner_status` 都对应真实查询;早期版本建过两个没人用的索引(`message_id`、`raw_path`),
|
||||
已在建表语句里幂等 `DROP` 掉。
|
||||
|
||||
---
|
||||
|
||||
## 10. 全文检索开关(FTS5)
|
||||
|
||||
`Storage.FullTextSearch: true` 会建立 FTS5(trigram 分词器)索引,中文子串搜索走索引:
|
||||
|
||||
| | 搜索每次 @2000 封 | 占用 |
|
||||
|---|---|---|
|
||||
| `false`(默认) | 7.30 ms(`LIKE '%…%'` 全表扫描) | 7,028,736 B |
|
||||
| `true` | **0.35 ms** | 9,777,152 B(**索引多占 2.7 MB ≈ 1.4 KB/封**) |
|
||||
|
||||
默认关闭是因为本机磁盘偏紧;按 12.5 GB 可用空间算,索引成本要到**上百万封**才会成为问题,
|
||||
所以只要搜索体验优先,随时可以打开(改配置重启即可,首次启动会自动为历史邮件建索引)。
|
||||
|
||||
---
|
||||
|
||||
### 10.1 入站邮件身份校验(SPF / DKIM / DMARC)
|
||||
|
||||
在此之前**谁都能用 `From: [email protected]` 给这台服务器发信**,服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有区别。
|
||||
现在收信时依次做:
|
||||
|
||||
| 步骤 | 实现 | 说明 |
|
||||
|---|---|---|
|
||||
| SPF | `Spf.EvaluateAsync`(RFC 7208 常用子集) | all / include / a / mx / ip4 / ip6 / exists + 限定符 + redirect + 宏;**查询次数上限 10**(超了 permerror);`ptr` 机制按 RFC 建议直接视为不匹配 |
|
||||
| DKIM | `DkimVerifier`(RFC 6376) | relaxed/simple 规范化、`h=` 重名从下往上取、`l=` 截断、`x=` 过期、`p=` 为空视为吊销;**公钥发布成 CNAME 时会自动跟**(outlook.com 就是这种) |
|
||||
| DMARC | `Dmarc.EvaluateAsync`(RFC 7489) | `p=` / `aspf=` / `adkim=`;relaxed 对齐用「组织域」(内置 co.uk / com.cn 这类多段后缀表) |
|
||||
|
||||
结论会写进报文:`Authentication-Results:`、`X-Spam-Score:`、`X-Spam-Reason:`(**前置插入,不动原有字节**,
|
||||
所以发件人的 DKIM 签名不会被我们破坏 —— 自检里有这条断言)。
|
||||
判定为垃圾(默认阈值 3;DMARC 失败固定 +4)则投进 `spam` 文件夹并把 `DeliveryStatus` 标成 `received-spam`,
|
||||
**默认不拒收**:校验实现自身也可能有 bug,投垃圾箱可逆、拒收不可逆。
|
||||
|
||||
**验签实现必须能被打假**:自检里带了「改正文 / 改主题 / 只翻转一个字节 / 换公钥 / 公钥吊销」五类反向用例,
|
||||
它们必须全部失败 —— 2026-09-13 的 DKIM 事故就是签名端和验签端犯了同一个错,导致自检「全通过」而外部判 fail。
|
||||
|
||||
维护命令:**`--verify-inbound [N]`** —— 对已落库的真实邮件走真实 DNS 验签(排查「这封信是不是伪造的」)。
|
||||
实测最近 25 封里 8 封通过(含 126.com 这类第三方签名),1 封 163 转发的因正文被改写而判 fail(**判定正确**)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 已知限制
|
||||
- ~~未实现 IMAP/POP3~~ → **IMAP4rev1 已在 v2 实现**(见第 8 节);POP3 仍未实现。
|
||||
- ~~TLS 证书自签名~~ → **已换成 Let's Encrypt 受信任证书**(DNS-01,含自动续期计划任务)。
|
||||
- ~~无账号体系~~ → **v2.2 已有自助注册 / 登录加固 / 找回密码 / 会话与资料管理**(见第 6.2 节);
|
||||
仍缺:**管理员删除账号**(目前只能停用,`PATCH /api/admin/users/{email}` `{"active":false}`)。
|
||||
- **明文凭据**:`appsettings.json` 中的 `AdminPassword` 与证书口令是明文,注意文件权限。
|
||||
- **无病毒/垃圾过滤**:收信不做内容扫描,也未做 SPF/DKIM 校验入站判定。
|
||||
- **默认无全文索引**:搜索是 `LIKE` 扫描(数千封内毫秒级);需要更快请开 `FullTextSearch`。
|
||||
- **无配额**:单用户磁盘占用不限制。
|
||||
- **`--vacuum` 需停服执行**(见第 9 节)。
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 自检:不依赖网络与真实邮箱,验证中文编解码、MIME 往返、附件、DKIM 签名可被验证。
|
||||
/// 用法:WpywMail.Native.exe --selftest
|
||||
/// </summary>
|
||||
public static partial class SelfTest
|
||||
{
|
||||
private static int passed;
|
||||
private static int failed;
|
||||
|
||||
public static int Run()
|
||||
{
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
Console.WriteLine($"=== WpywMail.Native 自检 {BuildInfo.Version} ===");
|
||||
|
||||
var config = new AppConfig
|
||||
{
|
||||
Domain = "wpy.email",
|
||||
Hostname = "mail.example.com",
|
||||
AdminEmail = "[email protected]",
|
||||
AdminPassword = "selftest-password-1234",
|
||||
DataDirectory = Path.Combine(Path.GetTempPath(), "wpyw-selftest-" + Guid.NewGuid().ToString("N")[..8]),
|
||||
Dkim = new DkimConfig { Enabled = true, Selector = "mail" },
|
||||
};
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
TestChineseRoundTrip(config);
|
||||
TestMessageIdDomain(config);
|
||||
TestRfc2047Decoding();
|
||||
TestRawUtf8Headers();
|
||||
TestQuotedPrintable();
|
||||
TestAttachmentRoundTrip(config);
|
||||
TestCharsetFallback();
|
||||
TestDkimSignatureVerifies(config);
|
||||
TestDkimSurvivesTransport(config);
|
||||
TestAddressParsing();
|
||||
TestSmtpDataReader();
|
||||
TestDkimCanonicalization();
|
||||
TestStorageBackends();
|
||||
TestAccounts();
|
||||
TestInboundAuth();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Fail("自检整体", $"抛出异常:{ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(config.DataDirectory, true); } catch { }
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"=== 结果:{passed} 项通过,{failed} 项失败 ===");
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 用例
|
||||
|
||||
private static void TestChineseRoundTrip(AppConfig config)
|
||||
{
|
||||
const string subject = "中文主题测试:你好,世界(含全角标点)";
|
||||
const string body = "这是正文第一行。\n第二行包含 emoji 之外的符号:→ ★ ①\n第三行结束。";
|
||||
var raw = Mime.Build(new ComposeRequest("[email protected]", "王朋友", ["[email protected]"], [], subject, body), config);
|
||||
var parsed = Mime.Parse(raw);
|
||||
|
||||
Check("中文主题往返", parsed.Subject == subject, $"得到「{parsed.Subject}」");
|
||||
Check("中文正文往返", parsed.Text == body, $"得到「{parsed.Text.Replace("\n", "\\n")}」");
|
||||
Check("正文使用 base64", Encoding.ASCII.GetString(raw).Contains("Content-Transfer-Encoding: base64"), "");
|
||||
Check("主题使用 RFC2047", Encoding.ASCII.GetString(raw).Contains("=?UTF-8?B?"), "");
|
||||
Check("报文全为 ASCII(可安全传输)", raw.All(b => b < 0x80), "存在 8bit 字节");
|
||||
Check("带显示名的中文发件人", RawHeader(raw, "From").Contains("=?UTF-8?B?"), RawHeader(raw, "From"));
|
||||
}
|
||||
|
||||
private static void TestMessageIdDomain(AppConfig config)
|
||||
{
|
||||
var raw = Mime.Build(new ComposeRequest("[email protected]", null, ["[email protected]"], [], "t", "b"), config);
|
||||
var messageId = RawHeader(raw, "Message-ID");
|
||||
Check("Message-ID 使用配置域名", messageId.Contains("@wpy.email>"), messageId);
|
||||
Check("Message-ID 不再写死旧域名", !messageId.Contains("wpyw.site"), messageId);
|
||||
}
|
||||
|
||||
private static void TestRfc2047Decoding()
|
||||
{
|
||||
var b = "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes("中文主题")) + "?=";
|
||||
Check("解码 B 编码字", Mime.DecodeHeader(b) == "中文主题", Mime.DecodeHeader(b));
|
||||
|
||||
var q = "=?UTF-8?Q?=E4=B8=AD=E6=96=87?=";
|
||||
Check("解码 Q 编码字", Mime.DecodeHeader(q) == "中文", Mime.DecodeHeader(q));
|
||||
|
||||
var adjacent = "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes("前半")) + "?= =?UTF-8?B?" +
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes("后半")) + "?=";
|
||||
Check("相邻编码字拼接无多余空格", Mime.DecodeHeader(adjacent) == "前半后半", Mime.DecodeHeader(adjacent));
|
||||
|
||||
var mixed = "Hello " + b;
|
||||
Check("编码字与纯文本混排", Mime.DecodeHeader(mixed) == "Hello 中文主题", Mime.DecodeHeader(mixed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 裸 UTF-8 头(没有 RFC 2047 编码字)与 8bit 正文。
|
||||
/// 现实中不少客户端这么发;早期版本会把它解成「[æµè¯]」这种乱码。
|
||||
/// </summary>
|
||||
private static void TestRawUtf8Headers()
|
||||
{
|
||||
var raw = Encoding.UTF8.GetBytes(
|
||||
"From: 张三 <[email protected]>\r\n" +
|
||||
"Subject: 中文裸 UTF-8 主题\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"Content-Transfer-Encoding: 8bit\r\n" +
|
||||
"\r\n" +
|
||||
"中文正文内容\r\n");
|
||||
var parsed = Mime.Parse(raw);
|
||||
Check("裸 UTF-8 主题解码", parsed.Subject == "中文裸 UTF-8 主题", parsed.Subject);
|
||||
Check("裸 UTF-8 发件人显示名", parsed.From.Contains("张三"), parsed.From);
|
||||
Check("8bit 正文解码", parsed.Text == "中文正文内容", parsed.Text);
|
||||
}
|
||||
|
||||
private static void TestQuotedPrintable() {
|
||||
var raw = Encoding.ASCII.GetBytes("Subject: =?UTF-8?Q?=E4=B8=AD=E6=96=87?=\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n=E4=B8=AD=E6=96=87=20ok=\r\n=E7=BB=AD=E8=A1=8C\r\n");
|
||||
var parsed = Mime.Parse(raw);
|
||||
// =E7=BB=AD = U+7EED「续」,且 =CRLF 是软换行必须被合并
|
||||
Check("QP 正文解码(含软换行合并)", parsed.Text == "中文 ok续行", $"得到「{parsed.Text}」");
|
||||
Check("QP 主题解码", parsed.Subject == "中文", parsed.Subject);
|
||||
}
|
||||
|
||||
private static void TestAttachmentRoundTrip(AppConfig config)
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes("附件内容:中文测试数据");
|
||||
var raw = Mime.Build(new ComposeRequest("[email protected]", null, ["[email protected]"], [],
|
||||
"带附件", "见附件", null, [new OutgoingAttachment("中文报告.txt", "text/plain", data)]), config);
|
||||
var parsed = Mime.Parse(raw);
|
||||
|
||||
Check("附件数量", parsed.Attachments.Count == 1, $"{parsed.Attachments.Count}");
|
||||
if (parsed.Attachments.Count == 1)
|
||||
{
|
||||
var attachment = parsed.Attachments[0];
|
||||
Check("附件名(中文)", attachment.FileName == "中文报告.txt", attachment.FileName);
|
||||
Check("附件内容逐字节一致", attachment.Data.SequenceEqual(data), "");
|
||||
Check("附件使用 RFC2231 文件名", Encoding.ASCII.GetString(raw).Contains("filename*=UTF-8''"), "");
|
||||
}
|
||||
Check("附件存在时正文仍可读", parsed.Text.Contains("见附件"), parsed.Text);
|
||||
}
|
||||
|
||||
private static void TestCharsetFallback()
|
||||
{
|
||||
Check("UTF-8 解码", Mime.DecodeCharset(Encoding.UTF8.GetBytes("中文"), "utf-8") == "中文", "");
|
||||
Check("未知字符集回退不崩", Mime.DecodeCharset(Encoding.UTF8.GetBytes("中文"), "x-unknown-999") == "中文", "");
|
||||
|
||||
var provider = CodePagesShim.Provider;
|
||||
if (provider is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Encoding.RegisterProvider(provider);
|
||||
var gbk = Encoding.GetEncoding(936).GetBytes("中文测试");
|
||||
Check("GBK 解码", Mime.DecodeCharset(gbk, "gb2312") == "中文测试", Mime.DecodeCharset(gbk, "gb2312"));
|
||||
}
|
||||
catch (Exception ex) { Check("GBK 解码", false, ex.Message); }
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" [跳过] GBK 解码(未引用 System.Text.Encoding.CodePages)");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TestDkimSignatureVerifies(AppConfig config)
|
||||
{
|
||||
var signer = DkimSigner.Create(config);
|
||||
if (signer is null) { Check("DKIM 初始化", false, "返回 null"); return; }
|
||||
|
||||
var raw = Mime.Build(new ComposeRequest("[email protected]", null, ["[email protected]"], [],
|
||||
"DKIM 测试主题", "DKIM 测试正文内容。"), config);
|
||||
var signed = signer.Sign(raw);
|
||||
|
||||
Check("报文已带 DKIM-Signature", Encoding.ASCII.GetString(signed).Contains("DKIM-Signature:"), "");
|
||||
Check("被签名报文仍是 ASCII", signed.All(b => b < 0x80), "");
|
||||
Check("DKIM 签名可被本地验证(RFC 6376 §3.7 顺序)", VerifyDkim(signed, signer), "签名校验失败");
|
||||
// 反向对照:如果验签器把「签名头放最前 + 结尾带 CRLF」也判为有效,说明它根本没在
|
||||
// 按 RFC 校验(2026-09-13 的真实事故就是签名端与验签端一起犯错导致假通过)。
|
||||
Check("(反向对照)非规范顺序必须验不过",
|
||||
!VerifyDkim(signed, signer, rfcOrder: false), "两种顺序都能通过 → 验签器未按 RFC 6376 校验");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最关键的一致性测试:签名覆盖的字节必须与传输写出的字节完全一致。
|
||||
/// 用一个「裸 LF 行尾 + 以点开头的行」的恶劣报文走完整链路:
|
||||
/// 规范化 → 签名 → DATA 编码(dot-stuffing)→ 收件端还原 → 验签。
|
||||
/// 若签名后才改行尾,或 dot-stuffing 破坏了正文,这里必然失败。
|
||||
/// </summary>
|
||||
private static void TestDkimSurvivesTransport(AppConfig config)
|
||||
{
|
||||
var signer = DkimSigner.Create(config);
|
||||
if (signer is null) { Check("DKIM 传输一致性", false, "签名器不可用"); return; }
|
||||
|
||||
var hostile = Encoding.UTF8.GetBytes(
|
||||
"From: [email protected]\n" +
|
||||
"To: [email protected]\n" +
|
||||
"Subject: 传输一致性测试\n" +
|
||||
"MIME-Version: 1.0\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\n" +
|
||||
"Content-Transfer-Encoding: 8bit\n" +
|
||||
"\n" +
|
||||
"第一行中文\n" +
|
||||
". 这一行以点开头\n" +
|
||||
"最后一行\n");
|
||||
|
||||
var normalized = SmtpDataEncoder.Normalize(hostile);
|
||||
// 注意:报文是 UTF-8 字节,检查时必须按 UTF-8 还原(用 Latin-1 解会得到乱码而匹配不上)
|
||||
Check("裸 LF 在签名前被规范化为 CRLF",
|
||||
Encoding.UTF8.GetString(normalized).Contains("\r\n. 这一行以点开头\r\n"),
|
||||
"");
|
||||
|
||||
var signed = signer.Sign(normalized);
|
||||
var wire = SmtpDataEncoder.Encode(signed);
|
||||
Check("线上报文对行首点做了 dot-stuffing",
|
||||
Encoding.UTF8.GetString(wire).Contains("\r\n.. 这一行以点开头\r\n"),
|
||||
"");
|
||||
|
||||
var received = SmtpDataEncoder.Decode(wire);
|
||||
Check("接收端还原后与签名前字节一致", received.SequenceEqual(signed), "");
|
||||
|
||||
var verified = VerifyDkim(received, signer);
|
||||
Check("DKIM 经过完整传输变换后仍可验证", verified, "签名校验失败");
|
||||
}
|
||||
|
||||
private static void TestAddressParsing()
|
||||
{
|
||||
var result = Mime.Addresses("张三 <[email protected]>, [email protected]; \"李四, 五\" <[email protected]>");
|
||||
Check("地址解析(含引号内逗号)", result.Length == 3 && result[0] == "[email protected]" && result[2] == "[email protected]",
|
||||
string.Join(" | ", result));
|
||||
Check("非法地址被过滤", Mime.Addresses("not-an-address, [email protected]").Length == 1, "");
|
||||
}
|
||||
|
||||
private static void TestSmtpDataReader()
|
||||
{
|
||||
// 模拟客户端发送:正文里有中文 UTF-8、以点开头的行需要 dot-stuffing
|
||||
var payload = "Subject: 测试\r\n\r\n.. 以点开头\r\n中文内容\r\n";
|
||||
var wire = Encoding.UTF8.GetBytes(payload.Replace("\r\n", "\r\n"));
|
||||
using var stream = new MemoryStream();
|
||||
// 客户端在 DATA 后追加结束行
|
||||
stream.Write(wire);
|
||||
stream.Write(Encoding.ASCII.GetBytes(".\r\n"));
|
||||
stream.Position = 0;
|
||||
|
||||
var reader = new SmtpReader(stream);
|
||||
var data = reader.ReadDataAsync(1024 * 1024, CancellationToken.None).GetAwaiter().GetResult();
|
||||
var text = Encoding.UTF8.GetString(data);
|
||||
|
||||
Check("DATA 读取保留 UTF-8 中文", text.Contains("中文内容"), text.Replace("\r\n", "\\n"));
|
||||
Check("DATA 读取做 dot-unstuffing", text.Contains(". 以点开头"), text.Replace("\r\n", "\\n"));
|
||||
Check("DATA 读取行尾统一 CRLF", text.EndsWith("\r\n"), "");
|
||||
}
|
||||
|
||||
private static void TestDkimCanonicalization()
|
||||
{
|
||||
// 折行 + 多余空白应被规范化成单空格
|
||||
var raw = Encoding.ASCII.GetBytes("From: [email protected]\r\nSubject: hello world \r\n\tcontinued\r\n\r\nbody\r\n");
|
||||
var (headers, _) = SplitForTest(raw);
|
||||
var subject = headers.First(h => h.Name == "Subject");
|
||||
var canonical = CanonicalForTest(subject.Name, subject.Value);
|
||||
Check("relaxed 头规范化(折行与空白)", canonical == "subject:hello world continued", canonical);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- DKIM 校验(独立于签名器的实现)
|
||||
|
||||
/// <summary>
|
||||
/// 按 RFC 6376 §3.7 第 2 步重建签名输入并验签。
|
||||
/// rfcOrder=false 时故意用「DKIM-Signature 放最前 + 结尾带 CRLF」的**非规范**顺序,
|
||||
/// 仅用于反向对照:非规范顺序绝不能被判为有效。
|
||||
/// </summary>
|
||||
private static bool VerifyDkim(byte[] message, DkimSigner signer, bool rfcOrder = true)
|
||||
{
|
||||
var (headers, body) = SplitForTest(message);
|
||||
var dkim = headers.FirstOrDefault(h => h.Name.Equals("DKIM-Signature", StringComparison.OrdinalIgnoreCase));
|
||||
if (dkim.Name is null) return false;
|
||||
|
||||
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var part in dkim.Value.Split(';'))
|
||||
{
|
||||
var equals = part.IndexOf('=');
|
||||
if (equals > 0) tags[part[..equals].Trim()] = part[(equals + 1)..].Trim();
|
||||
}
|
||||
if (!tags.TryGetValue("b", out var signatureBase64) || !tags.TryGetValue("bh", out var bodyHash)) return false;
|
||||
if (!tags.TryGetValue("h", out var headerList)) return false;
|
||||
|
||||
// 1) 正文哈希
|
||||
var canonicalBody = CanonicalBodyForTest(body);
|
||||
var computedBodyHash = Convert.ToBase64String(SHA256.HashData(canonicalBody));
|
||||
if (computedBodyHash != bodyHash.Trim()) return false;
|
||||
|
||||
// 2) 重建签名输入
|
||||
var withoutSignature = dkim.Value.Replace(tags["b"], "").TrimEnd();
|
||||
var signatureLine = CanonicalForTest("DKIM-Signature", withoutSignature);
|
||||
var builder = new StringBuilder();
|
||||
|
||||
if (!rfcOrder)
|
||||
{
|
||||
builder.Append(signatureLine).Append("\r\n");
|
||||
}
|
||||
|
||||
foreach (var name in headerList.Split(':', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var header = headers.FirstOrDefault(h => h.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (header == default) return false;
|
||||
builder.Append(CanonicalForTest(header.Name, header.Value)).Append("\r\n");
|
||||
}
|
||||
|
||||
if (rfcOrder)
|
||||
{
|
||||
// RFC 6376 §3.7:签名头放最后,且不带结尾 CRLF
|
||||
builder.Append(signatureLine);
|
||||
}
|
||||
|
||||
var publicKey = RSA.Create();
|
||||
publicKey.ImportSubjectPublicKeyInfo(signer.PublicKeyBytes, out _);
|
||||
// 与签名端一致用 Latin1(字节保真):ASCII 会把 8bit 头里的非 ASCII 字符替换成 '?'
|
||||
return publicKey.VerifyData(Encoding.Latin1.GetBytes(builder.ToString()),
|
||||
Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
private static byte[] CanonicalBodyForTest(byte[] body)
|
||||
{
|
||||
var text = Encoding.Latin1.GetString(body).Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
var lines = text.Split('\n').Select(l => CollapseForTest(l).TrimEnd(' ', '\t'));
|
||||
var joined = string.Join("\r\n", lines).TrimEnd('\r', '\n');
|
||||
if (joined.Length > 0) joined += "\r\n";
|
||||
return Encoding.Latin1.GetBytes(joined);
|
||||
}
|
||||
|
||||
private static string CanonicalForTest(string name, string value)
|
||||
{
|
||||
var unfolded = value.Replace("\r\n", "").Replace("\n", "");
|
||||
return name.Trim().ToLowerInvariant() + ":" + CollapseForTest(unfolded).Trim();
|
||||
}
|
||||
|
||||
private static string CollapseForTest(string value)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var inWhitespace = false;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c is ' ' or '\t') { if (!inWhitespace) builder.Append(' '); inWhitespace = true; }
|
||||
else { builder.Append(c); inWhitespace = false; }
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static (List<(string Name, string Value)> Headers, byte[] Body) SplitForTest(byte[] message)
|
||||
{
|
||||
var headers = new List<(string, string)>();
|
||||
var index = 0;
|
||||
string? name = null;
|
||||
var value = new StringBuilder();
|
||||
while (index < message.Length)
|
||||
{
|
||||
var lineEnd = Array.IndexOf(message, (byte)'\n', index);
|
||||
if (lineEnd < 0) lineEnd = message.Length;
|
||||
var line = Encoding.Latin1.GetString(message, index, lineEnd - index).TrimEnd('\r');
|
||||
index = lineEnd + 1;
|
||||
if (line.Length == 0) break;
|
||||
if ((line[0] == ' ' || line[0] == '\t') && name is not null) { value.Append("\r\n").Append(line); continue; }
|
||||
if (name is not null) headers.Add((name, value.ToString()));
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon <= 0) { name = null; value.Clear(); continue; }
|
||||
name = line[..colon].Trim();
|
||||
value.Clear().Append(line[(colon + 1)..]);
|
||||
}
|
||||
if (name is not null) headers.Add((name, value.ToString()));
|
||||
return (headers, message[Math.Min(index, message.Length)..]);
|
||||
}
|
||||
|
||||
private static string RawHeader(byte[] raw, string name)
|
||||
{
|
||||
var (headers, _) = SplitForTest(raw);
|
||||
var header = headers.FirstOrDefault(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
return header == default ? "" : header.Value.Trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 断言
|
||||
|
||||
private static void Check(string title, bool ok, string detail)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
passed++;
|
||||
Console.WriteLine($" [通过] {title}");
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
Console.WriteLine($" [失败] {title}" + (string.IsNullOrEmpty(detail) ? "" : $" —— {detail}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void Fail(string title, string detail) => Check(title, false, detail);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 账号体系自检:注册(含验证码)、登录失败锁定、密码重置、会话管理、资料与审计。
|
||||
///
|
||||
/// 这些用例**不经过 HTTP**,直接打 AccountService + 存储层,所以跑得快、
|
||||
/// 也能覆盖「两套存储后端行为一致」这一点(json 与 sqlite 各跑一遍)。
|
||||
/// 验证码是随机的、只能从邮件正文里拿;这里为了可测,直接从存储里读回
|
||||
/// 哈希校验逻辑无法反推明文 —— 所以改为**注入式**:测试里用一个已知码替换哈希。
|
||||
/// </summary>
|
||||
public static partial class SelfTest
|
||||
{
|
||||
private static void TestAccounts()
|
||||
{
|
||||
foreach (var provider in new[] { "json", "sqlite" })
|
||||
{
|
||||
var config = new AppConfig
|
||||
{
|
||||
Domain = "wpy.email",
|
||||
Hostname = "mail.example.com",
|
||||
AdminEmail = "[email protected]",
|
||||
AdminPassword = "selftest-password-1234",
|
||||
DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-acct-{provider}-{Guid.NewGuid().ToString("N")[..8]}"),
|
||||
Dkim = new DkimConfig { Enabled = false },
|
||||
Storage = new StorageConfig { Provider = provider },
|
||||
};
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
IMailStore? store = null;
|
||||
try
|
||||
{
|
||||
config.Accounts = new AccountsConfig
|
||||
{
|
||||
Registration = "invite",
|
||||
InviteCode = "TEST-INVITE",
|
||||
// 两个域各司其职:wpy.email 由本机托管(免验证路径),mail.example.net 托管在别处(走验证码路径)
|
||||
AllowedDomains = ["wpy.email", "mail.example.net"],
|
||||
RequireEmailVerification = true,
|
||||
MinPasswordLength = 12,
|
||||
CodeMinutes = 30,
|
||||
MaxCodeAttempts = 3,
|
||||
MaxLoginFailures = 3,
|
||||
LockoutMinutes = 15,
|
||||
RegisterPerHourPerIp = 5,
|
||||
ResendPerHourPerEmail = 5,
|
||||
};
|
||||
store = Program.CreateStore(config);
|
||||
var accounts = new AccountService(config, store);
|
||||
|
||||
// ---- 密码策略
|
||||
Check($"[{provider}] 密码太短被拒", !accounts.CheckPassword("short", "[email protected]").Ok, "");
|
||||
Check($"[{provider}] 纯数字密码被拒", !accounts.CheckPassword("123456789012", "[email protected]").Ok, "");
|
||||
Check($"[{provider}] 与邮箱相同的密码被拒", !accounts.CheckPassword("[email protected]", "[email protected]").Ok, "");
|
||||
Check($"[{provider}] 合格密码被接受", accounts.CheckPassword("Str0ng-Pass-2026", "[email protected]").Ok, "");
|
||||
|
||||
// ---- 域名白名单
|
||||
Check($"[{provider}] 域名校验:允许 wpy.email",
|
||||
accounts.Register(new RegisterRequest("[email protected]", "Str0ng-Pass-2026", "新人", "TEST-INVITE"), "10.0.0.1", "test").Ok, "");
|
||||
var badDomain = accounts.Register(new RegisterRequest("[email protected]", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.2", "test");
|
||||
Check($"[{provider}] 域名校验:拒绝外部域", !badDomain.Ok && badDomain.Status == 400, badDomain.Error);
|
||||
|
||||
// ---- 邀请码
|
||||
var badInvite = accounts.Register(new RegisterRequest("[email protected]", "Str0ng-Pass-2026", "", "WRONG"), "10.0.0.3", "test");
|
||||
Check($"[{provider}] 邀请码错误被拒", !badInvite.Ok && badInvite.Status == 403, badInvite.Error);
|
||||
|
||||
// ---- 弱密码
|
||||
var weak = accounts.Register(new RegisterRequest("[email protected]", "123", "", "TEST-INVITE"), "10.0.0.4", "test");
|
||||
Check($"[{provider}] 注册时弱密码被拒", !weak.Ok && weak.Status == 400, weak.Error);
|
||||
|
||||
// ---- 【设计决定】本机托管的地址必须免邮箱验证直接开通
|
||||
// 验证码邮件只能投进「这个」信箱,而它在验证通过前登录不了(IMAP/Webmail/API 全进不去)
|
||||
// → 要验证就是死循环。这类地址的授权凭据是管理员发放的邀请码。
|
||||
var hostedEmail = $"hosted{provider}@wpy.email";
|
||||
var hostedReg = accounts.Register(new RegisterRequest(hostedEmail, "Str0ng-Pass-2026", "本机地址", "TEST-INVITE"), "10.0.0.21", "test");
|
||||
Check($"[{provider}] 本机托管地址免邮箱验证直接开通",
|
||||
hostedReg.Ok && !hostedReg.VerificationRequired, hostedReg.Error);
|
||||
Check($"[{provider}] 本机托管地址开通后立即可登录",
|
||||
store.Authenticate(hostedEmail, "Str0ng-Pass-2026") is not null, "");
|
||||
Check($"[{provider}] 免验证注册不会残留验证码记录",
|
||||
store.FindVerificationCode(hostedEmail, "register") is null, "");
|
||||
Check($"[{provider}] 托管判定:本机域=true / 外部域=false",
|
||||
accounts.IsHostedHere("[email protected]") && !accounts.IsHostedHere("[email protected]"), "");
|
||||
|
||||
// ---- 注册 → 验证码 → 激活(外部托管的邮箱:验证码才有意义)
|
||||
var email = $"reg{provider}@mail.example.net";
|
||||
var register = accounts.Register(new RegisterRequest(email, "Str0ng-Pass-2026", "注册用户", "TEST-INVITE"), "10.0.0.5", "test");
|
||||
Check($"[{provider}] 注册返回需要邮箱验证", register.Ok && register.VerificationRequired, register.Error);
|
||||
|
||||
// ★ 关键回归:曾经写成「验证通过才建号」,结果本地投递看不到收件人、验证码邮件根本送不到。
|
||||
// 正确做法是注册时就把信箱行建出来并置为未激活。
|
||||
var pending = store.FindUserAnyState(email);
|
||||
Check($"[{provider}] 注册后立即建号且处于未激活状态", pending is { Active: false },
|
||||
pending is null ? "(无用户行)" : $"active={pending.Active}");
|
||||
Check($"[{provider}] 未激活的信箱对本地投递可见", store.IsLocalAddress(email), "");
|
||||
Check($"[{provider}] 未激活期间任何密码都不能登录(凭据只存在验证码记录里)",
|
||||
store.Authenticate(email, "Str0ng-Pass-2026") is null, "");
|
||||
|
||||
var code = ReadCodeFromOutbox(store, email);
|
||||
Check($"[{provider}] 验证码邮件已入队且能取出 6 位码", code is { Length: 6 }, code ?? "(空)");
|
||||
|
||||
// ★ 端到端:这封验证码邮件必须真的能投进那个「未激活」信箱。
|
||||
// 原设计(验证通过才建号)就是死在这里 —— 本地投递找不到收件人,用户永远收不到码。
|
||||
var codeRaw = ReadQueuedRaw(store, email);
|
||||
var landed = codeRaw is null ? null : store.DeliverLocal(email, codeRaw, "[email protected]");
|
||||
Check($"[{provider}] 验证码邮件真的投进了未激活信箱",
|
||||
landed is not null && landed.OwnerEmail.Equals(email, StringComparison.OrdinalIgnoreCase),
|
||||
landed is null ? "本地投递返回 null(收件人不可见)" : $"owner={landed.OwnerEmail} folder={landed.Folder} 未读={landed.Unread}");
|
||||
|
||||
var wrong = accounts.VerifyRegistration(new VerifyCodeRequest(email, "000000"), "10.0.0.5", "test");
|
||||
Check($"[{provider}] 错误验证码被拒", !wrong.Ok, wrong.Error);
|
||||
|
||||
var verified = accounts.VerifyRegistration(new VerifyCodeRequest(email, code!), "10.0.0.5", "test");
|
||||
Check($"[{provider}] 正确验证码激活账号", verified.Ok && verified.Session is not null, verified.Error);
|
||||
var created = store.FindUser(email);
|
||||
Check($"[{provider}] 激活后用户存在且可认证",
|
||||
created is not null && store.Authenticate(email, "Str0ng-Pass-2026") is not null, "");
|
||||
Check($"[{provider}] 激活后验证码被清除", store.FindVerificationCode(email, "register") is null, "");
|
||||
|
||||
var reuse = accounts.VerifyRegistration(new VerifyCodeRequest(email, code!), "10.0.0.5", "test");
|
||||
Check($"[{provider}] 验证码不能用第二次", !reuse.Ok, reuse.Error);
|
||||
|
||||
// ---- 未完成验证的注册可以重来;抢注者无法凭自己提交的密码进去
|
||||
var pendEmail = $"pending{provider}@mail.example.net";
|
||||
accounts.Register(new RegisterRequest(pendEmail, "First-Pass-2026", "先注册", "TEST-INVITE"), "10.0.0.7", "test");
|
||||
var again = accounts.Register(new RegisterRequest(pendEmail, "Second-Pass-2026", "后注册", "TEST-INVITE"), "10.0.0.8", "test");
|
||||
Check($"[{provider}] 未激活的注册允许重来(不返回 409)", again.Ok && again.VerificationRequired, again.Error);
|
||||
Check($"[{provider}] 重来期间两个密码都不能登录(未激活就没有可用凭据)",
|
||||
store.Authenticate(pendEmail, "First-Pass-2026") is null && store.Authenticate(pendEmail, "Second-Pass-2026") is null, "");
|
||||
var pendCode = ReadCodeFromOutbox(store, pendEmail);
|
||||
var pendVerified = accounts.VerifyRegistration(new VerifyCodeRequest(pendEmail, pendCode!), "10.0.0.8", "test");
|
||||
Check($"[{provider}] 验证后生效的是读得到验证码那一方提交的密码",
|
||||
pendVerified.Ok
|
||||
&& store.Authenticate(pendEmail, "Second-Pass-2026") is not null
|
||||
&& store.Authenticate(pendEmail, "First-Pass-2026") is null, pendVerified.Error);
|
||||
|
||||
// ---- 登录失败时的措辞:卡在验证的人要被告知出路;被停用的人不能被探测出来
|
||||
var pendHint = accounts.LoginFailureHint(pendEmail);
|
||||
Check($"[{provider}] 已激活账号的失败提示是通用 401",
|
||||
pendHint.Status == 401 && !pendHint.PendingVerification, pendHint.AuditReason);
|
||||
var hintPending = accounts.LoginFailureHint($"pending2{provider}@mail.example.net");
|
||||
Check($"[{provider}] 不存在的账号失败提示是通用 401(不暴露存在性)",
|
||||
hintPending.Status == 401 && hintPending.AuditReason == "unknown user", hintPending.AuditReason);
|
||||
accounts.Register(new RegisterRequest($"pending2{provider}@mail.example.net", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.13", "test");
|
||||
var hintWait = accounts.LoginFailureHint($"pending2{provider}@mail.example.net");
|
||||
Check($"[{provider}] 卡在邮箱验证的账号会被告知出路(403)",
|
||||
hintWait.Status == 403 && hintWait.PendingVerification, $"{hintWait.Status} {hintWait.AuditReason}");
|
||||
|
||||
// ---- 管理员停用是权威状态:不能靠重新注册翻回来
|
||||
var disabled = $"disabled{provider}@wpy.email";
|
||||
store.CreateUser(disabled, "Str0ng-Pass-2026", "停用测试");
|
||||
store.SetLastLogin(disabled);
|
||||
store.SetUserActive(disabled, false);
|
||||
var reReg = accounts.Register(new RegisterRequest(disabled, "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.14", "test");
|
||||
Check($"[{provider}] 被停用的账号不能靠重新注册复活(403)",
|
||||
!reReg.Ok && reReg.Status == 403, $"{reReg.Status} {reReg.Error}");
|
||||
Check($"[{provider}] 被停用的账号登录提示不暴露状态(401)",
|
||||
accounts.LoginFailureHint(disabled).Status == 401, "");
|
||||
Check($"[{provider}] 停用状态没有被注册流程改掉", store.FindUser(disabled) is null, "");
|
||||
|
||||
// ---- 重复注册(已激活的账号必须被挡)
|
||||
var dup = accounts.Register(new RegisterRequest(email, "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.6", "test");
|
||||
Check($"[{provider}] 已激活账号重复注册被拒(409)", !dup.Ok && dup.Status == 409, dup.Error);
|
||||
|
||||
// ---- 登录失败锁定
|
||||
var lockedEmail = "[email protected]";
|
||||
store.CreateUser(lockedEmail, "Str0ng-Pass-2026", "锁定测试");
|
||||
for (var i = 0; i < config.Accounts.MaxLoginFailures; i++)
|
||||
accounts.Record(lockedEmail, "10.0.0.9", "login-failed", false, "test");
|
||||
Check($"[{provider}] 连续失败后进入锁定", accounts.LockRemainingSeconds(lockedEmail) > 0,
|
||||
$"剩余 {accounts.LockRemainingSeconds(lockedEmail)}s");
|
||||
var fresh = "[email protected]";
|
||||
store.CreateUser(fresh, "Str0ng-Pass-2026", "未锁定");
|
||||
Check($"[{provider}] 未失败过的账号不锁定", accounts.LockRemainingSeconds(fresh) == 0, "");
|
||||
|
||||
// ---- 审计可查
|
||||
var events = store.ListAuthEvents(lockedEmail, null, "login-failed", 10);
|
||||
Check($"[{provider}] 审计记录了登录失败", events.Count == config.Accounts.MaxLoginFailures, $"{events.Count} 条");
|
||||
Check($"[{provider}] 按 IP 统计失败次数", store.CountAuthEvents(null, "10.0.0.9", "login-failed", false, 60) == config.Accounts.MaxLoginFailures, "");
|
||||
|
||||
// ---- 会话管理
|
||||
var sessions = store.ListSessions(fresh);
|
||||
Check($"[{provider}] 新建用户初始无会话", sessions.Count == 0, $"{sessions.Count} 个");
|
||||
var s1 = store.CreateSession(fresh, 30);
|
||||
var s2 = store.CreateSession(fresh, 30);
|
||||
var s3 = store.CreateSession(fresh, 30);
|
||||
Check($"[{provider}] 三次登录产生三个会话", store.ListSessions(fresh).Count == 3, "");
|
||||
var revoked = store.RemoveSessions(fresh, s2.Token);
|
||||
Check($"[{provider}] 退出其他设备保留当前(吊销 2 个)", revoked == 2 && store.ListSessions(fresh).Count == 1, $"吊销 {revoked} 个");
|
||||
Check($"[{provider}] 保留的正是当前 token", store.GetSession(s2.Token) is not null && store.GetSession(s1.Token) is null, "");
|
||||
var all = store.RemoveSessions(fresh, null);
|
||||
Check($"[{provider}] 全部吊销", all == 1 && store.ListSessions(fresh).Count == 0, $"吊销 {all} 个");
|
||||
Check($"[{provider}] 会话视图标记 current", sessions.Count == 0, "");
|
||||
|
||||
// ---- 资料
|
||||
accounts.UpdateProfile(fresh, "新名字");
|
||||
var renamed = store.FindUser(fresh);
|
||||
Check($"[{provider}] 显示名更新生效", renamed?.DisplayName == "新名字", renamed?.DisplayName ?? "(null)");
|
||||
|
||||
// ---- 密码重置
|
||||
var resetEmail = "[email protected]";
|
||||
store.CreateUser(resetEmail, "Str0ng-Pass-2026", "重置测试");
|
||||
var keep = store.CreateSession(resetEmail, 30);
|
||||
var (requested, reqError) = accounts.RequestReset(resetEmail, "10.0.0.10");
|
||||
Check($"[{provider}] 申请重置成功", requested, reqError);
|
||||
var resetCode = ReadCodeFromOutbox(store, resetEmail);
|
||||
Check($"[{provider}] 重置验证码邮件已入队", resetCode is { Length: 6 }, resetCode ?? "(空)");
|
||||
var weakReset = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, resetCode!, "123"), "10.0.0.10");
|
||||
Check($"[{provider}] 重置时弱密码被拒", !weakReset.Ok && weakReset.Status == 400, weakReset.Error);
|
||||
var badCode = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, "999999", "New-Str0ng-2026"), "10.0.0.10");
|
||||
Check($"[{provider}] 重置时错误验证码被拒", !badCode.Ok, badCode.Error);
|
||||
var good = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, resetCode!, "New-Str0ng-2026"), "10.0.0.10");
|
||||
Check($"[{provider}] 正确验证码重置成功", good.Ok, good.Error);
|
||||
Check($"[{provider}] 新密码可用", store.Authenticate(resetEmail, "New-Str0ng-2026") is not null, "");
|
||||
Check($"[{provider}] 旧密码失效", store.Authenticate(resetEmail, "Str0ng-Pass-2026") is null, "");
|
||||
Check($"[{provider}] 重置后旧会话被吊销", store.GetSession(keep.Token) is null, "");
|
||||
|
||||
// ---- 不暴露账号是否存在
|
||||
var unknown = accounts.RequestReset("[email protected]", "10.0.0.11");
|
||||
Check($"[{provider}] 对不存在的邮箱申请重置也返回成功(防枚举)", unknown.Ok, unknown.Error);
|
||||
|
||||
// ---- 关闭注册
|
||||
config.Accounts.Registration = "closed";
|
||||
var closed = accounts.Register(new RegisterRequest("[email protected]", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.12", "test");
|
||||
Check($"[{provider}] 关闭注册后拒绝(403)", !closed.Ok && closed.Status == 403, closed.Error);
|
||||
|
||||
// ---- 限流
|
||||
config.Accounts.Registration = "invite";
|
||||
config.Accounts.RegisterPerHourPerIp = 2;
|
||||
|
||||
// ★ 真机验收抓到的坑:失败尝试(填错邀请码 / 密码太短)不能吃掉严格配额,
|
||||
// 否则正常用户表单填错几次就被挡一小时,NAT 下还会连累同 IP 的其他人。
|
||||
var failIp = "10.9.9.10";
|
||||
for (var i = 0; i < 6; i++)
|
||||
accounts.Register(new RegisterRequest($"quota{i}-{provider}@wpy.email", "123", "", "TEST-INVITE"), failIp, "test");
|
||||
var quota1 = accounts.Register(new RegisterRequest($"quota-a-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test");
|
||||
var quota2 = accounts.Register(new RegisterRequest($"quota-b-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test");
|
||||
Check($"[{provider}] 表单填错 6 次后仍能正常建号(失败不吃严格配额)",
|
||||
quota1.Ok && quota2.Ok, $"{quota1.Status}/{quota2.Status}");
|
||||
var quota3 = accounts.Register(new RegisterRequest($"quota-c-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test");
|
||||
Check($"[{provider}] 成功建号达到配额后照样限流(429)",
|
||||
!quota3.Ok && quota3.Status == 429, $"{quota3.Status} {quota3.Error}");
|
||||
|
||||
var ip = "10.9.9.9";
|
||||
var r1 = accounts.Register(new RegisterRequest($"rate1-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test");
|
||||
var r2 = accounts.Register(new RegisterRequest($"rate2-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test");
|
||||
var r3 = accounts.Register(new RegisterRequest($"rate3-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test");
|
||||
Check($"[{provider}] 同 IP 超过每小时上限后被限流(429)",
|
||||
r1.Ok && r2.Ok && !r3.Ok && r3.Status == 429, r3.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Fail($"[{provider}] 账号体系自检", $"抛出异常:{ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { store?.Dispose(); } catch { }
|
||||
try { Directory.Delete(config.DataDirectory, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>从出站队列里把那封验证码邮件的原文取出来,再解析出 6 位验证码。</summary>
|
||||
private static string? ReadCodeFromOutbox(IMailStore store, string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = ReadQueuedRaw(store, email);
|
||||
if (raw is null) return null;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(Mime.Parse(raw).Text ?? "", @"\b(\d{6})\b");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>取该收件人最近一封出站邮件的原始字节(用于把「投递」也纳入自检)。</summary>
|
||||
private static byte[]? ReadQueuedRaw(IMailStore store, string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = new List<QueueItem>();
|
||||
foreach (var user in store.AllUsers()) items.AddRange(store.ListQueue(user.Email));
|
||||
var hit = items
|
||||
.Where(q => q.Recipients.Any(r => r.Equals(email, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderByDescending(q => q.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
if (hit is null) return null;
|
||||
var message = store.GetById(hit.MessageId);
|
||||
return message is null ? null : store.ReadRaw(message.RawPath);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 入站校验(SPF / DKIM / DMARC)自检。
|
||||
///
|
||||
/// 关键设计:**DNS 查询走固定记录的桩实现**,所以断言是确定性的、不依赖外网。
|
||||
/// 而 DKIM 部分特意包含「篡改必须失败」的反向用例 —— 2026-09-13 的 DKIM 事故就是
|
||||
/// 「验签和签名犯了同一个错,于是一直假通过」,任何验签实现都必须能被打假才算数。
|
||||
/// </summary>
|
||||
public static partial class SelfTest
|
||||
{
|
||||
private sealed class StubDns : IDnsLookup
|
||||
{
|
||||
public readonly Dictionary<string, string[]> Txt = new(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly Dictionary<string, string[]> Addresses = new(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly Dictionary<string, string[]> Mx = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<IReadOnlyList<string>> TxtAsync(string name, CancellationToken token) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Txt.TryGetValue(name, out var v) ? v : []);
|
||||
public Task<IReadOnlyList<string>> AddressesAsync(string name, CancellationToken token) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Addresses.TryGetValue(name, out var v) ? v : []);
|
||||
public Task<IReadOnlyList<string>> MxAsync(string name, CancellationToken token) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Mx.TryGetValue(name, out var v) ? v : []);
|
||||
}
|
||||
|
||||
private static void TestInboundAuth()
|
||||
{
|
||||
var config = new AppConfig
|
||||
{
|
||||
Domain = "wpy.email",
|
||||
Hostname = "mail.example.com",
|
||||
AdminEmail = "[email protected]",
|
||||
AdminPassword = "SelfTest-Password-12",
|
||||
DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-auth-{Guid.NewGuid().ToString("N")[..8]}"),
|
||||
Dkim = new DkimConfig { Enabled = true, Selector = "sel" },
|
||||
InboundAuth = new InboundAuthConfig { DnsTimeoutSeconds = 2 },
|
||||
};
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
var dns = new StubDns();
|
||||
var token = CancellationToken.None;
|
||||
var cfg = config.InboundAuth;
|
||||
|
||||
// ─────────────────────────── SPF
|
||||
dns.Txt["strict.example.com"] = ["v=spf1 ip4:203.0.113.0/24 -all"];
|
||||
var pass = Spf.EvaluateAsync("203.0.113.5", "mail.example.com", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:ip4 CIDR 命中 → pass", pass.Outcome == "pass", $"{pass.Outcome} {pass.Detail}");
|
||||
|
||||
var fail = Spf.EvaluateAsync("198.51.100.7", "mail.example.com", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:ip4 不命中且 -all → fail", fail.Outcome == "fail", $"{fail.Outcome} {fail.Detail}");
|
||||
|
||||
dns.Txt["soft.example.com"] = ["v=spf1 ~all"];
|
||||
var soft = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:~all → softfail", soft.Outcome == "softfail", soft.Outcome);
|
||||
|
||||
dns.Txt["neutral.example.com"] = ["v=spf1 ?all"];
|
||||
var neutral = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:?all → neutral", neutral.Outcome == "neutral", neutral.Outcome);
|
||||
|
||||
dns.Txt["inc.example.com"] = ["v=spf1 include:_spf.relay.net -all"];
|
||||
dns.Txt["_spf.relay.net"] = ["v=spf1 ip4:198.51.100.7 -all"];
|
||||
var included = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:include 命中 → pass", included.Outcome == "pass", included.Detail);
|
||||
|
||||
dns.Txt["inc2.example.com"] = ["v=spf1 include:_spf.other.net -all"];
|
||||
dns.Txt["_spf.other.net"] = ["v=spf1 ip4:203.0.113.1 -all"];
|
||||
var notIncluded = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:include 不命中 → 落到 -all 的 fail", notIncluded.Outcome == "fail", notIncluded.Outcome);
|
||||
|
||||
dns.Txt["a.example.com"] = ["v=spf1 a -all"];
|
||||
dns.Addresses["a.example.com"] = ["198.51.100.7"];
|
||||
var aMatch = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:a 机制按 A 记录命中", aMatch.Outcome == "pass", aMatch.Outcome);
|
||||
|
||||
dns.Txt["mx.example.com"] = ["v=spf1 mx -all"];
|
||||
dns.Mx["mx.example.com"] = ["mx1.example.com"];
|
||||
dns.Addresses["mx1.example.com"] = ["198.51.100.7"];
|
||||
var mxMatch = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:mx 机制按 MX 主机命中", mxMatch.Outcome == "pass", mxMatch.Outcome);
|
||||
|
||||
var missing = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:没有记录 → none", missing.Outcome == "none", missing.Outcome);
|
||||
|
||||
dns.Txt["dup.example.com"] = ["v=spf1 -all", "v=spf1 +all"];
|
||||
var dup = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:两条记录 → permerror", dup.Outcome == "permerror", dup.Outcome);
|
||||
|
||||
// 查询次数上限:链式 include 超过 10 次必须 permerror
|
||||
for (var i = 0; i < 14; i++)
|
||||
dns.Txt[$"chain{i}.example.com"] = [$"v=spf1 include:chain{i + 1}.example.com -all"];
|
||||
dns.Txt["chain14.example.com"] = ["v=spf1 ip4:203.0.113.9 -all"];
|
||||
var tooMany = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", dns, cfg, token).Result;
|
||||
Check("SPF:include 链超过 10 次查询 → permerror", tooMany.Outcome == "permerror", tooMany.Outcome);
|
||||
|
||||
var heloFallback = Spf.EvaluateAsync("198.51.100.7", "helo.example.com", "", dns, cfg, token).Result;
|
||||
Check("SPF:空 MAIL FROM 时回退用 HELO 域", heloFallback.Domain == "helo.example.com", heloFallback.Domain);
|
||||
|
||||
Check("SPF:组织域/宏展开",
|
||||
Spf.DomainOf("Bob <[email protected]>") == "example.com"
|
||||
&& Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "[email protected]", "h") == "ex.com/ex.com/bob",
|
||||
Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "[email protected]", "h"));
|
||||
|
||||
// ─────────────────────────── DKIM(用本机签名器造真实签名,再打假)
|
||||
var signer = DkimSigner.Create(config);
|
||||
Check("DKIM:签名器可用(自检用)", signer is not null, "未启用则跳过后面的验签");
|
||||
if (signer is not null)
|
||||
{
|
||||
var raw = BuildMessage("[email protected]", "收件人", "DKIM self-test 主题", "line-one ASCII marker\r\n第二行。\r\n");
|
||||
var signed = signer.Sign(raw);
|
||||
dns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue];
|
||||
|
||||
var okResult = DkimVerifier.VerifyAllAsync(signed, dns, token).Result;
|
||||
Check("DKIM:自己签的报文验签通过", okResult.Count == 1 && okResult[0].Outcome == "pass",
|
||||
okResult.Count == 0 ? "(没找到签名)" : $"{okResult[0].Outcome} {okResult[0].Detail}");
|
||||
|
||||
// 反向用例 1:改正文(用 ASCII 标记改,避免在 Latin1 视图里搜中文搜不到)
|
||||
var bad1 = DkimVerifier.VerifyAllAsync(Tamper(signed, "line-one ASCII marker", "line-one TAMPERED"), dns, token).Result;
|
||||
Check("DKIM:改正文后必须验签失败", bad1.Count > 0 && bad1[0].Outcome == "fail",
|
||||
bad1.Count == 0 ? "(没找到签名)" : bad1[0].Detail);
|
||||
|
||||
// 反向用例 2:改被签名的头(同样只能用 ASCII 片段做替换 —— Latin1 视图里搜不到 UTF-8 中文)
|
||||
var bad2 = DkimVerifier.VerifyAllAsync(Tamper(signed, "DKIM self-test", "DKIM tampered!!"), dns, token).Result;
|
||||
Check("DKIM:改主题后必须验签失败", bad2.Count > 0 && bad2[0].Outcome == "fail",
|
||||
bad2.Count == 0 ? "(没找到签名)" : bad2[0].Detail);
|
||||
|
||||
// 反向用例 2b:只翻转正文里的一个字节也必须失败(最严格的篡改用例)
|
||||
var flipped = (byte[])signed.Clone();
|
||||
var marker = Encoding.Latin1.GetBytes("line-one ASCII marker");
|
||||
var at = IndexOf(flipped, marker);
|
||||
if (at > 0) flipped[at + 3] ^= 0x01;
|
||||
var bad2b = DkimVerifier.VerifyAllAsync(flipped, dns, token).Result;
|
||||
Check("DKIM:正文翻转一个字节必须验签失败", at > 0 && bad2b.Count > 0 && bad2b[0].Outcome == "fail",
|
||||
at > 0 ? bad2b[0].Detail : "测试标记没找到");
|
||||
|
||||
// 反向用例 3:DNS 里换成别人的公钥
|
||||
var otherConfig = new AppConfig
|
||||
{
|
||||
Domain = config.Domain, Hostname = config.Hostname, AdminEmail = config.AdminEmail,
|
||||
AdminPassword = config.AdminPassword, DataDirectory = config.DataDirectory,
|
||||
Dkim = new DkimConfig { Enabled = true, Selector = "other" },
|
||||
};
|
||||
var other = DkimSigner.Create(otherConfig);
|
||||
dns.Txt[signer.RecordName + "." + config.Domain] = [other!.RecordValue];
|
||||
var bad3 = DkimVerifier.VerifyAllAsync(signed, dns, token).Result;
|
||||
Check("DKIM:公钥不匹配必须验签失败", bad3.Count > 0 && bad3[0].Outcome == "fail",
|
||||
bad3.Count == 0 ? "(没找到签名)" : bad3[0].Detail);
|
||||
|
||||
// 反向用例 4:公钥被吊销(p= 为空)
|
||||
dns.Txt[signer.RecordName + "." + config.Domain] = ["v=DKIM1; k=rsa; p="];
|
||||
var bad4 = DkimVerifier.VerifyAllAsync(signed, dns, token).Result;
|
||||
Check("DKIM:公钥 p= 为空(已吊销)→ fail", bad4.Count > 0 && bad4[0].Outcome == "fail",
|
||||
bad4.Count == 0 ? "(没找到签名)" : bad4[0].Detail);
|
||||
dns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue];
|
||||
|
||||
// 加前置头(我们入站校验要往报文前面插 Authentication-Results)不能破坏对方签名
|
||||
var withHeaders = InboundAuth.PrependHeaders(signed, "Authentication-Results: mail.example.com; spf=pass\r\nX-Spam-Score: 0\r\n");
|
||||
var stillOk = DkimVerifier.VerifyAllAsync(withHeaders, dns, token).Result;
|
||||
Check("DKIM:前面插入我们自己的头之后,对方签名依然有效",
|
||||
stillOk.Count == 1 && stillOk[0].Outcome == "pass", $"{stillOk.Count} 个签名");
|
||||
|
||||
var plain = DkimVerifier.VerifyAllAsync(BuildMessage("[email protected]", "x", "无签名", "正文"), dns, token).Result;
|
||||
Check("DKIM:没有签名的报文返回空列表", plain.Count == 0, $"{plain.Count} 个");
|
||||
}
|
||||
|
||||
// ─────────────────────────── DMARC
|
||||
dns.Txt["_dmarc.strict2.example.com"] = ["v=DMARC1; p=reject; rua=mailto:[email protected]"];
|
||||
var dmarcPass = Dmarc.EvaluateAsync("strict2.example.com", ("pass", "strict2.example.com"), [], dns, token).Result;
|
||||
Check("DMARC:SPF 对齐通过 + p=reject", dmarcPass.Outcome == "pass" && dmarcPass.Policy == "reject",
|
||||
$"{dmarcPass.Outcome}/{dmarcPass.Policy}");
|
||||
|
||||
var dmarcFail = Dmarc.EvaluateAsync("strict2.example.com", ("fail", "evil.net"), [], dns, token).Result;
|
||||
Check("DMARC:SPF 未对齐 → fail 且带策略", dmarcFail.Outcome == "fail" && dmarcFail.Policy == "reject",
|
||||
$"{dmarcFail.Outcome}/{dmarcFail.Policy}");
|
||||
|
||||
dns.Txt["_dmarc.relaxed.example.com"] = ["v=DMARC1; p=quarantine"];
|
||||
var relaxedAlign = Dmarc.EvaluateAsync("relaxed.example.com", ("pass", "mail.relaxed.example.com"), [], dns, token).Result;
|
||||
Check("DMARC:relaxed 对齐(子域算对齐)→ pass", relaxedAlign.Outcome == "pass", relaxedAlign.Detail);
|
||||
|
||||
dns.Txt["_dmarc.strictdomain.example.com"] = ["v=DMARC1; p=none; aspf=s"];
|
||||
var strictAlign = Dmarc.EvaluateAsync("strictdomain.example.com", ("pass", "mail.strictdomain.example.com"), [], dns, token).Result;
|
||||
Check("DMARC:aspf=s 时子域不算对齐 → fail", strictAlign.Outcome == "fail", strictAlign.Detail);
|
||||
|
||||
var dmarcDkim = Dmarc.EvaluateAsync("dkimalign.example.com", ("fail", "x"), [("pass", "dkimalign.example.com")],
|
||||
new StubDnsWithRecords(("_dmarc.dkimalign.example.com", "v=DMARC1; p=none")), token).Result;
|
||||
Check("DMARC:SPF 挂了但 DKIM 对齐 → 依然 pass", dmarcDkim.Outcome == "pass", dmarcDkim.Detail);
|
||||
|
||||
var noRecord = Dmarc.EvaluateAsync("nodmarc.example.com", ("fail", "x"), [], dns, token).Result;
|
||||
Check("DMARC:没有记录 → none", noRecord.Outcome == "none", noRecord.Outcome);
|
||||
|
||||
Check("DMARC:组织域判定(含 com.cn/co.uk 这类多段后缀)",
|
||||
Dmarc.OrganizationalDomain("a.b.example.co.uk") == "example.co.uk"
|
||||
&& Dmarc.OrganizationalDomain("mail.example.com") == "example.com"
|
||||
&& Dmarc.OrganizationalDomain("news.sina.com.cn") == "sina.com.cn",
|
||||
Dmarc.OrganizationalDomain("a.b.example.co.uk"));
|
||||
|
||||
// ─────────────────────────── 端到端:干净信 vs 冒名信
|
||||
var cleanDns = new StubDns();
|
||||
if (signer is not null)
|
||||
{
|
||||
var cleanRaw = signer.Sign(BuildMessage("[email protected]", "我", "正常邮件", "正文内容\r\n"));
|
||||
cleanDns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue];
|
||||
cleanDns.Txt["_dmarc." + config.Domain] = ["v=DMARC1; p=reject"];
|
||||
cleanDns.Txt[config.Domain] = ["v=spf1 ip4:203.0.113.0/24 -all"];
|
||||
|
||||
var clean = InboundAuth.CheckAsync(cleanRaw, "203.0.113.9", "mail.example.com", "[email protected]", config, token, cleanDns).Result;
|
||||
Check("端到端:SPF+DKIM+DMARC 全通过的邮件不判垃圾",
|
||||
clean.Spf == "pass" && clean.Dkim == "pass" && clean.Dmarc == "pass" && clean.Score == 0 && !clean.Spam,
|
||||
$"spf={clean.Spf} dkim={clean.Dkim} dmarc={clean.Dmarc} 分数={clean.Score}");
|
||||
Check("端到端:Authentication-Results 头写全了",
|
||||
clean.HeaderBlock.Contains("spf=pass") && clean.HeaderBlock.Contains("dkim=pass")
|
||||
&& clean.HeaderBlock.Contains("dmarc=pass") && clean.HeaderBlock.Contains("X-Spam-Score: 0"),
|
||||
clean.HeaderBlock.Replace("\r\n", " | ").Trim());
|
||||
|
||||
// 冒名信:外域 IP 假冒 wpy.email,无 DKIM,DMARC p=reject
|
||||
var spoof = InboundAuth.CheckAsync(BuildMessage("[email protected]", "我", "我是老板", "把钱转过来"),
|
||||
"198.51.100.7", "evil.example.net", "[email protected]", config, token, cleanDns).Result;
|
||||
Check("端到端:冒名邮件被判为垃圾(SPF fail + DMARC 失败)",
|
||||
spoof.Spf == "fail" && spoof.Dmarc == "fail" && spoof.Spam && spoof.Score >= 4,
|
||||
$"spf={spoof.Spf} dmarc={spoof.Dmarc} 分数={spoof.Score} 垃圾={spoof.Spam}");
|
||||
Check("端到端:默认不拒收(只投垃圾箱,可逆)", !spoof.Reject, $"Reject={spoof.Reject}");
|
||||
|
||||
var strictConfig = new AppConfig
|
||||
{
|
||||
Domain = config.Domain, Hostname = config.Hostname, AdminEmail = config.AdminEmail,
|
||||
AdminPassword = config.AdminPassword, DataDirectory = config.DataDirectory,
|
||||
Dkim = config.Dkim,
|
||||
InboundAuth = new InboundAuthConfig { RejectOnDmarcReject = true },
|
||||
};
|
||||
var strictSpoof = InboundAuth.CheckAsync(BuildMessage("[email protected]", "我", "我是老板", "把钱转过来"),
|
||||
"198.51.100.7", "evil.example.net", "[email protected]", strictConfig, token, cleanDns).Result;
|
||||
Check("端到端:打开 RejectOnDmarcReject 后 p=reject 的冒名信会被拒收", strictSpoof.Reject, $"Reject={strictSpoof.Reject}");
|
||||
|
||||
// 插入头之后报文仍可正常解析(不能把收信搞坏)
|
||||
var withAuth = InboundAuth.PrependHeaders(cleanRaw, clean.HeaderBlock);
|
||||
var reparsed = Mime.Parse(withAuth);
|
||||
Check("端到端:插入校验头后报文仍能正常解析",
|
||||
reparsed.Subject == "正常邮件" && reparsed.Text.Contains("正文内容"), $"{reparsed.Subject} / {reparsed.Text.Trim()}");
|
||||
}
|
||||
|
||||
Check("端到端:X-Spam-Reason 会说明判垃圾的理由",
|
||||
InboundAuth.CheckAsync(BuildMessage("[email protected]", "我", "x", "y"),
|
||||
"198.51.100.7", "evil.example.net", "[email protected]", config, token, cleanDns).Result
|
||||
.HeaderBlock.Contains("X-Spam-Reason:"), "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Fail("入站校验自检", $"抛出异常:{ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(config.DataDirectory, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>只带一条 DNS 记录的一次性桩(用于个别用例)。</summary>
|
||||
private sealed class StubDnsWithRecords(params (string Name, string Value)[] records) : IDnsLookup
|
||||
{
|
||||
public Task<IReadOnlyList<string>> TxtAsync(string name, CancellationToken token) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(records.Where(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(r => r.Value).ToArray());
|
||||
public Task<IReadOnlyList<string>> AddressesAsync(string name, CancellationToken token) => Task.FromResult<IReadOnlyList<string>>([]);
|
||||
public Task<IReadOnlyList<string>> MxAsync(string name, CancellationToken token) => Task.FromResult<IReadOnlyList<string>>([]);
|
||||
}
|
||||
|
||||
/// <summary>在字节层面做替换(用 Latin1 视图,1 字符 = 1 字节,不会动到别的字节)。</summary>
|
||||
private static byte[] Tamper(byte[] raw, string from, string to) =>
|
||||
Encoding.Latin1.GetBytes(Encoding.Latin1.GetString(raw).Replace(from, to));
|
||||
|
||||
private static int IndexOf(byte[] haystack, byte[] needle)
|
||||
{
|
||||
for (var i = 0; i + needle.Length <= haystack.Length; i++)
|
||||
{
|
||||
var hit = true;
|
||||
for (var j = 0; j < needle.Length; j++)
|
||||
if (haystack[i + j] != needle[j]) { hit = false; break; }
|
||||
if (hit) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static byte[] BuildMessage(string from, string toName, string subject, string body)
|
||||
{
|
||||
var text = $"From: <{from}>\r\nTo: <[email protected]>\r\nSubject: {subject}\r\n"
|
||||
+ $"Date: {Mime.FormatDate(DateTimeOffset.Now)}\r\nMessage-ID: <{Guid.NewGuid():N}@example.com>\r\n"
|
||||
+ "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\r\n\r\n" + body;
|
||||
return Encoding.UTF8.GetBytes(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 存储层自检:**同一套断言在 json 与 sqlite 两个后端上各跑一遍**,
|
||||
/// 保证两套实现语义一致(这样 Storage.Provider 才能真正做到一键切换/回滚)。
|
||||
/// </summary>
|
||||
public static partial class SelfTest
|
||||
{
|
||||
private static void TestStorageBackends()
|
||||
{
|
||||
foreach (var provider in new[] { "json", "sqlite" })
|
||||
{
|
||||
var config = new AppConfig
|
||||
{
|
||||
Domain = "wpy.email",
|
||||
Hostname = "mail.example.com",
|
||||
AdminEmail = "[email protected]",
|
||||
AdminPassword = "selftest-password-1234",
|
||||
DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-store-{provider}-{Guid.NewGuid().ToString("N")[..8]}"),
|
||||
Dkim = new DkimConfig { Enabled = false },
|
||||
Storage = new StorageConfig { Provider = provider },
|
||||
};
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
try { RunStorageChecks(config, provider); }
|
||||
catch (Exception ex) { Fail($"[{provider}] 存储自检", $"抛出异常:{ex.Message}"); }
|
||||
finally { try { Directory.Delete(config.DataDirectory, true); } catch { } }
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunStorageChecks(AppConfig config, string provider)
|
||||
{
|
||||
using var store = Program.CreateStore(config);
|
||||
var admin = config.AdminEmail;
|
||||
|
||||
Check($"[{provider}] 自动创建的管理员可认证", store.Authenticate(admin, config.AdminPassword) is not null, "");
|
||||
Check($"[{provider}] 错误口令被拒绝", store.Authenticate(admin, "wrong-password-1234") is null, "");
|
||||
Check($"[{provider}] 本地地址判定", store.IsLocalAddress(admin) && !store.IsLocalAddress("[email protected]"), "");
|
||||
|
||||
// ---- 报文原文往返(逐字节)----
|
||||
var raw = Mime.Build(new ComposeRequest("张三 <[email protected]>", "张三", [admin], [],
|
||||
"存储自检:中文主题", "存储自检正文,包含中文与全角标点()、——。"), config);
|
||||
var parsed = Mime.Parse(raw);
|
||||
var first = store.SaveMessage(new MailMessage
|
||||
{
|
||||
OwnerEmail = admin, Folder = "inbox", From = parsed.From, To = parsed.To, Subject = parsed.Subject,
|
||||
Text = parsed.Text, MessageId = parsed.MessageId, Date = DateTimeOffset.UtcNow, Unread = true,
|
||||
}, raw);
|
||||
|
||||
var readBack = store.ReadRaw(first.RawPath);
|
||||
Check($"[{provider}] 报文原文逐字节读回", readBack.SequenceEqual(raw), $"{raw.Length} 字节");
|
||||
|
||||
// ---- UID 单调 ----
|
||||
var second = store.SaveMessage(new MailMessage
|
||||
{
|
||||
OwnerEmail = admin, Folder = "inbox", From = "[email protected]", To = admin,
|
||||
Subject = "第二封", Text = "第二封正文", Date = DateTimeOffset.UtcNow.AddSeconds(1), Unread = true,
|
||||
}, raw);
|
||||
Check($"[{provider}] IMAP UID 同文件夹内递增", second.Uid == first.Uid + 1, $"{first.Uid} → {second.Uid}");
|
||||
Check($"[{provider}] 未读计数正确", store.CountUnseen(admin, "inbox") == 2, $"{store.CountUnseen(admin, "inbox")}");
|
||||
|
||||
// ---- 标记已读 / 星标 / 统计 ----
|
||||
store.MarkRead(admin, first.Id, true);
|
||||
var unreadAfter = store.CountUnseen(admin, "inbox");
|
||||
var stats = store.Stats(admin);
|
||||
var inboxCount = (int)stats.GetType().GetProperty("inbox")!.GetValue(stats)!;
|
||||
var unreadCount = (int)stats.GetType().GetProperty("unread")!.GetValue(stats)!;
|
||||
Check($"[{provider}] 标记已读后未读数下降", unreadAfter == 1 && unreadCount == 1, $"{unreadAfter}/{unreadCount}");
|
||||
Check($"[{provider}] 统计的收件箱总数", inboxCount == 2, $"{inboxCount}");
|
||||
|
||||
store.SetStar(admin, second.Id, true);
|
||||
var starred = (int)store.Stats(admin).GetType().GetProperty("starred")!.GetValue(store.Stats(admin))!;
|
||||
Check($"[{provider}] 星标计数", starred == 1, $"{starred}");
|
||||
|
||||
// ---- 分页与搜索 ----
|
||||
var (total, page) = store.ListMessagesPage(admin, "inbox", "", false, false, 1, 0);
|
||||
Check($"[{provider}] 分页:总数与页大小", total == 2 && page.Count == 1, $"total={total} 页={page.Count}");
|
||||
var (unreadTotal, _) = store.ListMessagesPage(admin, "inbox", "", true, false, 10, 0);
|
||||
Check($"[{provider}] 分页:未读过滤", unreadTotal == 1, $"{unreadTotal}");
|
||||
var hits = store.ListMessages(admin, "", "存储自检正文");
|
||||
Check($"[{provider}] 中文正文搜索命中", hits.Count == 1, $"命中 {hits.Count}");
|
||||
var miss = store.ListMessages(admin, "", "绝对不存在的关键词xyzzy");
|
||||
Check($"[{provider}] 搜索不误报", miss.Count == 0, $"命中 {miss.Count}");
|
||||
|
||||
// ---- 会话 ----
|
||||
var session = store.CreateSession(admin, 30);
|
||||
Check($"[{provider}] 会话创建与校验", store.GetSession(session.Token)?.Email == admin, "");
|
||||
store.RemoveSession(session.Token);
|
||||
Check($"[{provider}] 会话删除后失效", store.GetSession(session.Token) is null, "");
|
||||
|
||||
// ---- 队列 ----
|
||||
var queued = store.QueueOutbound(admin, ["[email protected]"], "队列自检", "正文", raw);
|
||||
var due = store.TakeDueQueue(10);
|
||||
Check($"[{provider}] 出站任务入队并可取出", due.Count == 1 && due[0].MessageId == queued.Id, $"{due.Count} 条");
|
||||
if (due.Count > 0)
|
||||
{
|
||||
store.CompleteQueue(due[0]);
|
||||
var queueStats = (int)store.Stats(admin).GetType().GetProperty("queue")!.GetValue(store.Stats(admin))!;
|
||||
Check($"[{provider}] 完成后队列清空", queueStats == 0, $"{queueStats}");
|
||||
var sent = store.GetById(queued.Id)!;
|
||||
Check($"[{provider}] 发件箱状态更新为 sent", sent.DeliveryStatus == "sent", sent.DeliveryStatus);
|
||||
}
|
||||
|
||||
// ---- 删除:inbox → trash → 永久 ----
|
||||
store.DeleteMessage(admin, first.Id, permanent: false);
|
||||
var moved = store.GetMessage(admin, first.Id);
|
||||
Check($"[{provider}] 删除先移入垃圾箱", moved?.Folder == "trash", moved?.Folder ?? "(null)");
|
||||
|
||||
// 永久删除 + 原文回收:用一封**内容唯一**的报文来验证。
|
||||
// (不能拿 first 来验:它的字节被别的邮件/队列项共享,按内容去重后本就不该被回收。)
|
||||
var doomedRaw = Mime.Build(new ComposeRequest("[email protected]", "删除自检", [admin], [],
|
||||
"永久删除自检专用报文 " + Guid.NewGuid().ToString("N")[..8], "这封邮件的字节应当独一无二,删除后原文必须一起消失。"), config);
|
||||
var doomed = store.SaveMessage(new MailMessage
|
||||
{
|
||||
OwnerEmail = admin, Folder = "inbox", From = "[email protected]", To = admin,
|
||||
Subject = "永久删除自检", Text = "唯一内容", Date = DateTimeOffset.UtcNow.AddMinutes(1), Unread = false,
|
||||
}, doomedRaw);
|
||||
var doomedPath = doomed.RawPath;
|
||||
store.DeleteMessage(admin, doomed.Id, permanent: true);
|
||||
Check($"[{provider}] 永久删除后查不到", store.GetMessage(admin, doomed.Id) is null, "");
|
||||
Check($"[{provider}] 永久删除同时回收了原文", ThrowsOnMissing(() => store.ReadRaw(doomedPath)), doomedPath);
|
||||
|
||||
// ---- 本地投递(同域收件人)----
|
||||
var delivered = store.DeliverLocal(admin, raw, "[email protected]");
|
||||
Check($"[{provider}] 本地投递入库为一封未读邮件", delivered is not null && delivered.Unread, "");
|
||||
|
||||
// ---- 附件 ----
|
||||
var attachmentStore = store.SaveAttachment(Encoding.UTF8.GetBytes("附件内容"), "测试.txt");
|
||||
Check($"[{provider}] 附件写入并读回",
|
||||
Encoding.UTF8.GetString(store.ReadAttachment(attachmentStore)) == "附件内容", "");
|
||||
|
||||
// ---- SQLite 专属:大对象按内容去重 ----
|
||||
if (store is SqliteStore)
|
||||
{
|
||||
var a = store.SaveRaw(raw);
|
||||
var b = store.SaveRaw(raw);
|
||||
Check("[sqlite] 相同报文内容按哈希去重(同一 blob)", a == b, $"{a} / {b}");
|
||||
var report = ((SqliteStore)store).StorageReport();
|
||||
Check("[sqlite] 大对象压缩生效(存储小于原文)",
|
||||
report.BlobStoredBytes > 0 && report.BlobStoredBytes < report.BlobRawBytes,
|
||||
$"{report.BlobRawBytes} → {report.BlobStoredBytes}");
|
||||
Check("[sqlite] 无孤儿大对象或可清理", report.Orphans == 0 || ((SqliteStore)store).Compact() >= 0,
|
||||
$"孤儿 {report.Orphans}");
|
||||
}
|
||||
|
||||
// ---- 彻底删除账号(--purge-user 用的就是这一套;两个后端必须同一契约)
|
||||
var doomedUser = $"purge-{provider}@wpy.email";
|
||||
store.CreateUser(doomedUser, "Str0ng-Pass-2026", "待删账号");
|
||||
store.CreateSession(doomedUser, 30);
|
||||
store.SaveVerificationCode(new VerificationCode
|
||||
{
|
||||
Email = doomedUser, Purpose = "register",
|
||||
Salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||
CodeHash = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
var doomedMail = store.SaveMessage(new MailMessage
|
||||
{
|
||||
OwnerEmail = doomedUser, Folder = "inbox", From = "[email protected]", To = doomedUser,
|
||||
Subject = "待删账号的邮件", Text = "内容 " + Guid.NewGuid().ToString("N")[..8],
|
||||
Date = DateTimeOffset.UtcNow, Unread = true,
|
||||
}, null);
|
||||
foreach (var mail in store.ListMessages(doomedUser, "", "")) store.DeleteMessage(doomedUser, mail.Id, permanent: true);
|
||||
Check($"[{provider}] 删除账号前邮件确实属于它", store.GetMessage(doomedUser, doomedMail.Id) is null, "");
|
||||
Check($"[{provider}] 删除账号返回成功", store.DeleteUser(doomedUser), "");
|
||||
Check($"[{provider}] 删除后查不到账号", store.FindUserAnyState(doomedUser) is null, "");
|
||||
Check($"[{provider}] 删除后会话与验证码一并清掉",
|
||||
store.ListSessions(doomedUser).Count == 0 && store.FindVerificationCode(doomedUser, "register") is null, "");
|
||||
Check($"[{provider}] 重复删除返回 false(幂等)", !store.DeleteUser(doomedUser), "");
|
||||
}
|
||||
|
||||
private static bool ThrowsOnMissing(Action action)
|
||||
{
|
||||
try { action(); return false; }
|
||||
catch { return true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// SMTP DATA 段的线上编码与还原。
|
||||
///
|
||||
/// 为什么要单独抽出来:
|
||||
/// DKIM 是对「签名那一刻的确切字节」做的哈希。任何在签名之后改写报文的行为
|
||||
/// (例如把裸 LF 换成 CRLF)都会让接收方算出的正文哈希对不上,签名直接失效。
|
||||
/// 因此约定:**先规范化行尾 → 再签名 → 传输阶段除 dot-stuffing 外不得改动任何字节**。
|
||||
/// 本类同时被发送侧与自检使用,保证两边行为一致。
|
||||
/// </summary>
|
||||
internal static class SmtpDataEncoder
|
||||
{
|
||||
private static readonly byte[] Crlf = [13, 10];
|
||||
|
||||
/// <summary>把报文规范化为统一的 CRLF 行尾形式(不加密、不加终止行)。应在 DKIM 签名之前调用。</summary>
|
||||
public static byte[] Normalize(byte[] message)
|
||||
{
|
||||
using var output = new MemoryStream(message.Length + 16);
|
||||
var index = 0;
|
||||
while (index < message.Length)
|
||||
{
|
||||
var current = message[index];
|
||||
if (current == (byte)'\r')
|
||||
{
|
||||
output.Write(Crlf);
|
||||
index += index + 1 < message.Length && message[index + 1] == (byte)'\n' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (current == (byte)'\n')
|
||||
{
|
||||
output.Write(Crlf);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
output.WriteByte(current);
|
||||
index++;
|
||||
}
|
||||
var bytes = output.ToArray();
|
||||
if (bytes.Length == 0 || !EndsWithCrlf(bytes))
|
||||
{
|
||||
var padded = new byte[bytes.Length + 2];
|
||||
Buffer.BlockCopy(bytes, 0, padded, 0, bytes.Length);
|
||||
padded[^2] = 13;
|
||||
padded[^1] = 10;
|
||||
return padded;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成 DATA 段实际要写出的字节:行首的点做 dot-stuffing,结尾补 CRLF 与单独一行的 "."。
|
||||
/// 除 dot-stuffing 外不改动任何字节,以保证与 DKIM 签名一致。
|
||||
/// </summary>
|
||||
public static byte[] Encode(byte[] normalizedMessage)
|
||||
{
|
||||
using var output = new MemoryStream(normalizedMessage.Length + 16);
|
||||
var atLineStart = true;
|
||||
foreach (var current in normalizedMessage)
|
||||
{
|
||||
if (atLineStart && current == (byte)'.') output.WriteByte((byte)'.'); // dot-stuffing
|
||||
output.WriteByte(current);
|
||||
atLineStart = current == (byte)'\n';
|
||||
}
|
||||
if (output.Length == 0 || output.GetBuffer()[output.Length - 1] != (byte)'\n') output.Write(Crlf);
|
||||
output.Write([(byte)'.', 13, 10]);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>接收侧还原:去掉终止行并做 dot-unstuffing(自检用于模拟收件端)。</summary>
|
||||
public static byte[] Decode(byte[] wire)
|
||||
{
|
||||
// 去掉结尾的 ".\r\n"
|
||||
var end = wire.Length;
|
||||
if (end >= 3 && wire[end - 3] == (byte)'.' && wire[end - 2] == 13 && wire[end - 1] == 10)
|
||||
end -= 3;
|
||||
else if (end >= 2 && wire[end - 2] == (byte)'.' && wire[end - 1] == 10)
|
||||
end -= 2;
|
||||
|
||||
using var output = new MemoryStream(end);
|
||||
var atLineStart = true;
|
||||
for (var index = 0; index < end; index++)
|
||||
{
|
||||
var current = wire[index];
|
||||
if (atLineStart && current == (byte)'.' && index + 1 < end && wire[index + 1] == (byte)'.')
|
||||
{
|
||||
index++; // 去掉填充的点
|
||||
}
|
||||
output.WriteByte(wire[index]);
|
||||
atLineStart = wire[index] == (byte)'\n';
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static bool EndsWithCrlf(byte[] bytes) =>
|
||||
bytes.Length >= 2 && bytes[^2] == 13 && bytes[^1] == 10;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 字节级 SMTP 行读取器。
|
||||
///
|
||||
/// 为什么不用 StreamReader:
|
||||
/// 1. StreamReader 只能返回字符串,8bit 正文会被字符集转换破坏;
|
||||
/// 2. StreamReader 会预读缓冲,若之后改从原始流直接读 DATA,属于正文的字节
|
||||
/// 可能已经被吞进它的缓冲区,造成命令/正文错位。
|
||||
/// 这里让命令与 DATA 共用同一份缓冲区,从根本上避免这两个问题。
|
||||
/// </summary>
|
||||
internal sealed class SmtpReader
|
||||
{
|
||||
private const int MaxCommandLine = 8192;
|
||||
private static readonly byte[] Crlf = [13, 10];
|
||||
|
||||
private readonly Stream stream;
|
||||
private readonly byte[] buffer = new byte[8192];
|
||||
private readonly MemoryStream line = new(MaxCommandLine);
|
||||
private int start;
|
||||
private int end;
|
||||
|
||||
public SmtpReader(Stream stream) => this.stream = stream;
|
||||
|
||||
/// <summary>读一行命令(不含 CRLF),连接关闭返回 null。</summary>
|
||||
public async Task<string?> ReadLineAsync(CancellationToken token)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (start >= end)
|
||||
{
|
||||
end = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token);
|
||||
start = 0;
|
||||
if (end <= 0) return null;
|
||||
}
|
||||
|
||||
var current = buffer[start++];
|
||||
if (current == (byte)'\n')
|
||||
{
|
||||
var bytes = line.ToArray();
|
||||
line.SetLength(0);
|
||||
var length = bytes.Length;
|
||||
if (length > 0 && bytes[length - 1] == (byte)'\r') length--;
|
||||
return Encoding.ASCII.GetString(bytes, 0, length);
|
||||
}
|
||||
|
||||
line.WriteByte(current);
|
||||
if (line.Length > MaxCommandLine) throw new InvalidOperationException("命令行过长,已断开。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>从同一缓冲区读取恰好 length 个字节(IMAP 的 literal 需要)。</summary>
|
||||
public async Task<byte[]> ReadExactlyAsync(int length, CancellationToken token)
|
||||
{
|
||||
if (length < 0) throw new InvalidOperationException("长度非法。");
|
||||
var result = new byte[length];
|
||||
var written = 0;
|
||||
while (written < length)
|
||||
{
|
||||
if (start >= end)
|
||||
{
|
||||
end = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token);
|
||||
start = 0;
|
||||
if (end <= 0) throw new IOException("连接在读取 literals 途中关闭。");
|
||||
}
|
||||
var take = Math.Min(length - written, end - start);
|
||||
Buffer.BlockCopy(buffer, start, result, written, take);
|
||||
start += take;
|
||||
written += take;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 DATA 段直到单独一行的 "."。按字节忠实处理并做 dot-unstuffing,
|
||||
/// 行尾统一为 CRLF。超过 maxBytes 抛 InvalidOperationException。
|
||||
/// </summary>
|
||||
public async Task<byte[]> ReadDataAsync(int maxBytes, CancellationToken token)
|
||||
{
|
||||
var message = new MemoryStream();
|
||||
line.SetLength(0);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (start >= end)
|
||||
{
|
||||
end = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token);
|
||||
start = 0;
|
||||
if (end <= 0) throw new IOException("连接在 DATA 中途关闭。");
|
||||
}
|
||||
|
||||
var current = buffer[start++];
|
||||
if (current != (byte)'\n')
|
||||
{
|
||||
line.WriteByte(current);
|
||||
if (line.Length > maxBytes) throw new InvalidOperationException("单行过长,已拒绝。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = line.ToArray();
|
||||
line.SetLength(0);
|
||||
if (text.Length > 0 && text[^1] == (byte)'\r') text = text[..^1];
|
||||
if (text.Length == 1 && text[0] == (byte)'.') break; // DATA 结束
|
||||
if (text.Length > 1 && text[0] == (byte)'.') text = text[1..]; // dot-unstuffing
|
||||
|
||||
message.Write(text);
|
||||
message.Write(Crlf);
|
||||
if (message.Length > maxBytes)
|
||||
throw new InvalidOperationException($"邮件超过 {maxBytes / 1024 / 1024} MB 上限,已拒绝。");
|
||||
}
|
||||
|
||||
return message.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 存储后端基准测试:同一份负载分别跑 JSON 与 SQLite 两套实现,输出可对比的数字。
|
||||
///
|
||||
/// 关注点(也是 JSON 实现真正的瓶颈):
|
||||
/// 1. 入库:JSON 每存一封都要把**全部邮件**重新序列化并整文件重写(O(N)/次);
|
||||
/// 2. 单条改动(标记已读):同上 —— 这是最容易被放大的操作(IMAP 客户端一打开收件箱就会批量改);
|
||||
/// 3. 列表 / 未读数 / 统计 / 搜索:JSON 全是全表扫描。
|
||||
/// </summary>
|
||||
public static class StorageBench
|
||||
{
|
||||
public static int Run(AppConfig baseConfig, int count, int mutationSamples)
|
||||
{
|
||||
count = Math.Clamp(count, 20, 20_000);
|
||||
mutationSamples = Math.Clamp(mutationSamples, 10, count);
|
||||
|
||||
Console.WriteLine($"=== 存储后端基准测试({count} 封邮件,单条改动抽样 {mutationSamples} 次)===");
|
||||
Console.WriteLine();
|
||||
|
||||
var root = Path.Combine(Path.GetTempPath(), "wpyw-bench-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var jsonDir = Path.Combine(root, "json");
|
||||
var sqliteDir = Path.Combine(root, "sqlite");
|
||||
Directory.CreateDirectory(jsonDir);
|
||||
Directory.CreateDirectory(sqliteDir);
|
||||
|
||||
var jsonConfig = Clone(baseConfig, jsonDir, "json", fullText: false);
|
||||
var sqliteConfig = Clone(baseConfig, sqliteDir, "sqlite", fullText: false);
|
||||
var ftsDir = Path.Combine(root, "sqlite-fts");
|
||||
Directory.CreateDirectory(ftsDir);
|
||||
var ftsConfig = Clone(baseConfig, ftsDir, "sqlite", fullText: true);
|
||||
|
||||
try
|
||||
{
|
||||
var json = Measure("JSON(整文件重写)", jsonConfig, count, mutationSamples);
|
||||
var sqlite = Measure("SQLite(WAL + 索引)", sqliteConfig, count, mutationSamples);
|
||||
var sqliteFts = Measure("SQLite + FTS5 全文索引", ftsConfig, count, mutationSamples);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"{"操作",-22}{"JSON",16}{"SQLite",16}{"提升",12}");
|
||||
Console.WriteLine(new string('-', 68));
|
||||
Row("入库 每封", json.InsertPerOp, sqlite.InsertPerOp, "ms");
|
||||
Row("标记已读 每次", json.MutatePerOp, sqlite.MutatePerOp, "ms");
|
||||
Row("收件箱首页(50条) 每次", json.ListPerOp, sqlite.ListPerOp, "ms");
|
||||
Row("未读数 每次", json.UnreadPerOp, sqlite.UnreadPerOp, "ms");
|
||||
Row("统计 每次", json.StatsPerOp, sqlite.StatsPerOp, "ms");
|
||||
Row("搜索 每次", json.SearchPerOp, sqlite.SearchPerOp, "ms");
|
||||
Row("入库总计", json.InsertTotal, sqlite.InsertTotal, "ms");
|
||||
Row("磁盘占用", json.Bytes, sqlite.Bytes, "B");
|
||||
Row("内存增量", json.Memory, sqlite.Memory, "B");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"明细:JSON → {json.Detail}");
|
||||
Console.WriteLine($" SQLite → {sqlite.Detail}");
|
||||
Console.WriteLine($" SQLite+FTS5 → {sqliteFts.Detail}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("全文检索开关的取舍(同一份数据):");
|
||||
Console.WriteLine($" 搜索 每次:LIKE 扫描 {sqlite.SearchPerOp:N2} ms / 占用 {sqlite.Bytes:N0} B" +
|
||||
$" ←→ FTS5 {sqliteFts.SearchPerOp:N2} ms / 占用 {sqliteFts.Bytes:N0} B" +
|
||||
$"(索引多占 {sqliteFts.Bytes - sqlite.Bytes:N0} B)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"磁盘对比:JSON 共 {json.Bytes:N0} B,SQLite 共 {sqlite.Bytes:N0} B" +
|
||||
(sqlite.Bytes > 0 ? $"({(double)json.Bytes / sqlite.Bytes:F2}×)" : ""));
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("结论:SQLite 的「入库」与「单条改动」耗时与邮件量基本无关(索引定位单行);");
|
||||
Console.WriteLine(" JSON 实现每次改动都要重写全部邮件,随邮件量增长呈超线性,且常驻内存随邮箱增长。");
|
||||
Console.WriteLine(" 列表首页两者都在毫秒级;SQLite 的优势随规模放大(内存与写放大不增长)。");
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(root, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void Row(string label, double a, double b, string unit)
|
||||
{
|
||||
var ratio = b > 0 ? a / b : 0;
|
||||
var gain = unit == "ms" && ratio > 1 ? $"{ratio:F1}× 快"
|
||||
: unit == "B" && ratio > 1 ? $"省 {100.0 * (a - b) / a:F0}%"
|
||||
: ratio is > 0 and < 1 ? $"{ratio:F2}×" : "—";
|
||||
Console.WriteLine($"{label,-22}{a,15:N2} {unit,-1}{b,14:N2} {unit,-1}{gain,11}");
|
||||
}
|
||||
|
||||
private sealed record Metrics(
|
||||
double InsertTotal, double InsertPerOp, double MutatePerOp,
|
||||
double ListPerOp, double UnreadPerOp, double StatsPerOp, double SearchPerOp,
|
||||
long Bytes, long Memory, string DbPath, string Detail);
|
||||
|
||||
private static Metrics Measure(string label, AppConfig config, int count, int mutationSamples)
|
||||
{
|
||||
using var store = config.Storage.Provider == "sqlite"
|
||||
? (IMailStore)new SqliteStore(config)
|
||||
: new FileStore(config);
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
var before = GC.GetTotalMemory(true);
|
||||
|
||||
// ---- 预生成负载(生成不计入耗时)----
|
||||
var payloads = new List<(MailMessage Message, byte[] Raw)>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var raw = Mime.Build(new ComposeRequest(
|
||||
$"sender{i % 37}@example.com", $"发件人 {i % 37}",
|
||||
new[] { config.AdminEmail }, [],
|
||||
$"基准测试邮件 #{i}:包含中文主题与正文",
|
||||
string.Join("\r\n", Enumerable.Repeat($"这是第 {i} 封测试邮件的正文内容,用于测量存储后端的写入与查询开销。", 12))),
|
||||
config);
|
||||
var parsed = Mime.Parse(raw);
|
||||
var message = new MailMessage
|
||||
{
|
||||
OwnerEmail = config.AdminEmail,
|
||||
Folder = i % 5 == 0 ? "sent" : "inbox",
|
||||
From = parsed.From,
|
||||
To = parsed.To,
|
||||
Subject = parsed.Subject,
|
||||
Text = parsed.Text,
|
||||
Html = parsed.Html,
|
||||
MessageId = parsed.MessageId,
|
||||
Date = DateTimeOffset.UtcNow.AddSeconds(-i),
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
Unread = true,
|
||||
DeliveryStatus = "received",
|
||||
};
|
||||
payloads.Add((message, raw));
|
||||
}
|
||||
|
||||
// ---- 1) 入库 ----
|
||||
var sw = Stopwatch.StartNew();
|
||||
foreach (var (message, raw) in payloads) store.SaveMessage(message, raw);
|
||||
sw.Stop();
|
||||
var insertTotal = sw.Elapsed.TotalMilliseconds;
|
||||
var insertPerOp = insertTotal / count;
|
||||
|
||||
// ---- 2) 单条改动(标记已读)----
|
||||
var sample = payloads.Take(mutationSamples).Select(p => p.Message.Id).ToArray();
|
||||
sw.Restart();
|
||||
foreach (var id in sample) store.MarkRead(config.AdminEmail, id, true);
|
||||
sw.Stop();
|
||||
var mutatePerOp = sw.Elapsed.TotalMilliseconds / sample.Length;
|
||||
|
||||
// ---- 3) 收件箱首页(与 API /api/messages 的真实路径一致:分页)----
|
||||
sw.Restart();
|
||||
for (var i = 0; i < 10; i++) store.ListMessagesPage(config.AdminEmail, "inbox", "", false, false, 50, 0);
|
||||
sw.Stop();
|
||||
var listPerOp = sw.Elapsed.TotalMilliseconds / 10;
|
||||
|
||||
// ---- 4) 未读数 ----
|
||||
sw.Restart();
|
||||
for (var i = 0; i < 20; i++) store.CountUnseen(config.AdminEmail, "inbox");
|
||||
sw.Stop();
|
||||
var unreadPerOp = sw.Elapsed.TotalMilliseconds / 20;
|
||||
|
||||
// ---- 5) 统计 ----
|
||||
sw.Restart();
|
||||
for (var i = 0; i < 20; i++) store.Stats(config.AdminEmail);
|
||||
sw.Stop();
|
||||
var statsPerOp = sw.Elapsed.TotalMilliseconds / 20;
|
||||
|
||||
// ---- 6) 搜索 ----
|
||||
sw.Restart();
|
||||
for (var i = 0; i < 10; i++) store.ListMessages(config.AdminEmail, "", "第 178 封");
|
||||
sw.Stop();
|
||||
var searchPerOp = sw.Elapsed.TotalMilliseconds / 10;
|
||||
|
||||
// ---- 7) 空间与内存 ----
|
||||
store.Persist();
|
||||
long bytes;
|
||||
var dbPath = "";
|
||||
var breakdown = "";
|
||||
if (store is SqliteStore sqlite)
|
||||
{
|
||||
var report = sqlite.StorageReport();
|
||||
bytes = report.DbBytes + report.WalBytes;
|
||||
dbPath = report.Database;
|
||||
breakdown = $"大对象 {report.BlobStoredBytes:N0} B(由 {report.BlobRawBytes:N0} B 原文压缩而来,{report.BlobGzipped} 个启用压缩)、" +
|
||||
$"正文列 {report.TextBytes:N0} B、空闲页 {report.FreeBytes:N0} B";
|
||||
}
|
||||
else
|
||||
{
|
||||
var rawBytes = DirectoryBytes(Path.Combine(config.DataDirectory, "raw"));
|
||||
var jsonBytes = new[] { "users.json", "messages.json", "queue.json", "sessions.json" }
|
||||
.Select(f => Path.Combine(config.DataDirectory, f))
|
||||
.Where(File.Exists).Sum(f => new FileInfo(f).Length);
|
||||
bytes = jsonBytes + rawBytes + DirectoryBytes(Path.Combine(config.DataDirectory, "attachments"));
|
||||
dbPath = config.DataDirectory;
|
||||
breakdown = $"JSON {jsonBytes:N0} B + raw 报文 {rawBytes:N0} B";
|
||||
}
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
var memory = GC.GetTotalMemory(true) - before;
|
||||
|
||||
Console.WriteLine($"{label}:入库 {insertTotal:N0} ms,每封 {insertPerOp:N2} ms," +
|
||||
$"标记已读 {mutatePerOp:N2} ms/次,占用 {bytes:N0} B");
|
||||
|
||||
return new Metrics(insertTotal, insertPerOp, mutatePerOp, listPerOp, unreadPerOp, statsPerOp, searchPerOp,
|
||||
bytes, memory, dbPath, breakdown);
|
||||
}
|
||||
|
||||
private static long DirectoryBytes(string path) =>
|
||||
Directory.Exists(path) ? Directory.GetFiles(path, "*", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length) : 0;
|
||||
|
||||
private static AppConfig Clone(AppConfig source, string dataDirectory, string provider, bool fullText) => new()
|
||||
{
|
||||
Domain = source.Domain,
|
||||
Hostname = source.Hostname,
|
||||
DataDirectory = dataDirectory,
|
||||
AdminEmail = source.AdminEmail,
|
||||
AdminPassword = source.AdminPassword,
|
||||
DeliveryMode = "direct",
|
||||
Dkim = new DkimConfig { Enabled = false },
|
||||
Storage = new StorageConfig { Provider = provider, FullTextSearch = fullText },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 存储后端之间的数据搬迁。
|
||||
///
|
||||
/// 正向(JSON → SQLite):把 users/messages/queue/sessions 全部导入数据库,
|
||||
/// 报文原文与附件读文件后进 blobs 表(gzip + 内容去重),并**逐封校验**
|
||||
/// 「从数据库读回来的字节」与「原文件字节」SHA256 完全一致,之后才允许删除源文件。
|
||||
///
|
||||
/// 反向(SQLite → JSON):把 blobs 还原成 raw/ 与 attachments/ 文件并重写 JSON,
|
||||
/// 用于一键回滚。
|
||||
/// </summary>
|
||||
public static class StorageMigration
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
|
||||
private static T? ReadJson<T>(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return default;
|
||||
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), Json); }
|
||||
catch (Exception ex) { AppLog.Error($"[迁移] 读取 {Path.GetFileName(path)} 失败:{ex.Message}"); return default; }
|
||||
}
|
||||
|
||||
private static long DirectoryBytes(string path) =>
|
||||
Directory.Exists(path) ? Directory.GetFiles(path, "*", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length) : 0;
|
||||
|
||||
private static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
|
||||
// ------------------------------------------------------------------ 正向
|
||||
|
||||
public static int ToSqlite(AppConfig config, bool deleteSourceFiles)
|
||||
{
|
||||
var dataDir = config.DataDirectory;
|
||||
var usersPath = Path.Combine(dataDir, "users.json");
|
||||
var messagesPath = Path.Combine(dataDir, "messages.json");
|
||||
var queuePath = Path.Combine(dataDir, "queue.json");
|
||||
var sessionsPath = Path.Combine(dataDir, "sessions.json");
|
||||
|
||||
var users = ReadJson<List<MailUser>>(usersPath) ?? [];
|
||||
var messages = ReadJson<List<MailMessage>>(messagesPath) ?? [];
|
||||
var queue = ReadJson<List<QueueItem>>(queuePath) ?? [];
|
||||
var sessions = ReadJson<List<SessionRecord>>(sessionsPath) ?? [];
|
||||
|
||||
if (messages.Count == 0 && users.Count == 0)
|
||||
{
|
||||
Console.WriteLine("没有可迁移的数据(users.json / messages.json 为空或不存在)。");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var beforeRaw = DirectoryBytes(Path.Combine(dataDir, "raw"));
|
||||
var beforeAttachments = DirectoryBytes(Path.Combine(dataDir, "attachments"));
|
||||
var beforeJson = new[] { usersPath, messagesPath, queuePath, sessionsPath }
|
||||
.Where(File.Exists).Sum(p => new FileInfo(p).Length);
|
||||
|
||||
Console.WriteLine($"源数据:用户 {users.Count},邮件 {messages.Count},队列 {queue.Count},会话 {sessions.Count}");
|
||||
Console.WriteLine($"源占用:raw {beforeRaw:N0} B + attachments {beforeAttachments:N0} B + JSON {beforeJson:N0} B");
|
||||
|
||||
using var store = new SqliteStore(config);
|
||||
var existing = (int)store.StorageReport().Messages;
|
||||
if (existing > 0)
|
||||
{
|
||||
Console.WriteLine($"数据库里已有 {existing} 封邮件,将按 id 覆盖导入(幂等)。");
|
||||
}
|
||||
|
||||
foreach (var user in users) store.ImportUser(user);
|
||||
|
||||
// UID 唯一性修复:数据库上有 (owner, folder, uid) 唯一索引
|
||||
var used = new HashSet<(string, string, int)>();
|
||||
var nextUid = new Dictionary<(string, string), int>();
|
||||
var verified = new List<(MailMessage Message, string OriginalSha, bool HadFile)>();
|
||||
var imported = 0;
|
||||
var missingRaw = 0;
|
||||
|
||||
foreach (var message in messages.OrderBy(m => m.Date))
|
||||
{
|
||||
message.OwnerEmail = (message.OwnerEmail ?? "").ToLowerInvariant();
|
||||
message.Folder = (message.Folder ?? "inbox").ToLowerInvariant();
|
||||
|
||||
var key = (message.OwnerEmail, message.Folder);
|
||||
if (!nextUid.TryGetValue(key, out var next)) next = 1;
|
||||
var uid = message.Uid;
|
||||
if (uid <= 0 || !used.Add((key.Item1, key.Item2, uid)))
|
||||
{
|
||||
while (used.Contains((key.Item1, key.Item2, next))) next++;
|
||||
uid = next;
|
||||
used.Add((key.Item1, key.Item2, uid));
|
||||
}
|
||||
message.Uid = uid;
|
||||
next = Math.Max(next, uid + 1);
|
||||
nextUid[key] = next;
|
||||
|
||||
// 报文原文:文件 → blobs
|
||||
var originalSha = "";
|
||||
var hadFile = false;
|
||||
if (!string.IsNullOrWhiteSpace(message.RawPath) && !message.RawPath.StartsWith("db:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var full = Path.Combine(dataDir, message.RawPath);
|
||||
if (File.Exists(full))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(full);
|
||||
originalSha = Sha(bytes);
|
||||
hadFile = true;
|
||||
message.RawPath = store.SaveRaw(bytes);
|
||||
message.Size = bytes.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
missingRaw++;
|
||||
message.RawPath = "";
|
||||
}
|
||||
}
|
||||
|
||||
// 附件:文件 → blobs
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.StoredAs) ||
|
||||
attachment.StoredAs.StartsWith("db:", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var full = Path.Combine(dataDir, attachment.StoredAs);
|
||||
if (File.Exists(full)) attachment.StoredAs = store.SaveAttachment(File.ReadAllBytes(full), attachment.FileName);
|
||||
}
|
||||
|
||||
store.SaveMessage(message);
|
||||
verified.Add((message, originalSha, hadFile));
|
||||
imported++;
|
||||
}
|
||||
|
||||
foreach (var item in queue) store.ImportQueueItem(item);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var session in sessions.Where(s => s.Expires > now)) store.ImportSession(session);
|
||||
|
||||
// ---- 校验:从数据库读回来的字节必须与原文件逐字节一致 ----
|
||||
Console.WriteLine("校验中(逐封比对 SHA256)……");
|
||||
var bad = 0;
|
||||
foreach (var (message, originalSha, hadFile) in verified)
|
||||
{
|
||||
if (!hadFile || message.RawPath.Length == 0) continue;
|
||||
try
|
||||
{
|
||||
var back = store.ReadRaw(message.RawPath);
|
||||
if (Sha(back) != originalSha)
|
||||
{
|
||||
bad++;
|
||||
Console.Error.WriteLine($" [不一致] {message.Id} {message.Subject}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
bad++;
|
||||
Console.Error.WriteLine($" [读取失败] {message.Id}:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var report = store.StorageReport();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"导入完成:邮件 {imported} 封,队列 {queue.Count} 条,会话 {sessions.Count} 个" +
|
||||
(missingRaw > 0 ? $",{missingRaw} 封缺少原始报文文件" : ""));
|
||||
Console.WriteLine($"入库大对象:原始 {report.BlobRawBytes:N0} B → 存储 {report.BlobStoredBytes:N0} B" +
|
||||
(report.BlobRawBytes > 0 ? $"({(100.0 * report.BlobStoredBytes / report.BlobRawBytes):F0}%,{report.BlobGzipped} 个压缩)" : ""));
|
||||
Console.WriteLine($"数据库文件:{report.DbBytes:N0} B + WAL {report.WalBytes:N0} B");
|
||||
Console.WriteLine($"校验结果:SHA256 不一致 {bad} 封");
|
||||
|
||||
if (bad > 0)
|
||||
{
|
||||
Console.Error.WriteLine("存在校验失败,已保留源文件,请勿删除。");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!deleteSourceFiles)
|
||||
{
|
||||
Console.WriteLine("源文件已保留(想释放空间请加 --delete-source 重跑)。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- 删源文件(校验已全部通过才走到这里)----
|
||||
var deleted = 0;
|
||||
long freed = 0;
|
||||
foreach (var file in Directory.GetFiles(Path.Combine(dataDir, "raw"), "*.eml", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
freed += new FileInfo(file).Length;
|
||||
File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
var attachmentDir = Path.Combine(dataDir, "attachments");
|
||||
foreach (var file in Directory.GetFiles(attachmentDir, "*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
freed += new FileInfo(file).Length;
|
||||
File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
Console.WriteLine($"已删除源文件 {deleted} 个,释放 {freed:N0} B");
|
||||
Console.WriteLine($"提示:JSON 索引文件(users/messages/queue/sessions.json)保留未删,便于随时回滚。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 反向
|
||||
|
||||
public static int ToJson(AppConfig config)
|
||||
{
|
||||
var dataDir = config.DataDirectory;
|
||||
using var store = new SqliteStore(config);
|
||||
|
||||
var messages = store.AllMessages().ToList();
|
||||
var users = store.AllUsers().ToList();
|
||||
var queue = store.AllQueueItems().ToList();
|
||||
var sessions = store.AllSessions().ToList();
|
||||
|
||||
var rawDir = Path.Combine(dataDir, "raw");
|
||||
var attachmentDir = Path.Combine(dataDir, "attachments");
|
||||
Directory.CreateDirectory(rawDir);
|
||||
Directory.CreateDirectory(attachmentDir);
|
||||
|
||||
var exported = new Dictionary<long, string>();
|
||||
foreach (var (id, data) in store.ExportBlobs())
|
||||
{
|
||||
// 同一个 blob 可能被多封邮件/附件引用,这里各导出一份文件(回滚场景不追求去重)
|
||||
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{id}{GuessExtension(data)}";
|
||||
var relative = Path.Combine("raw", name);
|
||||
File.WriteAllBytes(Path.Combine(dataDir, relative), data);
|
||||
exported[id] = relative;
|
||||
}
|
||||
|
||||
static string GuessExtension(byte[] data) =>
|
||||
data.Length >= 4 && data[0] == 0x50 && data[1] == 0x4B ? ".zip" : ".eml";
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.RawPath.StartsWith("db:", StringComparison.OrdinalIgnoreCase) &&
|
||||
long.TryParse(message.RawPath.AsSpan(3), out var blobId) && exported.TryGetValue(blobId, out var path))
|
||||
message.RawPath = path;
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
if (attachment.StoredAs.StartsWith("db:", StringComparison.OrdinalIgnoreCase) &&
|
||||
long.TryParse(attachment.StoredAs.AsSpan(3), out var attId) && exported.TryGetValue(attId, out var attPath))
|
||||
{
|
||||
var target = Path.Combine("attachments", Path.GetFileName(attPath));
|
||||
File.Copy(Path.Combine(dataDir, attPath), Path.Combine(dataDir, target), true);
|
||||
attachment.StoredAs = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(dataDir, "messages.json"), JsonSerializer.Serialize(messages, Json), new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(dataDir, "users.json"), JsonSerializer.Serialize(users, Json), new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(dataDir, "queue.json"), JsonSerializer.Serialize(queue, Json), new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(dataDir, "sessions.json"), JsonSerializer.Serialize(sessions, Json), new UTF8Encoding(false));
|
||||
|
||||
Console.WriteLine($"已导出:邮件 {messages.Count} 封,用户 {users.Count},队列 {queue.Count},会话 {sessions.Count}");
|
||||
Console.WriteLine($"报文文件 {exported.Count} 个 → {rawDir}");
|
||||
Console.WriteLine("把 appsettings.json 的 Storage.Provider 改回 json 即可用这套数据启动。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 状态
|
||||
|
||||
public static int Status(AppConfig config)
|
||||
{
|
||||
var dataDir = config.DataDirectory;
|
||||
var rawBytes = DirectoryBytes(Path.Combine(dataDir, "raw"));
|
||||
var attachmentBytes = DirectoryBytes(Path.Combine(dataDir, "attachments"));
|
||||
var jsonBytes = new[] { "users.json", "messages.json", "queue.json", "sessions.json" }
|
||||
.Select(f => Path.Combine(dataDir, f)).Where(File.Exists).Sum(p => new FileInfo(p).Length);
|
||||
|
||||
Console.WriteLine($"当前配置的后端:{config.Storage.Provider}");
|
||||
Console.WriteLine($"raw/ {rawBytes,12:N0} B ({Directory.GetFiles(Path.Combine(dataDir, "raw"), "*").Length} 个文件)");
|
||||
Console.WriteLine($"attachments/{attachmentBytes,12:N0} B ({Directory.GetFiles(Path.Combine(dataDir, "attachments"), "*").Length} 个文件)");
|
||||
Console.WriteLine($"JSON 索引 {jsonBytes,12:N0} B");
|
||||
|
||||
var dbPath = string.IsNullOrWhiteSpace(config.Storage.DatabasePath)
|
||||
? Path.Combine(dataDir, "wpywmail.db") : config.Storage.DatabasePath;
|
||||
if (!File.Exists(dbPath))
|
||||
{
|
||||
Console.WriteLine("SQLite 数据库:尚未创建");
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var store = new SqliteStore(config);
|
||||
var report = store.StorageReport();
|
||||
|
||||
Console.WriteLine($"SQLite {report.DbBytes + report.WalBytes,12:N0} B ({dbPath})");
|
||||
Console.WriteLine($" 邮件 {report.Messages} 封,大对象 {report.Blobs} 个" +
|
||||
$"(原始 {report.BlobRawBytes:N0} B → 存储 {report.BlobStoredBytes:N0} B," +
|
||||
$"其中 {report.BlobGzipped} 个启用了压缩)");
|
||||
Console.WriteLine($" 正文列合计 {report.TextBytes:N0} B(最大一封 {report.LargestTextBytes:N0} B:{Trim(report.LargestTextSubject)})");
|
||||
Console.WriteLine($" 页统计:{report.PageCount} 页 × {report.PageSize} B = {report.TotalBytes:N0} B," +
|
||||
$"其中空闲 {report.FreeBytes:N0} B" +
|
||||
(report.FreeBytes > 0 ? "(用 --vacuum 回收;注意 VACUUM 需要约 2 倍临时空间,且应在服务停止时做)" : ""));
|
||||
Console.WriteLine($" 孤儿大对象 {report.Orphans} 个(用 --compact 清理)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string Trim(string text) =>
|
||||
string.IsNullOrEmpty(text) ? "(无)" : text.Length <= 24 ? text : text[..24] + "…";
|
||||
|
||||
public static int Compact(AppConfig config)
|
||||
{
|
||||
using var store = new SqliteStore(config);
|
||||
var removed = store.Compact();
|
||||
Console.WriteLine($"已清理孤儿大对象 {removed} 个。");
|
||||
return Status(config);
|
||||
}
|
||||
|
||||
/// <summary>合并 WAL 并回收空闲页,把数据库文件压到实际大小。</summary>
|
||||
public static int Vacuum(AppConfig config)
|
||||
{
|
||||
using var store = new SqliteStore(config);
|
||||
var before = store.StorageReport();
|
||||
Console.WriteLine($"整理前:主库 {before.DbBytes:N0} B + WAL {before.WalBytes:N0} B,空闲页 {before.FreeBytes:N0} B");
|
||||
store.Vacuum(); // VACUUM:回收空闲页
|
||||
store.Persist(); // 再把 VACUUM 期间产生的 WAL 归并回主库并截断
|
||||
var after = store.StorageReport();
|
||||
Console.WriteLine($"整理后:主库 {after.DbBytes:N0} B + WAL {after.WalBytes:N0} B,空闲页 {after.FreeBytes:N0} B");
|
||||
Console.WriteLine($"实际占用:{(before.DbBytes + before.WalBytes):N0} B → {(after.DbBytes + after.WalBytes):N0} B" +
|
||||
$"(释放 {(before.DbBytes + before.WalBytes) - (after.DbBytes + after.WalBytes):N0} B)");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- 必须关闭 InvariantGlobalization,否则 GBK/GB2312 代码页不可用,非 UTF-8 的中文邮件会解码失败 -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
<AssemblyName>WpywMail.Native</AssemblyName>
|
||||
<RootNamespace>WpywMail.Native</RootNamespace>
|
||||
<Version>2.0.1</Version>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- 邮件存储后端:SQLite(单文件、WAL、带索引/事务)。随包发布原生库,无需外部安装。 -->
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"Domain": "wpy.email",
|
||||
"Hostname": "mail.example.com",
|
||||
"HttpPrefix": "http://127.0.0.1:8787/",
|
||||
"SmtpPort": 25,
|
||||
"SubmissionPort": 587,
|
||||
"DataDirectory": "C:\\WpywMailData",
|
||||
"AdminEmail": "[email protected]",
|
||||
"AdminPassword": "replace-with-a-long-password",
|
||||
"TlsCertificatePath": "C:\\WpywMailData\\certs\\mail.example.com.pfx",
|
||||
"TlsCertificatePassword": "replace-with-certificate-password",
|
||||
"DeliveryMode": "direct",
|
||||
"DirectDelivery": {
|
||||
"ConnectionTimeoutSeconds": 30,
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"DnsTimeoutSeconds": 5,
|
||||
"OpportunisticStartTls": true,
|
||||
"RequireStartTls": false,
|
||||
"DnsServer": "",
|
||||
"HeloName": ""
|
||||
},
|
||||
"Relay": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"User": "",
|
||||
"Password": "",
|
||||
"EnableSsl": true
|
||||
},
|
||||
"Retry": {
|
||||
"MaxAttempts": 12,
|
||||
"InitialDelaySeconds": 60,
|
||||
"MaxDelaySeconds": 3600,
|
||||
"RetryOnPermanentFailure": true,
|
||||
"MaxAttemptsForPermanent": 3,
|
||||
"SendBounceNotification": true
|
||||
},
|
||||
"Dkim": {
|
||||
"Enabled": true,
|
||||
"Selector": "mail",
|
||||
"SigningDomain": "",
|
||||
"PrivateKeyPath": "",
|
||||
"Headers": [
|
||||
"From",
|
||||
"To",
|
||||
"Subject",
|
||||
"Date",
|
||||
"Message-ID",
|
||||
"MIME-Version",
|
||||
"Content-Type",
|
||||
"Content-Transfer-Encoding"
|
||||
]
|
||||
},
|
||||
"Api": {
|
||||
"SessionDays": 30,
|
||||
"CorsOrigin": "*",
|
||||
"LongPollSeconds": 25
|
||||
},
|
||||
"Smtp": {
|
||||
"MaxMessageBytes": 26214400,
|
||||
"AdvertiseStartTls": true,
|
||||
"AllowAuthOnInbound": false,
|
||||
"AddReceivedHeader": true,
|
||||
"AuthFailuresBeforeBan": 8,
|
||||
"BanMinutes": 15,
|
||||
"EnforceSenderMatch": true
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
@@ -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"
|
||||
@@ -0,0 +1,446 @@
|
||||
<#
|
||||
账号体系真实验收(在邮件服务器本机执行)
|
||||
|
||||
为什么必须跑真机:这套东西的价值全在「真的能注册、真的能登录、验证码邮件真的进得了信箱」,
|
||||
单元自检(--selftest)只能证明存储层与业务层的逻辑,证明不了 HTTP 链路、IMAP 链路、
|
||||
以及「验证码邮件是否真的投递到了客户端读得到的地方」。
|
||||
|
||||
覆盖:
|
||||
A 策略接口、B 邀请码、C 本机托管地址注册即开通(含死循环回归)、D 会话/资料/审计、
|
||||
E 真实 IMAP 993 登录 + 收件箱非空、F 忘记密码→读信取码→重置、G 改密踢其他会话、
|
||||
H 登录失败锁定(423 + retryAfterSeconds)、I 管理员视角、J 停用后不能登录(并清理测试账号)
|
||||
|
||||
用法:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1
|
||||
退出码 = 失败项数(0 = 全通过)。
|
||||
#>
|
||||
param(
|
||||
[string]$Base = 'http://127.0.0.1:8787',
|
||||
[string]$ImapHost = '127.0.0.1',
|
||||
[int]$ImapPort = 993,
|
||||
[string]$ImapName = 'mail.example.com',
|
||||
[string]$Report = 'C:\Windows\Temp\wpyw-acct-verify.txt',
|
||||
[string]$AppSettings = 'C:\Program Files\WpywMail\appsettings.json'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
|
||||
|
||||
$script:pass = 0
|
||||
$script:fail = 0
|
||||
$script:skip = 0
|
||||
# 注意:PowerShell 变量名大小写不敏感,这里不能叫 $script:report —— 会和参数 $Report 撞车
|
||||
$script:logLines = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Say([string]$line) {
|
||||
Write-Host $line
|
||||
$script:logLines.Add($line)
|
||||
}
|
||||
function Ok([string]$name, [bool]$cond, [string]$detail = '') {
|
||||
if ($cond) { $script:pass++; Say ("[PASS] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
|
||||
else { $script:fail++; Say ("[FAIL] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
|
||||
}
|
||||
function Skip([string]$name, [string]$why = '') {
|
||||
$script:skip++
|
||||
Say ("[SKIP] {0}{1}" -f $name, $(if ($why) { " —— $why" } else { '' }))
|
||||
}
|
||||
|
||||
function Invoke-Api {
|
||||
param([string]$Method, [string]$Path, $Body = $null, [string]$Token = '')
|
||||
$headers = @{}
|
||||
if ($Token) { $headers['Authorization'] = "Bearer $Token" }
|
||||
$params = @{
|
||||
Uri = ($Base + $Path); Method = $Method; Headers = $headers
|
||||
UseBasicParsing = $true; TimeoutSec = 30
|
||||
}
|
||||
if ($null -ne $Body) {
|
||||
# 必须按 UTF-8 字节发,不能直接传字符串:控制台是 GBK,中文会变问号
|
||||
$params['Body'] = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress -Depth 8))
|
||||
$params['ContentType'] = 'application/json; charset=utf-8'
|
||||
}
|
||||
try {
|
||||
$r = Invoke-WebRequest @params
|
||||
$json = $null
|
||||
try { $json = $r.Content | ConvertFrom-Json } catch { }
|
||||
return @{ Code = [int]$r.StatusCode; Json = $json; Raw = $r.Content }
|
||||
} catch {
|
||||
$resp = $_.Exception.Response
|
||||
$code = 0
|
||||
$raw = ''
|
||||
if ($null -ne $resp) {
|
||||
try { $code = [int]$resp.StatusCode } catch { }
|
||||
try {
|
||||
$reader = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8)
|
||||
$raw = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
} catch { }
|
||||
}
|
||||
# ⚠ PowerShell 5.1 的坑:非 2xx 响应体常常已经被它的错误格式化逻辑读掉了,
|
||||
# 这时 GetResponseStream() 读出来是空的 —— 必须回退到 ErrorDetails.Message。
|
||||
if (-not $raw) {
|
||||
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $raw = $_.ErrorDetails.Message } } catch { }
|
||||
}
|
||||
$json = $null
|
||||
if ($raw) { try { $json = $raw | ConvertFrom-Json } catch { } }
|
||||
return @{ Code = $code; Json = $json; Raw = $raw }
|
||||
}
|
||||
}
|
||||
|
||||
function Field($obj, [string]$name, $fallback = $null) {
|
||||
if ($null -eq $obj) { return $fallback }
|
||||
$p = $obj.PSObject.Properties[$name]
|
||||
if ($null -ne $p -and $null -ne $p.Value) { return $p.Value }
|
||||
return $fallback
|
||||
}
|
||||
|
||||
# /api/login 直接把会话对象摊在顶层({token,expiresAt,user}),/api/register 则包在 session 里。
|
||||
# 两种形状都得认,否则会静默取到空 token,后面全用着已失效的 token 连锁失败(踩过)。
|
||||
function Get-Token($json) {
|
||||
$t = [string](Field (Field $json 'session') 'token' '')
|
||||
if (-not $t) { $t = [string](Field $json 'token' '') }
|
||||
return $t
|
||||
}
|
||||
|
||||
function Wait-InboxMessage {
|
||||
param([string]$Token, [string]$SubjectLike, [int]$Seconds = 40)
|
||||
for ($i = 0; $i -lt $Seconds; $i++) {
|
||||
$r = Invoke-Api 'GET' '/api/messages?folder=inbox&limit=20' $null $Token
|
||||
$list = Field $r.Json 'messages'
|
||||
if ($r.Code -eq 200 -and $null -ne $list) {
|
||||
foreach ($m in $list) {
|
||||
$s = [string](Field $m 'subject' '')
|
||||
if ($s -like $SubjectLike) { return $m }
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-ImapUntil {
|
||||
param($Reader, [string]$Tag, [int]$MaxLines = 500)
|
||||
$out = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 0; $i -lt $MaxLines; $i++) {
|
||||
$line = $Reader.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
$out.Add($line)
|
||||
if ($line.StartsWith($Tag + ' ')) { break }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
# 真实 IMAP 客户端:连 993(隐式 TLS)→ LOGIN → SELECT INBOX → UID SEARCH ALL
|
||||
function Test-ImapLogin {
|
||||
param([string]$Email, [string]$Password)
|
||||
$res = @{ Ok = $false; Detail = ''; Count = -1; Select = '' }
|
||||
$tcp = New-Object Net.Sockets.TcpClient
|
||||
try {
|
||||
$tcp.Connect($ImapHost, $ImapPort)
|
||||
$tcp.ReceiveTimeout = 20000
|
||||
$cb = [Net.Security.RemoteCertificateValidationCallback] { param($a, $b, $c, $d) return $true }
|
||||
$ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, $cb)
|
||||
$ssl.AuthenticateAsClient($ImapName)
|
||||
$reader = New-Object IO.StreamReader($ssl, [Text.Encoding]::UTF8)
|
||||
$writerEncoding = New-Object Text.UTF8Encoding($false)
|
||||
$writer = New-Object IO.StreamWriter($ssl, $writerEncoding)
|
||||
$writer.NewLine = "`r`n"
|
||||
$writer.AutoFlush = $true
|
||||
|
||||
$greeting = $reader.ReadLine()
|
||||
if ($greeting -notmatch '^\* OK') { $res.Detail = "问候语异常: $greeting"; return $res }
|
||||
|
||||
$writer.WriteLine("a1 LOGIN $Email $Password")
|
||||
$login = Read-ImapUntil $reader 'a1'
|
||||
$loginText = ($login -join ' | ')
|
||||
if ($loginText -notmatch 'a1 OK') { $res.Detail = "LOGIN 失败: $loginText"; return $res }
|
||||
|
||||
$writer.WriteLine('a2 SELECT INBOX')
|
||||
$sel = Read-ImapUntil $reader 'a2'
|
||||
$selText = ($sel -join ' ')
|
||||
if ($selText -notmatch 'a2 OK') { $res.Detail = "SELECT 失败: $selText"; return $res }
|
||||
|
||||
$exists = 0
|
||||
$m = [regex]::Match($selText, '\*\s+(\d+)\s+EXISTS')
|
||||
if ($m.Success) { $exists = [int]$m.Groups[1].Value }
|
||||
|
||||
$writer.WriteLine('a3 UID SEARCH ALL')
|
||||
$search = Read-ImapUntil $reader 'a3'
|
||||
$searchText = ($search -join ' ')
|
||||
$uidCount = -1
|
||||
$sm = [regex]::Match($searchText, '\*\s+SEARCH([\d\s]*)')
|
||||
if ($sm.Success) {
|
||||
$ids = ($sm.Groups[1].Value -split '\s+' | Where-Object { $_ -ne '' })
|
||||
$uidCount = $ids.Count
|
||||
}
|
||||
|
||||
$writer.WriteLine('a4 LOGOUT')
|
||||
[void](Read-ImapUntil $reader 'a4')
|
||||
|
||||
$res.Ok = $true
|
||||
$res.Count = $uidCount
|
||||
$res.Select = "EXISTS=$exists UIDSEARCH=$uidCount"
|
||||
return $res
|
||||
} catch {
|
||||
$res.Detail = $_.Exception.Message
|
||||
return $res
|
||||
} finally {
|
||||
try { $tcp.Close() } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Set-UserActive {
|
||||
param([string]$Token, [string]$Email, [bool]$Active)
|
||||
$path = '/api/admin/users/' + [Uri]::EscapeDataString($Email)
|
||||
return Invoke-Api 'PATCH' $path @{ active = $Active } $Token
|
||||
}
|
||||
|
||||
# ================================================================ 开始
|
||||
Say ("WpywMail 账号体系真实验收 {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))
|
||||
Say ("目标 API:{0} IMAP:{1}:{2}" -f $Base, $ImapHost, $ImapPort)
|
||||
Say ''
|
||||
|
||||
# 从服务器自己的配置里取管理员口令与邀请码(不硬编码,避免和部署配置不一致)
|
||||
$cfg = [System.IO.File]::ReadAllText($AppSettings) | ConvertFrom-Json
|
||||
$invite = $cfg.Accounts.InviteCode
|
||||
$adminEmail = $cfg.AdminEmail
|
||||
$adminPassword = $cfg.AdminPassword
|
||||
$domain = $cfg.Domain
|
||||
Say ("配置:域名={0} 邀请码长度={1} 管理员={2}" -f $domain, $invite.Length, $adminEmail)
|
||||
Say ''
|
||||
|
||||
$suffix = (Get-Random -Minimum 100000 -Maximum 999999)
|
||||
$newEmail = "selftest-$suffix@$domain"
|
||||
$newPassword = "Selftest-Pass-$suffix"
|
||||
$resetPassword = "Reset-Pass-$suffix"
|
||||
$lockEmail = "locktest-$suffix@$domain"
|
||||
$lockPassword = "Locktest-Pass-$suffix"
|
||||
$tempAccounts = @($newEmail, $lockEmail)
|
||||
|
||||
# 管理员先登录:后面「注册配额用尽」时要靠它兜底建号,最后的管理员用例也复用它
|
||||
$adminLogin = Invoke-Api 'POST' '/api/login' @{ email = $adminEmail; password = $adminPassword }
|
||||
$adminToken = Get-Token $adminLogin.Json
|
||||
Ok 'A0 管理员可以登录' ($adminLogin.Code -eq 200 -and $adminToken.Length -gt 20) ("HTTP " + $adminLogin.Code + " " + [string](Field $adminLogin.Json 'error'))
|
||||
|
||||
# ---------------------------------------------------------------- A 策略
|
||||
$ver = Invoke-Api 'GET' '/api/version'
|
||||
Ok 'A1 /api/version 可达' ($ver.Code -eq 200) ("HTTP " + $ver.Code)
|
||||
|
||||
$pol = Invoke-Api 'GET' '/api/auth/policy'
|
||||
Ok 'A2 策略接口可达' ($pol.Code -eq 200) ("HTTP " + $pol.Code)
|
||||
Ok 'A3 注册模式 = invite' ((Field $pol.Json 'registration') -eq 'invite') ([string](Field $pol.Json 'registration'))
|
||||
Ok 'A4 需要邀请码标记' ((Field $pol.Json 'inviteRequired') -eq $true) ([string](Field $pol.Json 'inviteRequired'))
|
||||
$allowed = Field $pol.Json 'allowedDomains'
|
||||
Ok 'A5 允许域名包含本机域' ($allowed -contains $domain) ([string]::Join(',', $allowed))
|
||||
Ok 'A6 密码最短长度 >= 8' (([int](Field $pol.Json 'minPasswordLength' 0)) -ge 8) ([string](Field $pol.Json 'minPasswordLength'))
|
||||
$note = [string](Field $pol.Json 'verificationNote' '')
|
||||
Ok 'A7 策略里说明了「本机托管地址免验证」的原因' ($note.Length -gt 10) $note
|
||||
|
||||
# ---------------------------------------------------------------- B/C 注册
|
||||
$badInvite = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = 'WRONG-CODE'
|
||||
}
|
||||
Ok 'B1 邀请码错误被拒(403)' ($badInvite.Code -eq 403) ([string](Field $badInvite.Json 'error'))
|
||||
|
||||
$badDomain = Invoke-Api 'POST' '/api/register' @{
|
||||
email = "nobody-$suffix@example.com"; password = $newPassword; displayName = '外部域'; inviteCode = $invite
|
||||
}
|
||||
Ok 'B2 白名单外的域名被拒(400)' ($badDomain.Code -eq 400) ([string](Field $badDomain.Json 'error'))
|
||||
|
||||
$weak = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = '12345678'; displayName = '弱密码'; inviteCode = $invite
|
||||
}
|
||||
Ok 'B3 弱密码被拒(400)' ($weak.Code -eq 400) ([string](Field $weak.Json 'error'))
|
||||
|
||||
$reg = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = $invite
|
||||
}
|
||||
$session = Field $reg.Json 'session'
|
||||
$token = [string](Field $session 'token' '')
|
||||
if ($reg.Code -eq 429) {
|
||||
# 一小时内重复跑本脚本会撞到 IP 配额(配额按「真的建出的账号数」计,见 AccountService)。
|
||||
# 这不是缺陷,但要如实标注,并改用管理员接口建号,让后面的 40 多项用例照常跑完。
|
||||
Skip 'C1/C2/C3 自助注册链路' '本小时该 IP 的注册配额已用尽(重复运行脚本所致);已改用管理员接口建号继续验收'
|
||||
$boot = Invoke-Api 'POST' '/api/admin/users' @{ email = $newEmail; password = $newPassword; displayName = '验收账号' } $adminToken
|
||||
Ok 'C1b 配额用尽时管理员接口可以建号' ($boot.Code -eq 201) ("HTTP " + $boot.Code + " " + [string](Field $boot.Json 'error'))
|
||||
$boot2 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
$token = Get-Token $boot2.Json
|
||||
Ok 'C1c 兜底建的号可以登录' ($boot2.Code -eq 200 -and $token.Length -gt 20) ("HTTP " + $boot2.Code)
|
||||
} else {
|
||||
Ok 'C1 本机域注册成功(201)' ($reg.Code -eq 201) ("HTTP " + $reg.Code + " " + [string](Field $reg.Json 'error'))
|
||||
Ok 'C2 本机域注册不需要邮箱验证(死循环回归)' ((Field $reg.Json 'verificationRequired') -eq $false) ([string](Field $reg.Json 'verificationRequired'))
|
||||
Ok 'C3 注册直接返回会话 token' ($token.Length -gt 20) ("token 长度 " + $token.Length)
|
||||
}
|
||||
|
||||
$me = Invoke-Api 'GET' '/api/me' $null $token
|
||||
Ok 'C4 注册后的 token 可访问 /api/me' ($me.Code -eq 200) ("HTTP " + $me.Code)
|
||||
Ok 'C5 /api/me 返回的账号一致' (([string](Field (Field $me.Json 'user') 'email' '')) -eq $newEmail) ([string](Field (Field $me.Json 'user') 'email' ''))
|
||||
|
||||
$login1 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
$token2 = Get-Token $login1.Json
|
||||
Ok 'C6 新账号可以正常登录' ($login1.Code -eq 200 -and $token2.Length -gt 20) ("HTTP " + $login1.Code)
|
||||
if (-not $token) { $token = $token2 }
|
||||
|
||||
# ---------------------------------------------------------------- D 会话 / 资料 / 审计
|
||||
$prof = Invoke-Api 'PATCH' '/api/account/profile' @{ displayName = "验收账号-$suffix" } $token
|
||||
Ok 'D1 修改显示名成功' ($prof.Code -eq 200) ([string](Field $prof.Json 'error'))
|
||||
$me2 = Invoke-Api 'GET' '/api/me' $null $token
|
||||
Ok 'D2 显示名已生效' (([string](Field (Field $me2.Json 'user') 'displayName' '')) -eq "验收账号-$suffix") ([string](Field (Field $me2.Json 'user') 'displayName' ''))
|
||||
|
||||
$sess = Invoke-Api 'GET' '/api/account/sessions' $null $token
|
||||
$sessList = Field $sess.Json 'sessions'
|
||||
Ok 'D3 会话列表可读且包含当前会话' ($sess.Code -eq 200 -and $null -ne ($sessList | Where-Object { (Field $_ 'current') -eq $true })) ("共 " + @($sessList).Count + " 个会话")
|
||||
|
||||
$audit = Invoke-Api 'GET' '/api/account/audit?limit=50' $null $token
|
||||
$events = Field $audit.Json 'events'
|
||||
$reasons = @($events | ForEach-Object { [string](Field $_ 'reason' '') })
|
||||
Ok 'D4 审计里能看到 register 事件' ($reasons -contains 'register') ([string]::Join(',', $reasons))
|
||||
Ok 'D5 审计里能看到 login-ok 事件' ($reasons -contains 'login-ok') ''
|
||||
Ok 'D6 审计里能看到 profile-updated 事件' ($reasons -contains 'profile-updated') ''
|
||||
|
||||
# ---------------------------------------------------------------- E 真实 IMAP
|
||||
$imap = Test-ImapLogin $newEmail $newPassword
|
||||
Ok 'E1 新账号能用真实 IMAP(993 隐式 TLS) 登录' $imap.Ok ([string]$imap.Detail)
|
||||
Ok 'E2 IMAP SELECT INBOX 成功' ($imap.Ok -and $imap.Select -ne '') ([string]$imap.Select)
|
||||
|
||||
# 自己给自己发一封中文邮件,验证本地投递进了这个新信箱
|
||||
$send = Invoke-Api 'POST' '/api/send' @{
|
||||
to = $newEmail; subject = "账号验收邮件 $suffix"; text = "这封邮件用来验证新注册账号的信箱能收信。编号 $suffix"
|
||||
} $token
|
||||
Ok 'E3 新账号可以发信(入队 202)' ($send.Code -eq 202) ("HTTP " + $send.Code + " " + [string](Field $send.Json 'error'))
|
||||
|
||||
$landed = Wait-InboxMessage $token "账号验收邮件*" 40
|
||||
$landedId = [string](Field $landed 'id' '')
|
||||
Ok 'E4 自己发的邮件已投递进收件箱' ($null -ne $landed -and $landedId.Length -gt 0) ([string](Field $landed 'subject' '(未收到)'))
|
||||
|
||||
$imap2 = Test-ImapLogin $newEmail $newPassword
|
||||
Ok 'E5 IMAP 收件箱计数 >= 1' ($imap2.Ok -and $imap2.Count -ge 1) ([string]$imap2.Select)
|
||||
|
||||
# ---------------------------------------------------------------- F 忘记密码 → 读信取码 → 重置
|
||||
$forgot = Invoke-Api 'POST' '/api/auth/forgot' @{ email = $newEmail }
|
||||
Ok 'F1 申请重置密码返回成功' ($forgot.Code -eq 200) ("HTTP " + $forgot.Code + " " + [string](Field $forgot.Json 'error'))
|
||||
|
||||
$resetMail = Wait-InboxMessage $token '*重置密码验证码*' 40
|
||||
$resetId = [string](Field $resetMail 'id' '')
|
||||
Ok 'F2 重置验证码邮件已投进收件箱' ($resetId.Length -gt 0) ([string](Field $resetMail 'subject' '(未收到)'))
|
||||
|
||||
$code = ''
|
||||
if ($resetId) {
|
||||
$detail = Invoke-Api 'GET' ("/api/messages/" + [Uri]::EscapeDataString($resetId)) $null $token
|
||||
$msg = Field $detail.Json 'message'
|
||||
$body = [string](Field $msg 'text' '')
|
||||
if (-not $body) { $body = [string](Field $msg 'html' '') }
|
||||
# ⚠ 必须锚定「验证码:」这个标签:邮件正文里还写着收件人地址(selftest-123456@…),
|
||||
# 直接抓第一个 6 位数字会抓到地址里的数字,测试自己就成了假失败源。
|
||||
$cm = [regex]::Match($body, '验证码[::]\s*(\d{6})')
|
||||
if (-not $cm.Success) {
|
||||
$all = [regex]::Matches($body, '\b(\d{6})\b')
|
||||
if ($all.Count -gt 0) { $cm = $all[$all.Count - 1] }
|
||||
}
|
||||
if ($cm.Success) {
|
||||
if ($cm.Groups.Count -gt 1) { $code = $cm.Groups[1].Value } else { $code = $cm.Value }
|
||||
}
|
||||
}
|
||||
Ok 'F3 能从邮件正文里取出 6 位验证码' ($code.Length -eq 6) ("code=" + $(if ($code) { $code } else { '(空)' }))
|
||||
|
||||
$badReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = '000000'; password = $resetPassword }
|
||||
Ok 'F4 错误验证码被拒(400)' ($badReset.Code -eq 400) ([string](Field $badReset.Json 'error'))
|
||||
|
||||
if ($code.Length -eq 6) {
|
||||
$goodReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = $code; password = $resetPassword }
|
||||
Ok 'F5 正确验证码重置成功' ($goodReset.Code -eq 200) ("HTTP " + $goodReset.Code + " " + [string](Field $goodReset.Json 'error'))
|
||||
}
|
||||
|
||||
$oldLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
Ok 'F6 重置后旧密码失效(401)' ($oldLogin.Code -eq 401) ("HTTP " + $oldLogin.Code)
|
||||
$newLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
|
||||
$token3 = Get-Token $newLogin.Json
|
||||
Ok 'F7 重置后新密码可登录' ($newLogin.Code -eq 200 -and $token3.Length -gt 20) ("HTTP " + $newLogin.Code)
|
||||
$imap3 = Test-ImapLogin $newEmail $resetPassword
|
||||
Ok 'F8 新密码同样能用 IMAP 登录' $imap3.Ok ([string]$imap3.Detail)
|
||||
|
||||
if ($token3) { $token = $token3 }
|
||||
|
||||
# ---------------------------------------------------------------- G 改密踢掉其他会话
|
||||
$loginExtra = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
|
||||
$token4 = Get-Token $loginExtra.Json
|
||||
$before = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
|
||||
$changed = Invoke-Api 'POST' '/api/account/password' @{ currentPassword = $resetPassword; password = $newPassword } $token
|
||||
$after = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
|
||||
Ok 'G1 修改密码成功' ($changed.Code -eq 200) ("HTTP " + $changed.Code + " " + [string](Field $changed.Json 'error'))
|
||||
Ok 'G2 改密后其他会话被吊销(当前保留)' ($after -lt $before -and $after -ge 1) ("改前 $before → 改后 $after")
|
||||
if ($token4) {
|
||||
$stale = Invoke-Api 'GET' '/api/me' $null $token4
|
||||
Ok 'G3 被踢掉的那个 token 已失效(401)' ($stale.Code -eq 401) ("HTTP " + $stale.Code)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- H 登录失败锁定
|
||||
$lockReg = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $lockEmail; password = $lockPassword; displayName = '锁定验收'; inviteCode = $invite
|
||||
}
|
||||
if ($lockReg.Code -eq 429) {
|
||||
Skip 'H1 第二个测试账号自助注册' '本小时注册配额已用尽(重复运行脚本所致);改用管理员接口建号'
|
||||
$lockReg = Invoke-Api 'POST' '/api/admin/users' @{ email = $lockEmail; password = $lockPassword; displayName = '锁定验收' } $adminToken
|
||||
}
|
||||
Ok 'H1 第二个测试账号建号成功' ($lockReg.Code -eq 201) ("HTTP " + $lockReg.Code + " " + [string](Field $lockReg.Json 'error'))
|
||||
|
||||
$maxFail = [int](Field $pol.Json 'maxLoginFailures' 8)
|
||||
$lastCode = 0
|
||||
for ($i = 1; $i -le ($maxFail + 1); $i++) {
|
||||
$r = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = "Wrong-Password-$i" }
|
||||
$lastCode = $r.Code
|
||||
}
|
||||
$lockReply = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = $lockPassword }
|
||||
$retryAfter = Field $lockReply.Json 'retryAfterSeconds'
|
||||
Ok 'H2 连续失败后返回 423 锁定' ($lockReply.Code -eq 423) ("HTTP " + $lockReply.Code + " " + [string](Field $lockReply.Json 'error'))
|
||||
Ok 'H3 锁定响应带 retryAfterSeconds' (($null -ne $retryAfter) -and ([int]$retryAfter -gt 0)) ("retryAfterSeconds=" + [string]$retryAfter + " 原始响应: " + $lockReply.Raw)
|
||||
Ok 'H4 锁定期间即使密码正确也被挡(不泄露密码对错)' ($lockReply.Code -eq 423) ''
|
||||
|
||||
# ---------------------------------------------------------------- I 管理员视角
|
||||
$users = Invoke-Api 'GET' '/api/admin/users' $null $adminToken
|
||||
$userList = Field $users.Json 'users'
|
||||
$mine = $userList | Where-Object { ([string](Field $_ 'email' '')) -eq $newEmail }
|
||||
Ok 'I2 管理员能看到新注册的账号' ($null -ne $mine) ("共 " + @($userList).Count + " 个账号")
|
||||
Ok 'I3 新账号在管理员视角是启用状态' ($null -ne $mine -and (Field $mine 'active') -eq $true) ([string](Field $mine 'active'))
|
||||
|
||||
$adminAudit = Invoke-Api 'GET' '/api/admin/audit?limit=50' $null $adminToken
|
||||
$adminEvents = Field $adminAudit.Json 'events'
|
||||
$adminReasons = @($adminEvents | ForEach-Object { [string](Field $_ 'reason' '') })
|
||||
Ok 'I4 管理员能看到全站审计' ($adminAudit.Code -eq 200 -and $adminEvents.Count -gt 0) ("共 " + @($adminEvents).Count + " 条,含 " + [string]::Join('/', ($adminReasons | Select-Object -Unique -First 6)))
|
||||
|
||||
$nonAdmin = Invoke-Api 'GET' '/api/admin/users' $null $token
|
||||
Ok 'I5 普通账号访问管理接口被拒(403)' ($nonAdmin.Code -eq 403) ("HTTP " + $nonAdmin.Code)
|
||||
|
||||
# ---------------------------------------------------------------- J 停用 + 清理
|
||||
$deact = Set-UserActive $adminToken $lockEmail $false
|
||||
Ok 'J1 管理员可停用账号' ($deact.Code -eq 200) ("HTTP " + $deact.Code + " " + [string](Field $deact.Json 'error'))
|
||||
$deact2 = Set-UserActive $adminToken $newEmail $false
|
||||
Ok 'J2 清理:停用验收账号一' ($deact2.Code -eq 200) ("HTTP " + $deact2.Code)
|
||||
|
||||
$disabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
Ok 'J3 被停用的账号不能登录(401,且不暴露账号状态)' ($disabled.Code -eq 401) ("HTTP " + $disabled.Code + " " + [string](Field $disabled.Json 'error'))
|
||||
$disabledImap = Test-ImapLogin $newEmail $newPassword
|
||||
Ok 'J4 被停用的账号不能登录 IMAP' (-not $disabledImap.Ok) ([string]$disabledImap.Detail)
|
||||
|
||||
# 停用必须是权威状态:不能靠「重新注册」把封禁翻回来(这是真机验收抓出来的洞)
|
||||
$revive = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = "Revive-Pass-$suffix"; displayName = '尝试复活'; inviteCode = $invite
|
||||
}
|
||||
Ok 'J5 被停用的账号不能靠重新注册复活(403)' ($revive.Code -eq 403) ("HTTP " + $revive.Code + " " + [string](Field $revive.Json 'error'))
|
||||
$stillDisabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = "Revive-Pass-$suffix" }
|
||||
Ok 'J6 复活尝试后账号依然进不去(401)' ($stillDisabled.Code -eq 401) ("HTTP " + $stillDisabled.Code)
|
||||
|
||||
Say ''
|
||||
Say ("=== 结果:{0} 项通过,{1} 项失败,{2} 项跳过 ===" -f $script:pass, $script:fail, $script:skip)
|
||||
Say ''
|
||||
Say "说明:测试期间创建的两个账号已停用(不是删除,服务器暂无删除账号接口):"
|
||||
foreach ($a in $tempAccounts) { Say (" - {0}" -f $a) }
|
||||
|
||||
try {
|
||||
$utf8 = New-Object Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($Report, $script:logLines, $utf8)
|
||||
Write-Host ("报告已写入 {0}" -f $Report)
|
||||
} catch {
|
||||
Write-Host ("报告写入失败:{0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
exit $script:fail
|
||||
@@ -0,0 +1,93 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
构建 → 自检 → 发布 → 部署到服务器 → 验证,一条命令完成 WpywMail 更新。
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1 -Server <SERVER_IP> -RemotePassword '***'
|
||||
.\deploy.ps1 -Server <SERVER_IP> -RemotePassword '***' -FullCopy # 首次部署或运行时变更时用
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Server,
|
||||
[string]$RemoteUser = 'Administrator',
|
||||
[string]$RemotePassword,
|
||||
[string]$RemotePath = 'C:\Program Files\WpywMail',
|
||||
[string]$TaskName = 'WpywMail',
|
||||
[switch]$FullCopy,
|
||||
[switch]$SkipTests
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$publish = Join-Path (Split-Path -Parent $root) 'work\publish-v2'
|
||||
|
||||
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
||||
|
||||
Step '1) 构建'
|
||||
Push-Location $root
|
||||
try {
|
||||
& dotnet build -c Release -v q --nologo
|
||||
if ($LASTEXITCODE -ne 0) { throw '构建失败' }
|
||||
|
||||
$exe = Join-Path $root 'bin\Release\net8.0\win-x64\WpywMail.Native.exe'
|
||||
|
||||
if (-not $SkipTests) {
|
||||
Step '2) 自检'
|
||||
& $exe --selftest
|
||||
if ($LASTEXITCODE -ne 0) { throw '自检未通过,已中止部署' }
|
||||
}
|
||||
|
||||
Step '3) 发布(自包含 win-x64)'
|
||||
& dotnet publish -c Release -r win-x64 --self-contained true -v q --nologo -o $publish
|
||||
if ($LASTEXITCODE -ne 0) { throw '发布失败' }
|
||||
} finally { Pop-Location }
|
||||
|
||||
if ($RemotePassword) {
|
||||
$sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force
|
||||
$cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec)
|
||||
} else {
|
||||
$cred = Get-Credential -UserName "$Server\$RemoteUser" -Message "连接 $Server 的凭据"
|
||||
}
|
||||
|
||||
Step '4) 停服务'
|
||||
$session = New-PSSession -ComputerName $Server -Credential $cred
|
||||
try {
|
||||
Invoke-Command -Session $session -ArgumentList $TaskName -ScriptBlock {
|
||||
param($task)
|
||||
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'" |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
}
|
||||
|
||||
Step '5) 传输文件'
|
||||
if ($FullCopy) {
|
||||
Write-Host ' 全量复制(首次部署或运行时版本变更时使用)'
|
||||
Copy-Item -Path "$publish\*" -Destination $RemotePath -ToSession $session -Recurse -Force
|
||||
} else {
|
||||
Write-Host ' 增量复制(仅程序集,约 400 KB)'
|
||||
foreach ($f in 'WpywMail.Native.dll', 'WpywMail.Native.exe', 'WpywMail.Native.pdb', 'WpywMail.Native.deps.json') {
|
||||
Copy-Item -Path (Join-Path $publish $f) -Destination "$RemotePath\" -ToSession $session -Force
|
||||
}
|
||||
}
|
||||
|
||||
Step '6) 启动并验证'
|
||||
Invoke-Command -Session $session -ArgumentList $TaskName, $RemotePath -ScriptBlock {
|
||||
param($task, $path)
|
||||
# 启动前在服务器上再跑一次自检,确保部署的就是通过测试的二进制
|
||||
& (Join-Path $path 'WpywMail.Native.exe') --selftest | Select-String -Pattern '失败|=== 结果'
|
||||
Start-ScheduledTask -TaskName $task
|
||||
Start-Sleep -Seconds 7
|
||||
$p = Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'"
|
||||
if (-not $p) { throw '服务未能启动' }
|
||||
" 运行中 PID: $($p.ProcessId)"
|
||||
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LocalPort -in 25, 587, 8787 } |
|
||||
ForEach-Object { " 监听 {0}:{1}" -f $_.LocalAddress, $_.LocalPort }
|
||||
Get-Content 'C:\WpywMailData\service.log' -Tail 4 -Encoding UTF8
|
||||
}
|
||||
} finally {
|
||||
Remove-PSSession $session
|
||||
}
|
||||
|
||||
Write-Host "`n部署完成。回滚:把 $RemotePath 换回 $RemotePath.v1-backup 并重启计划任务 $TaskName。" -ForegroundColor Green
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WpywMail.Native v2 —— 端到端验收测试(在服务器本机执行)
|
||||
|
||||
设计原则:
|
||||
· 只用 Python 标准库(服务器上是 3.8,没有 requests / cryptography);
|
||||
· 密码从 appsettings.json 读取,绝不硬编码;
|
||||
· 自带纯 Python 的 RSA/SHA-256 PKCS#1 v1.5 验签器,直接对「将来要发布到 DNS 的公钥」
|
||||
验证真实投递报文的 DKIM 签名 —— 这一步不依赖服务端私钥,也不依赖外部库;
|
||||
· 覆盖收信、发信、API、IMAP、DKIM、以及三条安全回归(非开放中继 / 提交需认证 / 明文登录限制)。
|
||||
|
||||
用法:
|
||||
python e2e_acceptance.py # 全部用例
|
||||
python e2e_acceptance.py --report out.txt
|
||||
退出码 = 失败用例数(0 表示全部通过)。
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import imaplib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from email.message import EmailMessage
|
||||
from email import utils as email_utils
|
||||
|
||||
socket.setdefaulttimeout(20)
|
||||
|
||||
EXE = r"C:\Program Files\WpywMail\WpywMail.Native.exe"
|
||||
CONFIG = r"C:\Program Files\WpywMail\appsettings.json"
|
||||
|
||||
PASSED = []
|
||||
FAILED = []
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
(PASSED if ok else FAILED).append(name)
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
line = " [{}] {}".format(mark, name)
|
||||
if detail and not ok:
|
||||
line += " <- " + str(detail)[:200]
|
||||
elif detail:
|
||||
line += " ({})".format(str(detail)[:120])
|
||||
print(line)
|
||||
return ok
|
||||
|
||||
|
||||
def section(title):
|
||||
print("\n=== {} ===".format(title))
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG, "r", encoding="utf-8-sig") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- API 客户端
|
||||
|
||||
def api(base, path, method="GET", payload=None, token=None, raw=False):
|
||||
url = base.rstrip("/") + path
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||
if token:
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read()
|
||||
return resp.status, (body if raw else json.loads(body.decode("utf-8")))
|
||||
except urllib.error.HTTPError as err:
|
||||
body = err.read()
|
||||
try:
|
||||
return err.code, json.loads(body.decode("utf-8"))
|
||||
except Exception:
|
||||
return err.code, body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 纯 Python DKIM 验签
|
||||
|
||||
def der_read(data, offset):
|
||||
"""极简 DER TLV 解析,返回 (tag, value_bytes, next_offset)。"""
|
||||
tag = data[offset]
|
||||
offset += 1
|
||||
length = data[offset]
|
||||
offset += 1
|
||||
if length & 0x80:
|
||||
count = length & 0x7F
|
||||
length = int.from_bytes(data[offset:offset + count], "big")
|
||||
offset += count
|
||||
return tag, data[offset:offset + length], offset + length
|
||||
|
||||
|
||||
def spki_to_rsa(der):
|
||||
"""从 SubjectPublicKeyInfo 里取出 (n, e)。"""
|
||||
_, spki, _ = der_read(der, 0)
|
||||
_, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过
|
||||
_, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING
|
||||
if bitstring[0] != 0:
|
||||
raise ValueError("BIT STRING 未使用位不为 0")
|
||||
_, rsa_seq, _ = der_read(bitstring[1:], 0)
|
||||
_, modulus, off2 = der_read(rsa_seq, 0)
|
||||
_, exponent, _ = der_read(rsa_seq, off2)
|
||||
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
|
||||
|
||||
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
|
||||
|
||||
|
||||
def rsa_verify_sha256(n, e, signature, message):
|
||||
try:
|
||||
size = (n.bit_length() + 7) // 8
|
||||
m = pow(int.from_bytes(signature, "big"), e, n)
|
||||
em = m.to_bytes(size, "big")
|
||||
if em[0] != 0x00 or em[1] != 0x01:
|
||||
return False, "PKCS#1 padding 前缀不符"
|
||||
sep = em.index(b"\x00", 2)
|
||||
digest_info = em[sep + 1:]
|
||||
expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest()
|
||||
if digest_info != expected:
|
||||
return False, "DigestInfo 不匹配"
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, "{}: {}".format(type(exc).__name__, exc)
|
||||
|
||||
|
||||
def split_message(raw):
|
||||
sep = raw.find(b"\r\n\r\n")
|
||||
if sep < 0:
|
||||
return [], raw
|
||||
head, body = raw[:sep], raw[sep + 4:]
|
||||
headers = []
|
||||
name = None
|
||||
for line in head.decode("latin-1").split("\r\n"):
|
||||
if line[:1] in (" ", "\t") and name:
|
||||
headers[-1] = (name, headers[-1][1] + "\r\n" + line)
|
||||
else:
|
||||
idx = line.find(":")
|
||||
if idx > 0:
|
||||
name = line[:idx]
|
||||
headers.append((name, line[idx + 1:]))
|
||||
return headers, body
|
||||
|
||||
|
||||
def canon_header(name, value):
|
||||
unfolded = value.replace("\r\n", "").replace("\n", "")
|
||||
return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip()
|
||||
|
||||
|
||||
def canon_body(body):
|
||||
text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [re.sub(r"[ \t]+", " ", line).rstrip(" \t") for line in text.split("\n")]
|
||||
joined = "\r\n".join(lines).rstrip("\r\n")
|
||||
return (joined + "\r\n").encode("latin-1") if joined else b""
|
||||
|
||||
|
||||
def verify_dkim(raw, n, e):
|
||||
"""按 RFC 6376 relaxed/relaxed 验证整封邮件的 DKIM 签名。"""
|
||||
headers, body = split_message(raw)
|
||||
dkim = [v for k, v in headers if k.lower() == "dkim-signature"]
|
||||
if not dkim:
|
||||
return False, "报文没有 DKIM-Signature 头"
|
||||
tags = {}
|
||||
for part in dkim[0].split(";"):
|
||||
if "=" in part:
|
||||
key, _, value = part.partition("=")
|
||||
tags[key.strip()] = value.strip()
|
||||
if tags.get("a") != "rsa-sha256":
|
||||
return False, "非 rsa-sha256: " + str(tags.get("a"))
|
||||
|
||||
if base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode() != tags.get("bh"):
|
||||
return False, "正文哈希(bh)不匹配"
|
||||
|
||||
# RFC 6376 §3.7 第 2 步:先按 h= 顺序哈希各被签名头(每个后面跟一个 CRLF),
|
||||
# 最后哈希 DKIM-Signature 头本身且不带结尾 CRLF。
|
||||
signing = ""
|
||||
for name in tags.get("h", "").split(":"):
|
||||
hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None)
|
||||
if hit is None:
|
||||
return False, "缺少被签名头 " + name
|
||||
signing += canon_header(name, hit) + "\r\n"
|
||||
signing += canon_header("DKIM-Signature", dkim[0].replace(tags["b"], "").rstrip())
|
||||
|
||||
return rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- SMTP 辅助
|
||||
|
||||
def smtp_talk(host, port, commands, starttls=False, auth=None, timeout=25):
|
||||
"""返回每一步的响应码列表,便于断言协议细节。"""
|
||||
out = []
|
||||
client = smtplib.SMTP(host, port)
|
||||
client.ehlo("e2e.local")
|
||||
out.append(("EHLO", 250, sorted(client.esmtp_features.keys())))
|
||||
if starttls:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
# Python 3.8 的 smtplib.starttls(keyfile, certfile, context):context 必须用关键字传,
|
||||
# 否则会被当成 keyfile(报 'certfile must be specified')
|
||||
client.starttls(context=ctx)
|
||||
client.ehlo("e2e.local")
|
||||
out.append(("EHLO-after-TLS", 250, sorted(client.esmtp_features.keys())))
|
||||
if auth:
|
||||
client.login(auth[0], auth[1])
|
||||
out.append(("AUTH", 235, ""))
|
||||
return client, out
|
||||
|
||||
|
||||
def build_chinese_message(sender, recipient, subject, body):
|
||||
msg = EmailMessage()
|
||||
msg["From"] = sender
|
||||
msg["To"] = recipient
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = email_utils.formatdate(localtime=True)
|
||||
msg["Message-ID"] = email_utils.make_msgid(domain=sender.split("@")[-1])
|
||||
msg.set_content(body)
|
||||
return msg
|
||||
|
||||
|
||||
def build_raw_8bit(sender, recipient, subject, body, token):
|
||||
return (
|
||||
"From: {} <{}>\r\n"
|
||||
"To: {}\r\n"
|
||||
"Subject: {}\r\n"
|
||||
"Date: {}\r\n"
|
||||
"Message-ID: <e2e-{}@e2e.local>\r\n"
|
||||
"MIME-Version: 1.0\r\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n"
|
||||
"Content-Transfer-Encoding: 8bit\r\n"
|
||||
"\r\n"
|
||||
"{}\r\n"
|
||||
).format("验收发件人", sender, recipient, subject, email_utils.formatdate(localtime=True), token, body).encode("utf-8")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 主流程
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--report", default=r"C:\_probe\e2e_report.txt")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config()
|
||||
domain = cfg["Domain"]
|
||||
hostname = cfg["Hostname"]
|
||||
account = cfg["AdminEmail"]
|
||||
password = cfg["AdminPassword"]
|
||||
smtp_port = int(cfg.get("SmtpPort", 25))
|
||||
submission_port = int(cfg.get("SubmissionPort", 587))
|
||||
imap_port = int(cfg.get("Imap", {}).get("Port", 143))
|
||||
api_base = "http://127.0.0.1:{}/".format(
|
||||
re.search(r":(\d+)", cfg.get("HttpPrefix", "http://127.0.0.1:8787/")).group(1))
|
||||
stamp = time.strftime("%H%M%S")
|
||||
host = args.host
|
||||
print("WpywMail v2 端到端验收 domain={} account={} {}".format(domain, account, stamp))
|
||||
|
||||
# ---------- 1. 二进制自检 ----------
|
||||
section("1) 二进制自检(--check-config / --selftest)")
|
||||
try:
|
||||
out = subprocess.run([EXE, "--check-config"], capture_output=True, timeout=60)
|
||||
check("配置校验通过", out.returncode == 0, out.stderr.decode("utf-8", "replace")[:120])
|
||||
out = subprocess.run([EXE, "--selftest"], capture_output=True, timeout=180)
|
||||
text = out.stdout.decode("utf-8", "replace")
|
||||
match = re.search(r"=+ 结果:(\d+) 项通过,(\d+) 项失败", text)
|
||||
check("内建自检全部通过", out.returncode == 0 and match and match.group(2) == "0",
|
||||
match.group(0) if match else text[-160:])
|
||||
except Exception as exc:
|
||||
check("二进制自检", False, exc)
|
||||
|
||||
# ---------- 2. DKIM 公钥可导出 ----------
|
||||
section("2) DKIM 公钥与 RSA 参数")
|
||||
dkim_record = ""
|
||||
n = e = None
|
||||
try:
|
||||
out = subprocess.run([EXE, "--dkim-dns"], capture_output=True, timeout=120)
|
||||
for line in out.stdout.decode("utf-8", "replace").splitlines():
|
||||
if line.startswith("VALUE="):
|
||||
dkim_record = line[len("VALUE="):].strip()
|
||||
if line.startswith("NAME="):
|
||||
dkim_name = line[len("NAME="):].strip()
|
||||
check("--dkim-dns 输出公钥记录", dkim_record.startswith("v=DKIM1;"), dkim_record[:80])
|
||||
match = re.search(r"p=([A-Za-z0-9+/=]+)", dkim_record)
|
||||
n, e = spki_to_rsa(base64.b64decode(match.group(1)))
|
||||
check("公钥可解析为 RSA 参数(2048 位)", n.bit_length() == 2048,
|
||||
"modulus {} 位".format(n.bit_length()))
|
||||
print(" 待发布记录: {}.{} = {}...(共 {} 字符)".format(
|
||||
dkim_name, domain, dkim_record[:48], len(dkim_record)))
|
||||
except Exception as exc:
|
||||
check("DKIM 公钥导出", False, exc)
|
||||
|
||||
# ---------- 3. SMTP 收信(8bit 裸 UTF-8 中文) ----------
|
||||
section("3) SMTP 收信(公网收信端口,8bit 裸 UTF-8)")
|
||||
inbox_token = "E2E-IN-" + stamp
|
||||
try:
|
||||
subject = "[验收] 外网中文邮件 " + inbox_token
|
||||
body = "这是验收测试注入的中文正文。\n标记:{}\n全角标点:你好,世界。()《》——".format(inbox_token)
|
||||
raw = build_raw_8bit("[email protected]", account, subject, body, inbox_token)
|
||||
client = smtplib.SMTP(host, smtp_port)
|
||||
code, caps = client.ehlo("e2e.local")
|
||||
check("25 端口 EHLO 成功", code == 250, code)
|
||||
check("25 端口广告 8BITMIME", "8bitmime" in [c.lower() for c in caps.decode().split("\n")[-1:]] or b"8BITMIME" in caps,
|
||||
caps.decode("utf-8", "replace")[:120])
|
||||
client.sendmail("[email protected]", [account], raw, mail_options=["BODY=8BITMIME"])
|
||||
client.quit()
|
||||
check("8bit 中文邮件投递被接受", True)
|
||||
except Exception as exc:
|
||||
check("8bit 中文邮件投递被接受", False, exc)
|
||||
|
||||
# ---------- 4. API 登录与收信入库 ----------
|
||||
section("4) API:登录 / 收件箱 / 中文解码")
|
||||
token = None
|
||||
try:
|
||||
status, obj = api(api_base, "/api/login", "POST", {"email": account, "password": password})
|
||||
check("登录成功", status == 200 and obj.get("token"), status)
|
||||
token = obj.get("token")
|
||||
status, obj = api(api_base, "/api/me", token=token)
|
||||
check("/api/me 返回统计", status == 200 and "stats" in obj, status)
|
||||
time.sleep(1.5)
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + inbox_token, token=token)
|
||||
found = obj.get("messages", []) if status == 200 else []
|
||||
check("刚投递的中文邮件出现在收件箱", len(found) == 1, "命中 {} 封".format(len(found)))
|
||||
if found:
|
||||
check("主题中文正确(无编解码乱码)", inbox_token in found[0]["subject"], found[0]["subject"])
|
||||
check("正文预览中文正确", "验收测试注入的中文正文" in found[0].get("preview", ""), found[0].get("preview"))
|
||||
except Exception as exc:
|
||||
check("API 收件箱检查", False, exc)
|
||||
|
||||
# ---------- 5. SMTP 提交(STARTTLS + AUTH) ----------
|
||||
section("5) SMTP 提交端口:STARTTLS + AUTH + 中文提交")
|
||||
submit_token = "E2E-OUT-" + stamp
|
||||
try:
|
||||
client, steps = smtp_talk(host, submission_port, [], starttls=True)
|
||||
caps_plain = steps[0][2]
|
||||
check("587 明文阶段广告 STARTTLS", "starttls" in caps_plain, caps_plain)
|
||||
caps_tls = steps[1][2] if len(steps) > 1 else []
|
||||
check("TLS 之后才广告 AUTH", "auth" in caps_tls, caps_tls)
|
||||
client.login(account, password)
|
||||
check("AUTH 认证成功(旧版此处为 538 死锁)", True)
|
||||
msg = build_chinese_message(account, account, "[验收] 587 提交中文邮件 " + submit_token,
|
||||
"通过 STARTTLS + AUTH 提交的中文正文。\n标记:{}".format(submit_token))
|
||||
client.send_message(msg)
|
||||
client.quit()
|
||||
check("中文邮件经 587 提交成功", True)
|
||||
except Exception as exc:
|
||||
check("587 提交链路", False, exc)
|
||||
|
||||
# ---------- 6. 队列与本地投递 ----------
|
||||
section("6) 出站队列与本地投递")
|
||||
try:
|
||||
deadline = time.time() + 40
|
||||
state = "?"
|
||||
while time.time() < deadline:
|
||||
status, obj = api(api_base, "/api/queue", token=token)
|
||||
items = [x for x in obj.get("queue", []) if any(r == account for r in x["recipients"])]
|
||||
if items and all(x["status"] == "sent" for x in items):
|
||||
state = "sent"
|
||||
break
|
||||
state = items[-1]["status"] if items else "(无任务)"
|
||||
time.sleep(3)
|
||||
check("发给本机账号的任务投递完成", state == "sent", "最终状态=" + state)
|
||||
except Exception as exc:
|
||||
check("队列投递", False, exc)
|
||||
|
||||
# ---------- 7. DKIM 独立验签(对公钥验证真实投递报文) ----------
|
||||
section("7) DKIM:对将来要发布的公钥验证真实投递报文")
|
||||
if n and e:
|
||||
try:
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token)
|
||||
msgs = obj.get("messages", []) if status == 200 else []
|
||||
signed_id = msgs[0]["id"] if msgs else None
|
||||
if not signed_id:
|
||||
check("找到带签名的投递副本", False, "收件箱没有找到 587 提交的那封")
|
||||
else:
|
||||
status, raw = api(api_base, "/api/messages/{}/raw".format(signed_id), token=token, raw=True)
|
||||
has_sig = b"DKIM-Signature" in raw
|
||||
check("投递副本带 DKIM-Signature", has_sig, "{} 字节".format(len(raw)))
|
||||
if has_sig:
|
||||
ok, reason = verify_dkim(raw, n, e)
|
||||
check("DKIM 签名对公钥验证通过(rsa-sha256 / 正文哈希一致)", ok, reason)
|
||||
except Exception as exc:
|
||||
check("DKIM 独立验签", False, exc)
|
||||
else:
|
||||
check("DKIM 独立验签", False, "公钥不可用")
|
||||
|
||||
# ---------- 8. API 其余端点 ----------
|
||||
section("8) API:附件 / 搜索 / 标记 / 长轮询")
|
||||
try:
|
||||
status, version = api(api_base, "/api/watch?since=0", token=token)
|
||||
check("/api/watch 长轮询返回版本号", status == 200 and "version" in version, status)
|
||||
|
||||
attachment = base64.b64encode("中文附件内容".encode("utf-8")).decode()
|
||||
status, obj = api(api_base, "/api/send", "POST", {
|
||||
"to": account,
|
||||
"subject": "[验收] 带附件 " + submit_token,
|
||||
"text": "正文见附件。",
|
||||
"attachments": [{"fileName": "验收附件.txt", "contentType": "text/plain", "base64": attachment}],
|
||||
}, token=token)
|
||||
check("/api/send 带附件入队(202)", status == 202 and obj.get("queued"), status)
|
||||
|
||||
time.sleep(6)
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token)
|
||||
hits = obj.get("messages", []) if status == 200 else []
|
||||
attach_msg = next((m for m in hits if m.get("hasAttachments")), None)
|
||||
check("带附件的邮件入库并识别出附件", attach_msg is not None,
|
||||
"命中 {} 封".format(len(hits)))
|
||||
if attach_msg:
|
||||
status, blob = api(api_base, "/api/messages/{}/attachments/0".format(attach_msg["id"]),
|
||||
token=token, raw=True)
|
||||
check("附件按原字节下载", status == 200 and blob.decode("utf-8", "replace") == "中文附件内容",
|
||||
"{} 字节".format(len(blob) if isinstance(blob, bytes) else -1))
|
||||
|
||||
if hits:
|
||||
mid = hits[0]["id"]
|
||||
status, obj = api(api_base, "/api/messages/{}".format(mid), "PATCH", {"starred": True}, token=token)
|
||||
check("PATCH 设置星标", status == 200 and obj["message"]["starred"], status)
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&starred=1", token=token)
|
||||
check("按星标筛选命中", status == 200 and any(m["id"] == mid for m in obj.get("messages", [])), status)
|
||||
except Exception as exc:
|
||||
check("API 其余端点", False, exc)
|
||||
|
||||
# ---------- 9. IMAP ----------
|
||||
section("9) IMAP:真实客户端流程")
|
||||
try:
|
||||
imap = imaplib.IMAP4(host, imap_port)
|
||||
check("IMAP 欢迎语含 IMAP4rev1", b"IMAP4rev1" in imap.welcome, imap.welcome[:60])
|
||||
caps = imap.capability()[1][0].decode()
|
||||
check("广告 STARTTLS", "STARTTLS" in caps, caps[:80])
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
imap.starttls(ctx)
|
||||
check("STARTTLS 握手成功", True)
|
||||
imap.login(account, password)
|
||||
check("IMAP LOGIN 成功", True)
|
||||
boxes = imap.list()[1]
|
||||
check("LIST 返回 6 个文件夹", len(boxes) == 6, "{} 个".format(len(boxes)))
|
||||
typ, data = imap.select("INBOX")
|
||||
exists = int(data[0])
|
||||
check("SELECT INBOX 有邮件", exists > 0, "{} 封".format(exists))
|
||||
typ, data = imap.search(None, "ALL")
|
||||
ids = data[0].split()
|
||||
check("SEARCH ALL 返回序号", len(ids) == exists, "{} vs {}".format(len(ids), exists))
|
||||
typ, data = imap.fetch(ids[-1], "(FLAGS RFC822.SIZE ENVELOPE)")
|
||||
summary = data[0].decode("latin-1") if data and isinstance(data[0], bytes) else str(data[0])
|
||||
check("FETCH 摘要含 ENVELOPE 与 RFC822.SIZE", "ENVELOPE" in summary and "RFC822.SIZE" in summary,
|
||||
summary[:120])
|
||||
check("RFC822.SIZE 非 0(旧邮件回退实际文件大小)", "RFC822.SIZE 0 " not in summary, summary[:120])
|
||||
typ, data = imap.fetch(ids[-1], "(BODY.PEEK[HEADER.FIELDS (SUBJECT)])")
|
||||
head = data[0][1].decode("utf-8", "replace") if isinstance(data[0], tuple) else b"".decode()
|
||||
check("HEADER.FIELDS 可取回主题", "Subject:" in head, head[:80])
|
||||
typ, data = imap.store(ids[-1], "+FLAGS", "(\\Seen)")
|
||||
check("STORE +FLAGS 成功", typ == "OK", typ)
|
||||
draft = "From: {}\r\nTo: [email protected]\r\nSubject: =?UTF-8?B?{}?=\r\nMIME-Version: 1.0\r\n\r\n正文\r\n".format(
|
||||
account, base64.b64encode("验收草稿".encode("utf-8")).decode())
|
||||
typ, data = imap.append("Drafts", "(\\Draft)", None, draft.encode("utf-8"))
|
||||
check("APPEND 中文草稿成功", typ == "OK", typ)
|
||||
imap.select("Drafts")
|
||||
typ, data = imap.search(None, "ALL")
|
||||
check("草稿已入库", len(data[0].split()) > 0, data[0])
|
||||
imap.logout()
|
||||
check("IMAP 正常注销", True)
|
||||
except Exception as exc:
|
||||
check("IMAP 流程", False, "{}: {}".format(type(exc).__name__, exc))
|
||||
|
||||
# ---------- 10. 安全回归 ----------
|
||||
section("10) 安全回归:非开放中继 / 提交需认证 / 明文登录限制")
|
||||
try:
|
||||
client = smtplib.SMTP(host, smtp_port)
|
||||
client.ehlo("e2e.local")
|
||||
client.docmd("MAIL FROM:<[email protected]>")
|
||||
code, resp = client.docmd("RCPT TO:<[email protected]>")
|
||||
check("25 端口拒绝外域收件人(非开放中继)", code == 550, "{} {}".format(code, resp))
|
||||
client.quit()
|
||||
except Exception as exc:
|
||||
check("非开放中继", False, exc)
|
||||
|
||||
try:
|
||||
client = smtplib.SMTP(host, submission_port)
|
||||
client.ehlo("e2e.local")
|
||||
code, resp = client.docmd("MAIL FROM:<[email protected]>")
|
||||
check("587 未认证即发信被拒(530)", code == 530, "{} {}".format(code, resp))
|
||||
client.quit()
|
||||
except Exception as exc:
|
||||
check("提交需认证", False, exc)
|
||||
|
||||
# ---------- 汇总 ----------
|
||||
section("汇总")
|
||||
total = len(PASSED) + len(FAILED)
|
||||
print(" 通过 {} / {},失败 {}".format(len(PASSED), total, len(FAILED)))
|
||||
if FAILED:
|
||||
print(" 失败用例:")
|
||||
for name in FAILED:
|
||||
print(" - " + name)
|
||||
|
||||
try:
|
||||
with open(args.report, "w", encoding="utf-8") as fh:
|
||||
fh.write("WpywMail v2 端到端验收报告 {}\n".format(time.strftime("%Y-%m-%d %H:%M:%S")))
|
||||
fh.write("域: {} 账号: {} 主机: {}\n\n".format(domain, account, hostname))
|
||||
fh.write("通过 {} / {},失败 {}\n".format(len(PASSED), total, len(FAILED)))
|
||||
if FAILED:
|
||||
fh.write("\n失败用例:\n" + "\n".join("- " + x for x in FAILED) + "\n")
|
||||
fh.write("\n全部用例:\n" + "\n".join("PASS " + x for x in PASSED) + "\n")
|
||||
print(" 报告已写入: " + args.report)
|
||||
except Exception as exc:
|
||||
print(" 报告写入失败: {}".format(exc))
|
||||
|
||||
return len(FAILED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把 WpywMail 需要的 DKIM / DMARC 记录写入 Cloudflare(一条命令补完 DNS)。
|
||||
|
||||
.DESCRIPTION
|
||||
记录值直接由服务端二进制从 DKIM 私钥推导(--dkim-dns),避免手工复制出错。
|
||||
幂等:已存在的同名记录会被更新而不是重复创建。
|
||||
|
||||
.EXAMPLE
|
||||
# 在本机执行(会通过 WinRM 读取服务器上的公钥)
|
||||
.\publish-dns.ps1 -Server <SERVER_IP> -Zone wpy.email -ApiToken "<CF Token>"
|
||||
|
||||
.NOTES
|
||||
Token 需要权限:Zone:DNS:Edit(对该 zone)。
|
||||
也可先只看将要写入的内容而不实际提交:加上 -WhatIfOnly
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Server,
|
||||
[Parameter(Mandatory)][string]$Zone,
|
||||
[Parameter(Mandatory)][string]$ApiToken,
|
||||
[string]$RemoteUser = 'Administrator',
|
||||
[string]$RemotePassword,
|
||||
[string]$RemoteExe = 'C:\Program Files\WpywMail\WpywMail.Native.exe',
|
||||
[string]$DmarcPolicy = 'p=none',
|
||||
[switch]$WhatIfOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Invoke-Cf {
|
||||
param([string]$Method, [string]$Path, $Body)
|
||||
$uri = "https://api.cloudflare.com/client/v4$Path"
|
||||
$headers = @{ Authorization = "Bearer $ApiToken"; 'Content-Type' = 'application/json' }
|
||||
if ($Body) {
|
||||
Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -Body ($Body | ConvertTo-Json -Depth 8)
|
||||
} else {
|
||||
Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- 1) 从服务器取回需要写入的记录 ----------
|
||||
Write-Host '正在从服务器读取 DKIM 公钥…' -ForegroundColor Cyan
|
||||
if ($RemotePassword) {
|
||||
$sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force
|
||||
$cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec)
|
||||
$lines = Invoke-Command -ComputerName $Server -Credential $cred -ScriptBlock {
|
||||
param($exe) & $exe --dkim-dns
|
||||
} -ArgumentList $RemoteExe
|
||||
} else {
|
||||
$lines = Invoke-Command -ComputerName $Server -ScriptBlock {
|
||||
param($exe) & $exe --dkim-dns
|
||||
} -ArgumentList $RemoteExe
|
||||
}
|
||||
|
||||
$map = @{}
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match '^([A-Z_]+)=(.*)$') { $map[$Matches[1]] = $Matches[2] }
|
||||
}
|
||||
if (-not $map['NAME'] -or -not $map['VALUE']) { throw "未能从服务器取得 DKIM 记录(输出:`n$($lines -join "`n"))" }
|
||||
|
||||
$dmarcShortName = $map['DMARC_NAME'] -replace "\.$([regex]::Escape($Zone))$", ''
|
||||
$dmarcValue = $map['DMARC_VALUE'] -replace 'p=none', $DmarcPolicy
|
||||
|
||||
$records = @(
|
||||
@{ Type = 'TXT'; Name = $map['NAME']; Content = $map['VALUE']; Comment = 'WpywMail DKIM' },
|
||||
@{ Type = 'TXT'; Name = $dmarcShortName; Content = $dmarcValue; Comment = 'WpywMail DMARC' }
|
||||
)
|
||||
|
||||
Write-Host "`n将写入以下记录(zone=$Zone):" -ForegroundColor Cyan
|
||||
foreach ($r in $records) {
|
||||
Write-Host (" {0,-4} {1,-28} {2}" -f $r.Type, $r.Name, ($r.Content.Substring(0, [Math]::Min(80, $r.Content.Length)) + $(if ($r.Content.Length -gt 80) { '…' } else { '' })))
|
||||
}
|
||||
if ($WhatIfOnly) { Write-Host "`n-WhatIfOnly:未提交任何更改。" -ForegroundColor Yellow; return }
|
||||
|
||||
# ---------- 2) 解析 zone id ----------
|
||||
$zones = Invoke-Cf -Method GET -Path "/zones?name=$Zone"
|
||||
if (-not $zones.result -or $zones.result.Count -eq 0) { throw "Cloudflare 中找不到 zone:$Zone(检查 Token 权限与域名拼写)" }
|
||||
$zoneId = $zones.result[0].id
|
||||
Write-Host "`nzone id: $zoneId" -ForegroundColor DarkGray
|
||||
|
||||
# ---------- 3) 幂等写入 ----------
|
||||
foreach ($r in $records) {
|
||||
$fqdn = if ($r.Name) { "$($r.Name).$Zone" } else { $Zone }
|
||||
$existing = Invoke-Cf -Method GET -Path "/zones/$zoneId/dns_records?type=$($r.Type)&name=$fqdn"
|
||||
$payload = @{ type = $r.Type; name = $fqdn; content = $r.Content; ttl = 1; comment = $r.Comment }
|
||||
|
||||
if ($existing.result.Count -gt 0) {
|
||||
$id = $existing.result[0].id
|
||||
$null = Invoke-Cf -Method PUT -Path "/zones/$zoneId/dns_records/$id" -Body $payload
|
||||
Write-Host " [更新] $fqdn" -ForegroundColor Yellow
|
||||
} else {
|
||||
$null = Invoke-Cf -Method POST -Path "/zones/$zoneId/dns_records" -Body $payload
|
||||
Write-Host " [新建] $fqdn" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n完成。等 1-2 分钟后可用以下命令核验:" -ForegroundColor Cyan
|
||||
Write-Host " Resolve-DnsName $($map['NAME']).$Zone -Type TXT -Server 1.1.1.1"
|
||||
Write-Host " Resolve-DnsName _dmarc.$Zone -Type TXT -Server 1.1.1.1"
|
||||
@@ -0,0 +1,253 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
用「DNS 上已发布的 DKIM 公钥」独立验证真实投递报文的签名。
|
||||
|
||||
与 e2e_acceptance.py 内的验签不同:本脚本**不接触私钥**,只吃 DNS TXT 记录的
|
||||
字面值(v=DKIM1; k=rsa; p=...),因此它证明的是收件方(Gmail/Outlook/QQ)
|
||||
将会看到的事实:公钥一发布,签名即可被验证通过。
|
||||
|
||||
零第三方依赖:手写 DER 解析 SPKI -> (n, e),再用 pow() 做 RSA-SHA256
|
||||
PKCS#1 v1.5 验签。Python 3.8+ 可跑。
|
||||
|
||||
用法:
|
||||
python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml a.eml --eml b.eml
|
||||
python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml-dir C:\\path\\raw
|
||||
python verify_published_dkim.py --dns-file ... --eml-dir ... --selector mail --domain wpy.email
|
||||
|
||||
--dns-file 内容可以是整条 TXT(v=DKIM1; k=rsa; p=...),也可以是只含 p= 后面
|
||||
那段 base64 的纯文本;还能容忍 DNS 分段留下的空白/换行。
|
||||
|
||||
退出码 = 验签失败的报文数(0 表示全部通过)。
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ DER / RSA
|
||||
|
||||
def der_read(data, offset):
|
||||
"""读一个 TLV,返回 (tag, content, next_offset)。仅支持短/长形式长度。"""
|
||||
tag = data[offset]
|
||||
length = data[offset + 1]
|
||||
offset += 2
|
||||
if length & 0x80:
|
||||
count = length & 0x7F
|
||||
length = int.from_bytes(data[offset:offset + count], "big")
|
||||
offset += count
|
||||
return tag, data[offset:offset + length], offset + length
|
||||
|
||||
|
||||
def spki_to_rsa(der):
|
||||
"""从 SubjectPublicKeyInfo 里取出 (n, e)。"""
|
||||
_, spki, _ = der_read(der, 0)
|
||||
_, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过
|
||||
_, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING
|
||||
if bitstring[0] != 0:
|
||||
raise ValueError("BIT STRING 未使用位不为 0")
|
||||
_, rsa_seq, _ = der_read(bitstring[1:], 0)
|
||||
_, modulus, off2 = der_read(rsa_seq, 0)
|
||||
_, exponent, _ = der_read(rsa_seq, off2)
|
||||
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
|
||||
|
||||
|
||||
def rsa_verify_sha256(n, e, signature, message):
|
||||
try:
|
||||
size = (n.bit_length() + 7) // 8
|
||||
if len(signature) != size:
|
||||
return False, "签名长度 %d 与模长 %d 不符" % (len(signature), size)
|
||||
m = pow(int.from_bytes(signature, "big"), e, n)
|
||||
em = m.to_bytes(size, "big")
|
||||
if em[0] != 0x00 or em[1] != 0x01:
|
||||
return False, "PKCS#1 v1.5 padding 前缀不符"
|
||||
sep = em.index(b"\x00", 2)
|
||||
digest_info = em[sep + 1:]
|
||||
expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest()
|
||||
if digest_info != expected:
|
||||
return False, "DigestInfo 不匹配(摘要或签名输入被改动)"
|
||||
return True, ""
|
||||
except Exception as exc: # noqa: BLE001 - 验签失败即失败
|
||||
return False, "%s: %s" % (type(exc).__name__, exc)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ DNS TXT 解析
|
||||
|
||||
def parse_txt_record(text):
|
||||
"""从 TXT 字面值里取出 p= 的 base64 并解析成 (n, e)。"""
|
||||
flat = re.sub(r"\s+", "", text) # DNS 分段/换行一律去掉
|
||||
m = re.search(r"p=([A-Za-z0-9+/=]+)", flat)
|
||||
if not m:
|
||||
raise ValueError("TXT 里找不到 p= 公钥段")
|
||||
pem_b64 = m.group(1)
|
||||
try:
|
||||
der = base64.b64decode(pem_b64, validate=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("p= 不是合法 base64: %s" % exc)
|
||||
return spki_to_rsa(der), pem_b64
|
||||
|
||||
|
||||
def record_tags(text):
|
||||
flat = re.sub(r"\s+", " ", text).strip()
|
||||
tags = {}
|
||||
for part in flat.split(";"):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
tags[k.strip().lower()] = v.strip()
|
||||
return tags
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ DKIM 验证
|
||||
|
||||
def split_message(raw):
|
||||
sep = raw.find(b"\r\n\r\n")
|
||||
if sep < 0:
|
||||
return [], raw
|
||||
head, body = raw[:sep], raw[sep + 4:]
|
||||
headers, name = [], None
|
||||
for line in head.decode("latin-1").split("\r\n"):
|
||||
if line[:1] in (" ", "\t") and name:
|
||||
headers[-1] = (name, headers[-1][1] + "\r\n" + line)
|
||||
else:
|
||||
idx = line.find(":")
|
||||
if idx > 0:
|
||||
name = line[:idx]
|
||||
headers.append((name, line[idx + 1:]))
|
||||
return headers, body
|
||||
|
||||
|
||||
def canon_header(name, value):
|
||||
unfolded = value.replace("\r\n", "").replace("\n", "")
|
||||
return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip()
|
||||
|
||||
|
||||
def canon_body(body):
|
||||
text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [re.sub(r"[ \t]+", " ", ln).rstrip(" \t") for ln in text.split("\n")]
|
||||
joined = "\r\n".join(lines).rstrip("\r\n")
|
||||
return (joined + "\r\n").encode("latin-1") if joined else b""
|
||||
|
||||
|
||||
def verify(raw, n, e):
|
||||
"""返回 (是否通过, 说明, 详情 dict)。relaxed/relaxed + rsa-sha256。"""
|
||||
headers, body = split_message(raw)
|
||||
sigs = [v for k, v in headers if k.lower() == "dkim-signature"]
|
||||
if not sigs:
|
||||
return False, "报文里没有 DKIM-Signature 头", {}
|
||||
tags = {}
|
||||
for part in sigs[0].split(";"):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
tags[k.strip().lower()] = v.strip()
|
||||
info = {"d": tags.get("d"), "s": tags.get("s"), "a": tags.get("a"),
|
||||
"c": tags.get("c"), "h": tags.get("h"), "bh": tags.get("bh"),
|
||||
"b_len": len(tags.get("b", ""))}
|
||||
|
||||
if tags.get("a") != "rsa-sha256":
|
||||
return False, "非 rsa-sha256(%s)" % tags.get("a"), info
|
||||
if tags.get("c", "simple/simple") != "relaxed/relaxed":
|
||||
return False, "非 relaxed/relaxed(%s)" % tags.get("c"), info
|
||||
|
||||
bh_calc = base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode()
|
||||
info["bh_calc"] = bh_calc
|
||||
if bh_calc != tags.get("bh"):
|
||||
return False, "正文哈希 bh 不匹配(正文被改动)", info
|
||||
|
||||
signing = ""
|
||||
for name in tags.get("h", "").split(":"):
|
||||
if not name.strip():
|
||||
continue
|
||||
hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None)
|
||||
if hit is None:
|
||||
return False, "被签名的头缺失: %s" % name, info
|
||||
# RFC 6376 §3.7 第 2 步之 1:每个被签名头后面必须跟一个 CRLF
|
||||
signing += canon_header(name, hit) + "\r\n"
|
||||
# 之 2:DKIM-Signature 头本身放在**最后**,且结尾**不带 CRLF**
|
||||
signing += canon_header("DKIM-Signature", sigs[0].replace(tags["b"], "").rstrip())
|
||||
|
||||
ok, why = rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii"))
|
||||
return ok, why, info
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 主流程
|
||||
|
||||
def collect(paths, eml_dir):
|
||||
files = list(paths)
|
||||
if eml_dir:
|
||||
for name in sorted(os.listdir(eml_dir)):
|
||||
if name.lower().endswith(".eml"):
|
||||
files.append(os.path.join(eml_dir, name))
|
||||
return files
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="用 DNS 已发布的公钥验证 DKIM")
|
||||
ap.add_argument("--dns-file", required=True, help="含已发布 TXT 值的文本文件")
|
||||
ap.add_argument("--eml", action="append", default=[], help="待验报文(可重复)")
|
||||
ap.add_argument("--eml-dir", help="目录下所有 .eml 都验")
|
||||
ap.add_argument("--selector", default=None, help="期望的选择器(校验 s=)")
|
||||
ap.add_argument("--domain", default=None, help="期望的签名域(校验 d=)")
|
||||
args = ap.parse_args()
|
||||
|
||||
raw_txt = open(args.dns_file, "rb").read().decode("utf-8", "replace")
|
||||
try:
|
||||
(n, e), pem_b64 = parse_txt_record(raw_txt)
|
||||
except ValueError as exc:
|
||||
print("[致命] 无法从 DNS 值解析公钥: %s" % exc)
|
||||
return 2
|
||||
tags = record_tags(raw_txt)
|
||||
|
||||
print("=" * 68)
|
||||
print("DNS 已发布 DKIM 公钥")
|
||||
print("=" * 68)
|
||||
print(" 记录长度 : %d 字符" % len(re.sub(r"\s+", "", raw_txt)))
|
||||
print(" v / k : %s / %s" % (tags.get("v"), tags.get("k")))
|
||||
print(" 公钥位长 : %d bit" % n.bit_length())
|
||||
print(" 公钥指数 : %d" % e)
|
||||
print(" p= 长度 : %d 字符 base64" % len(pem_b64))
|
||||
if tags.get("v") != "DKIM1":
|
||||
print(" [警告] v 不是 DKIM1")
|
||||
if n.bit_length() < 1024:
|
||||
print(" [警告] 密钥短于 1024 bit,多数收件方会直接判失败")
|
||||
|
||||
files = collect(args.eml, args.eml_dir)
|
||||
if not files:
|
||||
print("\n[致命] 没有指定任何 .eml")
|
||||
return 2
|
||||
|
||||
print("\n" + "=" * 68)
|
||||
print("对真实报文验签(只用上面这把公钥,不接触私钥)")
|
||||
print("=" * 68)
|
||||
failures = 0
|
||||
for path in files:
|
||||
name = os.path.basename(path)
|
||||
data = open(path, "rb").read()
|
||||
ok, why, info = verify(data, n, e)
|
||||
print("\n%s (%d 字节)" % (name, len(data)))
|
||||
print(" d=%s s=%s a=%s c=%s" % (info.get("d"), info.get("s"), info.get("a"), info.get("c")))
|
||||
print(" 签名字段 b 长度 %s,h=%s" % (info.get("b_len"), info.get("h")))
|
||||
print(" 正文哈希 bh: %s" % ("一致" if info.get("bh") == info.get("bh_calc") else "不一致"))
|
||||
if args.domain and info.get("d") != args.domain:
|
||||
ok, why = False, "d= 与期望域名 %s 不符(实际 %s)" % (args.domain, info.get("d"))
|
||||
if args.selector and info.get("s") != args.selector:
|
||||
ok, why = False, "s= 与期望选择器 %s 不符(实际 %s)" % (args.selector, info.get("s"))
|
||||
if ok:
|
||||
print(" [通过] RSA-SHA256 签名验证成功 —— 收件方用这条 DNS 记录即可验签")
|
||||
else:
|
||||
print(" [失败] %s" % why)
|
||||
failures += 1
|
||||
|
||||
print("\n" + "=" * 68)
|
||||
print("合计: %d 个报文,通过 %d,失败 %d" % (len(files), len(files) - failures, failures))
|
||||
print("=" * 68)
|
||||
return failures
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user