Files
wpywmail/server-native-v2/FileStore.cs
T

976 lines
39 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() { }
}