Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
@@ -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() { }
|
||||
}
|
||||
Reference in New Issue
Block a user