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
+134
View File
@@ -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;
}
}