Files

235 lines
9.9 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace WpywMail.Native;
public sealed class FileStore
{
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 rawDirectory;
private readonly AppConfig config;
private List<MailUser> users = [];
private List<MailMessage> messages = [];
private List<QueueItem> queue = [];
public FileStore(AppConfig config)
{
this.config = config;
Directory.CreateDirectory(config.DataDirectory);
rawDirectory = Path.Combine(config.DataDirectory, "raw");
Directory.CreateDirectory(rawDirectory);
usersPath = Path.Combine(config.DataDirectory, "users.json");
messagesPath = Path.Combine(config.DataDirectory, "messages.json");
queuePath = Path.Combine(config.DataDirectory, "queue.json");
Load();
EnsureAdmin();
}
private void Load()
{
lock (gate)
{
users = Read<List<MailUser>>(usersPath) ?? [];
messages = Read<List<MailMessage>>(messagesPath) ?? [];
queue = Read<List<QueueItem>>(queuePath) ?? [];
foreach (var item in queue.Where(x => x.Status == "processing"))
{
item.Status = "retry";
item.NextAttempt = DateTimeOffset.UtcNow;
}
}
}
private T? Read<T>(string path)
{
if (!File.Exists(path)) return default;
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), json); }
catch { return default; }
}
private void Write<T>(string path, T value)
{
var temp = path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(value, json), Encoding.UTF8);
File.Move(temp, path, true);
}
private void EnsureAdmin()
{
if (string.IsNullOrWhiteSpace(config.AdminPassword))
throw new InvalidOperationException("appsettings.json 中必须设置 AdminPassword。");
lock (gate)
{
if (users.Any(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase))) return;
users.Add(new MailUser
{
Email = config.AdminEmail.ToLowerInvariant(),
DisplayName = "Administrator",
Role = "admin",
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
});
users[^1].PasswordHash = HashPassword(config.AdminPassword, users[^1].PasswordSalt);
Write(usersPath, users);
}
}
public MailUser? FindUser(string email) => users.FirstOrDefault(x => x.Active && x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
public MailUser? Authenticate(string email, string password)
{
var user = FindUser(email);
return user is not null && VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
}
public bool IsLocalAddress(string email) => FindUser(email) is not null;
public IReadOnlyList<MailUser> ListUsers() => users.Where(x => x.Active).OrderBy(x => x.Email).ToArray();
public MailUser CreateUser(string email, string password, string displayName)
{
email = email.Trim().ToLowerInvariant();
if (FindUser(email) is not null) throw new InvalidOperationException("用户已存在。");
var user = new MailUser
{
Email = email,
DisplayName = displayName,
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
};
user.PasswordHash = HashPassword(password, user.PasswordSalt);
lock (gate) { users.Add(user); Write(usersPath, users); }
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 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)
{
var full = Path.GetFullPath(Path.Combine(config.DataDirectory, relativePath));
if (!full.StartsWith(Path.GetFullPath(rawDirectory), 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);
lock (gate) { messages.Add(message); Write(messagesPath, messages); }
return message;
}
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 => x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
.Where(x => query.Length == 0 || $"{x.From} {x.To} {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 void MarkRead(string owner, string id, bool read = true)
{
lock (gate) { var item = GetMessage(owner, id); if (item is null) return; item.Unread = !read; Write(messagesPath, messages); }
}
public MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text, byte[] raw)
{
var message = new MailMessage { OwnerEmail = owner, Folder = "sent", From = owner, To = string.Join(", ", recipients), Subject = subject, Text = text, DeliveryStatus = "queued", Unread = false };
message.RawPath = SaveRaw(raw);
lock (gate)
{
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);
}
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).Take(limit).ToArray();
foreach (var item in due) item.Status = "processing";
Write(queuePath, queue);
return due;
}
}
public MailMessage? GetById(string id) => messages.FirstOrDefault(x => x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
public void CompleteQueue(QueueItem item)
{
lock (gate)
{
item.Status = "sent";
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";
}
Write(queuePath, queue); Write(messagesPath, messages);
}
}
public void FailQueue(QueueItem item, Exception error)
{
lock (gate)
{
item.Attempts++;
item.LastError = error.Message;
var permanent = error is SmtpDeliveryException smtp && smtp.Permanent;
item.Status = permanent || item.Attempts >= 8 ? "failed" : "retry";
item.NextAttempt = DateTimeOffset.UtcNow.AddMinutes(Math.Min(60, Math.Pow(2, item.Attempts)));
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 = item.Status == "failed" && !remaining ? "failed" : "queued";
}
Write(queuePath, queue); Write(messagesPath, messages);
}
}
public object Stats(string owner)
{
lock (gate)
{
return new { inbox = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox"), unread = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox" && x.Unread), sent = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "sent"), queue = queue.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Status is "pending" or "retry" or "processing") };
}
}
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) => CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(hash), Convert.FromBase64String(HashPassword(password, salt)));
}