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

This commit is contained in:
WpyQwq
2026-09-19 11:19:40 +08:00
commit b8814a7615
84 changed files with 21197 additions and 0 deletions
+94
View File
@@ -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}");
}
}
}