using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace WpywMail.Native;
///
/// 基于文件的存储:users.json / messages.json / queue.json / sessions.json + raw/ 与 attachments/。
/// 单机个人邮箱场景下,这种实现的可靠性与可审计性优于引入数据库依赖。
///
/// 全量写入 + 原子替换(写 .tmp 再 Move),并对所有变更加锁。
///
/// ⚠️ 性能特征:**任何一次改动都会把全部邮件重新序列化并整文件重写**(O(N)),
/// 邮件量上千以后单次「标记已读」也会变得明显昂贵。v2.1 起默认使用
/// ,本实现保留作为可回滚的后端(Storage.Provider=json)。
///
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 users = [];
private List messages = [];
private List queue = [];
private List sessions = [];
private List codes = [];
private List authEvents = [];
/// 审计保留条数(由 ApiServer 按配置注入)。
public int AuditKeep { get; set; } = 2000;
/// 每次写入都会自增,供长轮询判断「有没有新变化」。
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>(usersPath) ?? [];
messages = Read>(messagesPath) ?? [];
queue = Read>(queuePath) ?? [];
sessions = Read>(sessionsPath) ?? [];
codes = Read>(verificationPath) ?? [];
authEvents = Read>(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(string path)
{
if (!File.Exists(path)) return default;
try { return JsonSerializer.Deserialize(File.ReadAllText(path), json); }
catch (Exception ex)
{
AppLog.Error($"[存储] 读取 {Path.GetFileName(path)} 失败,将从空数据继续:{ex.Message}");
return default;
}
}
private static void Write(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 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;
}
/// 用已算好的哈希建号(注册验证通过时用,避免明文密码再走一遍内存)。
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 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 ListAuthEvents(string? email, string? ip, string? reason, int limit)
{
lock (gate)
{
IEnumerable 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 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;
}
/// 同一账号 + 同一文件夹内的下一个 IMAP UID。
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 支持
/// IMAP 用的文件夹列表(固定集合,未使用也返回,便于客户端订阅)。
public static readonly string[] ImapFolders = MailFolders.ImapFolders;
public static string? NormalizeFolder(string name) => MailFolders.Normalize(name);
/// 按 UID 升序返回某文件夹的邮件(IMAP 要求的稳定顺序)。
public IReadOnlyList 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);
/// IMAP STORE:设置 \Seen / \Flagged。
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;
}
}
/// IMAP EXPUNGE:inbox 等移入垃圾箱;已在垃圾箱则彻底删除。
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;
}
}
/// IMAP APPEND:把客户端上传的报文存入指定文件夹。
public MailMessage? Append(string owner, string folder, byte[] raw, bool seen)
{
var parsed = Mime.Parse(raw);
var attachments = new List();
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); }
/// 把外发报文直接投递给本地收件人(同域发信不必绕 SMTP)。
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();
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 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));
/// 分页查询。文件实现只能先全表过滤再切片(这正是它随邮件量变慢的原因)。
public (int Total, IReadOnlyList 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));
/// 迁移用:读出全部邮件与队列(返回的是引用,可就地修改后调用 Persist)。
public IReadOnlyList AllMessages() { lock (gate) return messages.ToArray(); }
public IReadOnlyList 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;
}
}
/// 删除邮件。permanent=false 时只移到垃圾箱。
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? 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;
}
/// 生成一封本地退信(投递彻底失败时发给发件人自己)。
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 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 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; }
}
/// 纯文件实现没有需要释放的资源(保留以满足统一接口)。
public void Dispose() { }
}