commit b8814a76156fbdd7c92d22a891347bce8a290db0 Author: WpyQwq <3911625973@qq.com> Date: Sat Sep 19 11:19:40 2026 +0800 Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..71995cd --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +MAIL_DOMAIN=wpyw.site +MAIL_HOSTNAME=mail.wpyw.site +MAIL_USER=admin@wpyw.site +MAIL_PASSWORD=replace-with-a-long-password +WEB_PORT=8787 +SMTP_PORT=25 +SUBMISSION_PORT=587 +MAIL_DATA_DIR=H:\\MailData +SEED_DEMO=false + +# Optional outbound SMTP relay. If omitted, the server delivers directly to recipient MX hosts. +# SMTP_RELAY_HOST=smtp.example.com +# SMTP_RELAY_PORT=587 +# SMTP_RELAY_USER=username +# SMTP_RELAY_PASSWORD=password +# SMTP_RELAY_SECURE=false + +# Optional TLS for SMTP STARTTLS/SMTPS. Use a certificate for mail.wpyw.site. +# SMTP_TLS_KEY=H:\\MailData\\certs\\privkey.pem +# SMTP_TLS_CERT=H:\\MailData\\certs\\fullchain.pem diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dd4859 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# wpyw.mail + +服务端和客户端分离。当前优先维护独立服务端,服务端代码和启动说明位于 [`server/`](H:/Codex/2026-09-08/windows-wpyw-site-cf-web-4/server/)。 + +服务端提供 SMTP 收信、SMTP Submission 发信、本地邮件存储和 REST API;客户端后续单独开发,不参与服务端启动。 + +## 启动 + +```powershell +npm install +Copy-Item .env.example .env +notepad .env +npm run dev +``` + +开发时访问 `http://localhost:5173`,生产构建后运行: + +```powershell +npm run build +npm start +``` + +默认 Web API 监听 `8787`,SMTP 监听 `25`,Submission 监听 `587`。 + +## 重要配置 + +至少修改 `.env` 中的: + +```dotenv +MAIL_USER=admin@wpyw.site +MAIL_PASSWORD=一个足够长的密码 +MAIL_DATA_DIR=H:\\MailData +``` + +如果服务器不能直接连接外部 MX 的 25 端口,可以配置 SMTP 中继: + +```dotenv +SMTP_RELAY_HOST=smtp.example.com +SMTP_RELAY_PORT=587 +SMTP_RELAY_USER=your-user +SMTP_RELAY_PASSWORD=your-password +SMTP_RELAY_SECURE=false +``` + +## Cloudflare Tunnel + +不要把 SMTP 或 IMAP 通过现有 Web Tunnel 暴露给普通客户端。建议增加单独的 Webmail hostname: + +```yaml +ingress: + - hostname: webmail.wpyw.site + service: http://127.0.0.1:5173 + - service: http_status:404 +``` + +`mail.wpyw.site` 保持 DNS only,继续直接指向邮件服务器公网 IP。MX 记录继续指向 `mail.wpyw.site`。 + +## 当前版本边界 + +这是一个可运行的单账户基础服务,不等同于成熟商业邮件系统。正式长期运行前,还应增加 DKIM 签名、DMARC 报告、反垃圾策略、速率限制、持久化会话、多账户管理、附件下载鉴权、备份和监控。不要把它配置成 Open Relay。 diff --git a/client-winui/ApiClient.cs b/client-winui/ApiClient.cs new file mode 100644 index 0000000..a84d830 --- /dev/null +++ b/client-winui/ApiClient.cs @@ -0,0 +1,97 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace WpywMail.Client; + +public sealed class ApiClient +{ + private readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(12) }; + private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web); + private string token = ""; + + public string BaseUrl { get; private set; } + + public ApiClient(string baseUrl = "http://127.0.0.1:8787/api") + { + BaseUrl = NormalizeBaseUrl(baseUrl); + } + + public void SetBaseUrl(string baseUrl) => BaseUrl = NormalizeBaseUrl(baseUrl); + + public async Task LoginAsync(string email, string password, CancellationToken cancellationToken = default) + { + using var response = await http.PostAsJsonAsync($"{BaseUrl}/login", new { email, password }, json, cancellationToken); + return await ReadOrThrow(response, cancellationToken); + } + + public async Task GetMeAsync(CancellationToken cancellationToken = default) => + await SendAsync(HttpMethod.Get, "/me", cancellationToken: cancellationToken); + + public async Task GetConfigAsync(CancellationToken cancellationToken = default) => + await SendAsync(HttpMethod.Get, "/config", cancellationToken: cancellationToken); + + public async Task> GetMessagesAsync(string folder, string query = "", CancellationToken cancellationToken = default) + { + var url = $"/messages?folder={Uri.EscapeDataString(folder)}"; + if (!string.IsNullOrWhiteSpace(query)) url += $"&q={Uri.EscapeDataString(query)}"; + var result = await SendAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); + return result.Messages; + } + + public async Task GetMessageAsync(string id, CancellationToken cancellationToken = default) + { + var result = await SendAsync(HttpMethod.Get, $"/messages/{Uri.EscapeDataString(id)}", cancellationToken: cancellationToken); + return result.Message; + } + + public async Task SendMessageAsync(string to, string subject, string text, CancellationToken cancellationToken = default) => + await SendAsync(HttpMethod.Post, "/send", new { to, subject, text }, cancellationToken); + + public async Task ChangePasswordAsync(string password, CancellationToken cancellationToken = default) => + await SendAsync(HttpMethod.Post, "/account/password", new { password }, cancellationToken); + + public async Task LogoutAsync(CancellationToken cancellationToken = default) + { + try { await SendAsync(HttpMethod.Post, "/logout", cancellationToken: cancellationToken); } + finally { token = ""; } + } + + public void SetToken(string value) + { + token = value; + http.DefaultRequestHeaders.Authorization = string.IsNullOrWhiteSpace(token) ? null : new AuthenticationHeaderValue("Bearer", token); + } + + private async Task SendAsync(HttpMethod method, string path, object? body = null, CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(method, $"{BaseUrl}{path}"); + if (body is not null) request.Content = JsonContent.Create(body, options: json); + using var response = await http.SendAsync(request, cancellationToken); + return await ReadOrThrow(response, cancellationToken); + } + + private async Task ReadOrThrow(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(json, cancellationToken); + return result ?? throw new InvalidOperationException("服务端返回了空响应"); + } + + var message = await response.Content.ReadAsStringAsync(cancellationToken); + try + { + var error = JsonSerializer.Deserialize>(message, json); + if (error?.TryGetValue("error", out var detail) == true) throw new InvalidOperationException(detail); + } + catch (JsonException) { } + throw new InvalidOperationException($"服务端返回 HTTP {(int)response.StatusCode}"); + } + + private static string NormalizeBaseUrl(string value) + { + var url = string.IsNullOrWhiteSpace(value) ? "http://127.0.0.1:8787/api" : value.Trim().TrimEnd('/'); + return url.EndsWith("/api", StringComparison.OrdinalIgnoreCase) ? url : $"{url}/api"; + } +} diff --git a/client-winui/App.xaml b/client-winui/App.xaml new file mode 100644 index 0000000..018eb77 --- /dev/null +++ b/client-winui/App.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client-winui/App.xaml.cs b/client-winui/App.xaml.cs new file mode 100644 index 0000000..dc9af11 --- /dev/null +++ b/client-winui/App.xaml.cs @@ -0,0 +1,27 @@ +using Microsoft.UI.Xaml; + +namespace WpywMail.Client; + +public partial class App : Application +{ + public static Window? MainWindow { get; private set; } + + public App() + { + UnhandledException += (_, args) => + { + try { File.AppendAllText(Path.Combine(Path.GetTempPath(), "WpywMail.Client-error.log"), $"{DateTime.Now:O}\r\n{args.Exception}\r\n\r\n"); } catch { } + }; + AppDomain.CurrentDomain.UnhandledException += (_, args) => + { + try { File.AppendAllText(Path.Combine(Path.GetTempPath(), "WpywMail.Client-error.log"), $"{DateTime.Now:O}\r\n{args.ExceptionObject}\r\n\r\n"); } catch { } + }; + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + MainWindow = new MainWindow(); + MainWindow.Activate(); + } +} diff --git a/client-winui/Converters.cs b/client-winui/Converters.cs new file mode 100644 index 0000000..38d8343 --- /dev/null +++ b/client-winui/Converters.cs @@ -0,0 +1,20 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace WpywMail.Client; + +public sealed class UnreadBackgroundConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) => + value is true ? Application.Current.Resources["AccentSubtleBrush"] : Application.Current.Resources["SurfaceBrush"]; + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException(); +} + +public sealed class UnreadWeightConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) => + value is true ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal; + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException(); +} diff --git a/client-winui/MainWindow.xaml b/client-winui/MainWindow.xaml new file mode 100644 index 0000000..e3066ad --- /dev/null +++ b/client-winui/MainWindow.xaml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client-winui/MainWindow.xaml.cs b/client-winui/MainWindow.xaml.cs new file mode 100644 index 0000000..e45c2a2 --- /dev/null +++ b/client-winui/MainWindow.xaml.cs @@ -0,0 +1,244 @@ +using System.Collections.ObjectModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace WpywMail.Client; + +public sealed partial class MainWindow : Window +{ + private readonly ObservableCollection messages = []; + private ApiClient api = new(); + private string folder = "inbox"; + private CancellationTokenSource? searchCancellation; + + public MainWindow() + { + InitializeComponent(); + MessageList.ItemsSource = messages; + } + + private async void LoginButton_Click(object sender, RoutedEventArgs e) => await LoginAsync(); + + private async void PasswordBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Enter) await LoginAsync(); + } + + private async Task LoginAsync() + { + var email = EmailBox.Text.Trim(); + var password = PasswordBox.Password; + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + { + LoginStatus.Text = "请输入邮箱和密码。"; + return; + } + + LoginButton.IsEnabled = false; + LoginStatus.Text = "正在连接邮箱服务…"; + try + { + api.SetBaseUrl(ApiUrlBox.Text); + var result = await api.LoginAsync(email, password); + api.SetToken(result.Token); + AccountText.Text = result.User.Email; + LoginView.Visibility = Visibility.Collapsed; + ShellView.Visibility = Visibility.Visible; + LoginStatus.Text = ""; + await RefreshAsync(); + } + catch (Exception ex) + { + LoginStatus.Text = ex.Message.Contains("无法连接", StringComparison.OrdinalIgnoreCase) + ? "无法连接服务端,请检查地址、端口和服务状态。" + : ex.Message; + } + finally { LoginButton.IsEnabled = true; } + } + + private async Task RefreshAsync() + { + try + { + var me = await api.GetMeAsync(); + InboxCount.Text = me.Stats.Unread > 0 ? me.Stats.Unread.ToString() : ""; + await LoadFolderAsync(folder, SearchBox.Text); + ConnectionText.Text = "已连接"; + ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentBrush"]; + } + catch (Exception ex) + { + ConnectionText.Text = ex.Message; + ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"]; + } + } + + private async Task LoadFolderAsync(string selectedFolder, string query = "") + { + try + { + var result = await api.GetMessagesAsync(selectedFolder, query); + messages.Clear(); + foreach (var message in result) messages.Add(message); + FolderTitle.Text = selectedFolder switch + { + "sent" => "已发送", + "drafts" => "草稿", + "archive" => "归档", + "trash" => "垃圾箱", + _ => "收件箱" + }; + FolderSubtitle.Text = messages.Count == 0 ? "暂无邮件" : $"{messages.Count} 封邮件"; + } + catch (Exception ex) + { + ConnectionText.Text = ex.Message; + ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"]; + } + } + + private async void FolderButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string selectedFolder) return; + folder = selectedFolder; + EmptyReadingPane.Visibility = Visibility.Visible; + ReadingPane.Visibility = Visibility.Collapsed; + await LoadFolderAsync(folder, SearchBox.Text); + } + + private async void MessageList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (MessageList.SelectedItem is not MailSummary summary) return; + try + { + var message = await api.GetMessageAsync(summary.Id); + ReadingSubject.Text = message.Subject; + ReadingInitials.Text = message.Initials; + ReadingFrom.Text = message.From; + ReadingDate.Text = message.Date.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); + ReadingBody.Text = message.Text; + EmptyReadingPane.Visibility = Visibility.Collapsed; + ReadingPane.Visibility = Visibility.Visible; + summary.Unread = false; + MessageList.SelectedItem = null; + } + catch (Exception ex) { ConnectionText.Text = ex.Message; } + } + + private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await RefreshAsync(); + + private async void SearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + searchCancellation?.Cancel(); + searchCancellation = new CancellationTokenSource(); + var token = searchCancellation.Token; + try + { + await Task.Delay(260, token); + await LoadFolderAsync(folder, SearchBox.Text); + } + catch (OperationCanceledException) { } + } + + private async void LogoutButton_Click(object sender, RoutedEventArgs e) + { + await api.LogoutAsync(); + messages.Clear(); + ShellView.Visibility = Visibility.Collapsed; + LoginView.Visibility = Visibility.Visible; + PasswordBox.Password = ""; + LoginStatus.Text = "已退出登录。"; + } + + private async void SettingsButton_Click(object sender, RoutedEventArgs e) + { + ConfigResponse config; + try { config = await api.GetConfigAsync(); } + catch (Exception ex) + { + ConnectionText.Text = ex.Message; + ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"]; + return; + } + var newPassword = new PasswordBox { PlaceholderText = "新密码(至少 12 位)", MinWidth = 360 }; + var content = new StackPanel { Spacing = 12, Width = 430 }; + content.Children.Add(new TextBlock { Text = $"账户\n{config.Account}", Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextSecondaryBrush"] }); + content.Children.Add(new TextBlock { Text = $"域名:{config.Domain}\n主机名:{config.Hostname}\nSMTP:{config.Protocols.Smtp} 提交端口:{config.Protocols.Submission}", Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextTertiaryBrush"] }); + content.Children.Add(new Border { Height = 1, Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StrokeBrush"] }); + content.Children.Add(new TextBlock { Text = "修改密码", FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + content.Children.Add(newPassword); + + var dialog = new ContentDialog + { + Title = "账户与服务", + Content = content, + PrimaryButtonText = "保存密码", + CloseButtonText = "关闭", + DefaultButton = ContentDialogButton.Close, + XamlRoot = Content.XamlRoot + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary || string.IsNullOrWhiteSpace(newPassword.Password)) return; + if (newPassword.Password.Length < 12) + { + ConnectionText.Text = "密码至少需要 12 位。"; + ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"]; + return; + } + try + { + await api.ChangePasswordAsync(newPassword.Password); + ConnectionText.Text = "密码已更新。下次登录请使用新密码。"; + ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentBrush"]; + } + catch (Exception ex) + { + ConnectionText.Text = ex.Message; + ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"]; + } + } + + private async void ComposeButton_Click(object sender, RoutedEventArgs e) + { + var dialog = new ContentDialog + { + Title = "写信", + PrimaryButtonText = "发送", + CloseButtonText = "取消", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = Content.XamlRoot, + Content = new ComposeView() + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary || dialog.Content is not ComposeView compose) return; + if (string.IsNullOrWhiteSpace(compose.ToBox.Text) || string.IsNullOrWhiteSpace(compose.SubjectBox.Text) || string.IsNullOrWhiteSpace(compose.BodyBox.Text)) + { + ConnectionText.Text = "收件人、主题和正文不能为空。"; + return; + } + + try + { + await api.SendMessageAsync(compose.ToBox.Text, compose.SubjectBox.Text, compose.BodyBox.Text); + ConnectionText.Text = "邮件已加入发送队列。"; + if (folder == "sent") await LoadFolderAsync(folder, SearchBox.Text); + } + catch (Exception ex) { ConnectionText.Text = ex.Message; } + } +} + +public sealed class ComposeView : StackPanel +{ + public TextBox ToBox { get; } = new() { PlaceholderText = "收件人,例如 someone@example.com" }; + public TextBox SubjectBox { get; } = new() { PlaceholderText = "主题" }; + public TextBox BodyBox { get; } = new() { PlaceholderText = "正文", AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinHeight = 180 }; + + public ComposeView() + { + Spacing = 10; + Width = 520; + Children.Add(ToBox); + Children.Add(SubjectBox); + Children.Add(BodyBox); + } +} diff --git a/client-winui/Models.cs b/client-winui/Models.cs new file mode 100644 index 0000000..ef5e78d --- /dev/null +++ b/client-winui/Models.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; + +namespace WpywMail.Client; + +public sealed class LoginResponse +{ + public string Token { get; set; } = ""; + public LoginUser User { get; set; } = new(); +} + +public sealed class LoginUser +{ + public string Email { get; set; } = ""; + public string Role { get; set; } = "user"; + public string Domain { get; set; } = "wpyw.site"; +} + +public sealed class MessageListResponse +{ + public List Messages { get; set; } = []; +} + +public sealed class MessageDetailResponse +{ + public MailMessage Message { get; set; } = new(); +} + +public class MailSummary +{ + public string Id { get; set; } = ""; + public string From { get; set; } = ""; + public string To { get; set; } = ""; + public string Subject { get; set; } = "(无主题)"; + public DateTimeOffset Date { get; set; } + public bool Unread { get; set; } + public bool Starred { get; set; } + public string DeliveryStatus { get; set; } = "received"; + public string Preview { get; set; } = ""; + + [JsonIgnore] + public string SenderName => string.IsNullOrWhiteSpace(From) ? "未知发件人" : From.Split('@')[0]; + + [JsonIgnore] + public string Initials => string.Concat(SenderName.Split(['.', '-', '_'], StringSplitOptions.RemoveEmptyEntries).Take(2).Select(x => char.ToUpperInvariant(x[0]))); + + [JsonIgnore] + public string DeliveryStatusLabel => DeliveryStatus switch + { + "delivered" => "已投递", + "failed" => "投递失败", + "queued" or "pending" or "processing" => "发送中", + _ => "" + }; + + [JsonIgnore] + public string DateLabel => Date.Date == DateTimeOffset.Now.Date ? Date.ToLocalTime().ToString("HH:mm") : Date.ToLocalTime().ToString("MM/dd"); +} + +public sealed class MailMessage : MailSummary +{ + public string OwnerEmail { get; set; } = ""; + public string Folder { get; set; } = "inbox"; + public string Text { get; set; } = ""; + public string MessageId { get; set; } = ""; +} + +public sealed class AccountStats +{ + public int Inbox { get; set; } + public int Unread { get; set; } + public int Sent { get; set; } + public int Queue { get; set; } +} + +public sealed class MeResponse +{ + public LoginUser User { get; set; } = new(); + public AccountStats Stats { get; set; } = new(); +} + +public sealed class ConfigResponse +{ + public string Domain { get; set; } = ""; + public string Hostname { get; set; } = ""; + public string Account { get; set; } = ""; + public ProtocolConfig Protocols { get; set; } = new(); +} + +public sealed class ProtocolConfig +{ + public int Smtp { get; set; } + public int Submission { get; set; } + public string Api { get; set; } = ""; +} diff --git a/client-winui/README.md b/client-winui/README.md new file mode 100644 index 0000000..a02afde --- /dev/null +++ b/client-winui/README.md @@ -0,0 +1,17 @@ +# Wpyw Mail WinUI 3 客户端 + +这是独立于服务端的 Windows 客户端,使用 C# + WinUI 3 + Windows App SDK。 + +默认连接本机服务端:`http://127.0.0.1:8787/api`。登录页可以改成远程 API 地址。 + +## 本地运行 + +```powershell +dotnet restore .\client-winui\WpywMail.Client.csproj +dotnet build .\client-winui\WpywMail.Client.csproj -c Debug -p:Platform=x64 +dotnet run --project .\client-winui\WpywMail.Client.csproj -c Debug -p:Platform=x64 +``` + +客户端第一版包含:登录、收件箱/已发送等文件夹、搜索、邮件阅读、写信入队、刷新和退出登录。 + +服务端需要先启动 `server-native`,并确保 API 地址可访问。真正部署到公网时,建议为 API 单独配置 HTTPS 入口;不要把 SMTP/IMAP 端口放进 Cloudflare Tunnel 的普通 HTTP 路由。 diff --git a/client-winui/WpywMail.Client.csproj b/client-winui/WpywMail.Client.csproj new file mode 100644 index 0000000..39e4556 --- /dev/null +++ b/client-winui/WpywMail.Client.csproj @@ -0,0 +1,21 @@ + + + WinExe + net8.0-windows10.0.19041.0 + 10.0.17763.0 + WpywMail.Client + app.manifest + x64 + win-x64 + enable + enable + true + true + false + + + + + + + diff --git a/client-winui/app.manifest b/client-winui/app.manifest new file mode 100644 index 0000000..253b548 --- /dev/null +++ b/client-winui/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/documents/designs/wpyw-mail-client_design_system.md b/documents/designs/wpyw-mail-client_design_system.md new file mode 100644 index 0000000..548e09d --- /dev/null +++ b/documents/designs/wpyw-mail-client_design_system.md @@ -0,0 +1,47 @@ +# Wpyw Mail 客户端界面设计系统 + +来源:用户提供的 Outlook 深色主题截图;目标平台为 WinUI 3,采用克制、简洁的邮件工作台布局。 + +## 视觉原则 + +- 以深黑灰为主,不使用大面积装饰图或强烈渐变。 +- 信息架构优先:左侧导航、文件夹、中间邮件列表、右侧阅读区。 +- 蓝色只用于当前状态、链接、未读提示和主操作。 +- 圆角控制在 6–8px;分隔线比阴影更重要。 +- 动画只承担状态变化反馈,时长约 160–220ms,不持续循环。 + +## 颜色 + +| 用途 | 值 | +| --- | --- | +| 页面背景 | `#1E1E1E` | +| 导航栏 | `#151515` | +| 内容表面 | `#252525` | +| 抬升表面 | `#2D2D2D` | +| 分隔线 | `#3A3A3A` | +| 主文字 | `#F2F2F2` | +| 次文字 | `#B7B7B7` | +| 辅助文字 | `#848484` | +| 强调色 | `#5BA7FF` | +| 未读底色 | `#263D55` | +| 错误色 | `#E77878` | + +## 布局 + +- 顶栏 52px:品牌、搜索、账户和设置。 +- 操作栏 44px:写信、刷新、连接状态。 +- 主体四列:58px 图标栏、220px 文件夹、360px 邮件列表、剩余阅读区。 +- 基础间距采用 4/8/12/18/26px,正文行高 24px。 + +## 组件 + +- 主按钮:蓝色实底、6px 圆角、短文本。 +- 次按钮:透明底、灰色文字,仅在悬停或聚焦时显现背景。 +- 邮件卡片:发件人、主题、预览、时间四级信息;未读使用轻微蓝色底和半粗文字。 +- 阅读区:主题 26px,发件人 14px,正文 15px;不使用复杂富文本工具栏。 + +## 动画 + +- 登录成功后切换视图使用淡入/轻微位移。 +- 阅读区加载使用一次性淡入。 +- 搜索使用 260ms 防抖;不使用粒子、弹跳或持续动画。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..5d72db7 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + wpyw.mail + + +
+ + + diff --git a/installer-bootstrap/Installer.csproj b/installer-bootstrap/Installer.csproj new file mode 100644 index 0000000..d948126 --- /dev/null +++ b/installer-bootstrap/Installer.csproj @@ -0,0 +1,17 @@ + + + Exe + net8.0 + win-x64 + WpywMail.Installer + wpyw-mail-server-installer + enable + enable + true + true + true + + + + + diff --git a/installer-bootstrap/Program.cs b/installer-bootstrap/Program.cs new file mode 100644 index 0000000..3f6bd02 --- /dev/null +++ b/installer-bootstrap/Program.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using System.Reflection; +using System.IO.Compression; + +namespace WpywMail.Installer; + +internal static class Program +{ + public static int Main() + { + try + { + var root = Path.Combine(Path.GetTempPath(), "WpywMailInstaller", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + using var resource = Assembly.GetExecutingAssembly().GetManifestResourceStream("WpywMail.Installer.Payload.zip") ?? throw new InvalidOperationException("安装包内容缺失。"); + var zip = Path.Combine(root, "payload.zip"); + using (var output = File.Create(zip)) resource.CopyTo(output); + ZipFile.ExtractToDirectory(zip, root); + File.Delete(zip); + + var script = Path.Combine(root, "install.ps1"); + var psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", + WorkingDirectory = root, + UseShellExecute = true, + Verb = "runas", + }; + using var process = Process.Start(psi) ?? throw new InvalidOperationException("无法启动安装程序。"); + process.WaitForExit(); + if (process.ExitCode != 0) + { + Console.Error.WriteLine($"安装程序返回错误代码:{process.ExitCode}"); + } + return process.ExitCode; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + Console.Error.WriteLine("安装程序无法启动。"); + Console.Error.WriteLine("请记录上面的错误信息,然后按任意键退出。"); + try { Console.ReadKey(intercept: true); } catch { } + return 1; + } + } +} diff --git a/installer/build-installer.ps1 b/installer/build-installer.ps1 new file mode 100644 index 0000000..da6788d --- /dev/null +++ b/installer/build-installer.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$native = Join-Path $root 'server-native' +$stage = Join-Path $root ("work\installer-stage-" + (Get-Date -Format 'yyyyMMddHHmmss')) +$payload = Join-Path $stage 'payload' +$publish = Join-Path $stage 'publish' +$outputDir = Join-Path $root 'outputs' +$bootstrap = Join-Path $root 'installer-bootstrap' +$payloadZip = Join-Path $bootstrap 'Payload.zip' +$installerPublish = Join-Path $stage 'installer-publish' +$target = Join-Path $outputDir 'wpyw-mail-server-installer.exe' + +New-Item -ItemType Directory -Force -Path $payload, $outputDir | Out-Null + +dotnet publish (Join-Path $native 'WpywMail.Native.csproj') -c Release -r win-x64 --self-contained true -o $publish +Copy-Item -Path (Join-Path $publish '*') -Destination $payload -Recurse -Force +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'install.cmd') -Destination $payload -Force +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'install.ps1') -Destination $payload -Force + +Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $payloadZip -CompressionLevel Optimal -Force + +dotnet publish (Join-Path $bootstrap 'Installer.csproj') -c Release -r win-x64 --self-contained true -o $installerPublish +Copy-Item -LiteralPath (Join-Path $installerPublish 'wpyw-mail-server-installer.exe') -Destination $target -Force +Write-Host "安装包已生成:$target" diff --git a/installer/install.cmd b/installer/install.cmd new file mode 100644 index 0000000..9322177 --- /dev/null +++ b/installer/install.cmd @@ -0,0 +1,9 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" +if errorlevel 1 ( + echo. + echo 安装失败。按任意键关闭窗口。 + pause >nul +) +exit /b %errorlevel% diff --git a/installer/install.ps1 b/installer/install.ps1 new file mode 100644 index 0000000..7982f29 --- /dev/null +++ b/installer/install.ps1 @@ -0,0 +1,139 @@ +$ErrorActionPreference = 'Stop' + +# 任何安装错误都停留在窗口中,避免 PowerShell 一闪而过。 +trap { + $message = $_.Exception.Message + Write-Host '' + Write-Host '安装失败,详细信息如下:' -ForegroundColor Red + Write-Host $message -ForegroundColor Red + Write-Host '' + try { + $logPath = Join-Path $env:TEMP 'WpywMailInstaller-error.log' + "时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`r`n错误:$message`r`n位置:$($_.InvocationInfo.PositionMessage)" | Set-Content -LiteralPath $logPath -Encoding UTF8 + Write-Host "错误日志:$logPath" -ForegroundColor Yellow + } catch { } + Read-Host '请记录上面的错误信息,然后按回车键退出' + exit 1 +} + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-Administrator)) { + $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"") + Start-Process powershell.exe -Verb RunAs -ArgumentList $args -Wait + exit $LASTEXITCODE +} + +Write-Host '' +Write-Host 'wpyw.mail 邮箱服务安装程序' -ForegroundColor Cyan +Write-Host '本安装程序已经预填第一个邮箱:wpy@wpyw.site。' -ForegroundColor Yellow +Write-Host '安装过程中只需要输入密码;密码不会写入安装程序文件。' -ForegroundColor Yellow +Write-Host '提示:所有“直接回车”都表示使用方括号中的默认值或留空。' -ForegroundColor DarkGray +Write-Host '' + +$defaultInstall = Join-Path ${env:ProgramFiles} 'WpywMail' +$defaultData = 'C:\WpywMailData' +$installDir = Read-Host "程序安装目录 [$defaultInstall]" +if ([string]::IsNullOrWhiteSpace($installDir)) { $installDir = $defaultInstall } +$dataDir = Read-Host "邮件数据目录(邮件会保存在这里) [$defaultData]" +if ([string]::IsNullOrWhiteSpace($dataDir)) { $dataDir = $defaultData } + +do { + $password = Read-Host '邮箱密码(至少 12 位,登录 wpy@wpyw.site 使用)' -AsSecureString + $passwordPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) + try { $passwordText = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPtr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPtr) } + if ($passwordText.Length -lt 12) { Write-Host '密码太短,请重新输入至少 12 位密码。' -ForegroundColor Red } +} while ($passwordText.Length -lt 12) + +$relayHost = Read-Host 'SMTP 外发中继服务器(可选;留空则按收件人 MX 直接投递)' +$relayPort = 587 +$relayUser = '' +$relayPassword = '' +if (-not [string]::IsNullOrWhiteSpace($relayHost)) { + $relayPortText = Read-Host 'SMTP 外发中继端口 [587]' + if (-not [string]::IsNullOrWhiteSpace($relayPortText)) { + if (-not [int]::TryParse($relayPortText, [ref]$relayPort) -or $relayPort -lt 1 -or $relayPort -gt 65535) { throw 'SMTP 中继端口必须是 1 到 65535 之间的数字。' } + } + $relayUser = Read-Host 'SMTP 中继账号(没有就直接回车)' + if (-not [string]::IsNullOrWhiteSpace($relayUser)) { + $relayPasswordSecure = Read-Host 'SMTP 中继密码' -AsSecureString + $relayPasswordPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($relayPasswordSecure) + try { $relayPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($relayPasswordPtr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($relayPasswordPtr) } + } +} +$deliveryMode = if ([string]::IsNullOrWhiteSpace($relayHost)) { 'direct' } else { 'relay' } + +$tlsPath = Read-Host 'mail.wpyw.site 的 PFX 证书路径(没有就直接回车,安装器会临时生成)' +$tlsPassword = '' +if (-not [string]::IsNullOrWhiteSpace($tlsPath)) { + $tlsPasswordSecure = Read-Host 'PFX 证书密码(没有密码就直接回车)' -AsSecureString + $tlsPasswordPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($tlsPasswordSecure) + try { $tlsPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tlsPasswordPtr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tlsPasswordPtr) } +} + +if ([string]::IsNullOrWhiteSpace($tlsPath)) { + $certDir = Join-Path $dataDir 'certs' + New-Item -ItemType Directory -Force -Path $certDir | Out-Null + $tlsPath = Join-Path $certDir 'mail.wpyw.site.pfx' + $tlsPassword = [guid]::NewGuid().ToString('N') + $cert = New-SelfSignedCertificate -DnsName 'mail.wpyw.site' -CertStoreLocation 'Cert:\LocalMachine\My' -FriendlyName 'wpyw.mail temporary TLS' -NotAfter (Get-Date).AddYears(2) -KeyExportPolicy Exportable + $secureCertPassword = ConvertTo-SecureString -String $tlsPassword -AsPlainText -Force + Export-PfxCertificate -Cert $cert -FilePath $tlsPath -Password $secureCertPassword | Out-Null + Write-Host '未填写证书,已生成临时自签名 TLS 证书。正式使用前请替换为受信任证书。' -ForegroundColor Yellow +} + +New-Item -ItemType Directory -Force -Path $installDir, $dataDir | Out-Null +$payloadFiles = Get-ChildItem -LiteralPath $PSScriptRoot -File | Where-Object { $_.Name -notin @('install.ps1', 'install.cmd') } +foreach ($file in $payloadFiles) { Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $installDir $file.Name) -Force } + +$settings = [ordered]@{ + Domain = 'wpyw.site' + Hostname = 'mail.wpyw.site' + HttpPrefix = 'http://127.0.0.1:8787/' + SmtpPort = 25 + SubmissionPort = 587 + DataDirectory = $dataDir + AdminEmail = 'wpy@wpyw.site' + AdminPassword = $passwordText + TlsCertificatePath = $tlsPath + TlsCertificatePassword = $tlsPassword + DeliveryMode = $deliveryMode + DirectDelivery = [ordered]@{ + ConnectionTimeoutSeconds = 30 + CommandTimeoutSeconds = 30 + DnsTimeoutSeconds = 5 + OpportunisticStartTls = $true + RequireStartTls = $false + DnsServer = '' + } + Relay = [ordered]@{ Host = $relayHost; Port = $relayPort; User = $relayUser; Password = $relayPassword; EnableSsl = $true } +} +$settings | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $installDir 'appsettings.json') -Encoding UTF8 + +New-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口' -Direction Inbound -Protocol TCP -LocalPort 25,587 -Action Allow -ErrorAction SilentlyContinue | Out-Null +$exe = Join-Path $installDir 'WpywMail.Native.exe' +$action = New-ScheduledTaskAction -Execute $exe -WorkingDirectory $installDir +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +$taskSettings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName 'WpywMail' -Action $action -Trigger $trigger -Principal $principal -Settings $taskSettings -Force | Out-Null +Start-ScheduledTask -TaskName 'WpywMail' + +Write-Host '' +Write-Host '安装完成。' -ForegroundColor Green +Write-Host '邮箱地址:wpy@wpyw.site' +Write-Host "程序目录:$installDir" +Write-Host "邮件数据目录:$dataDir" +Write-Host '收信 SMTP:25 客户端发信:587 本机 API:127.0.0.1:8787' +Write-Host 'Cloudflare Tunnel 的 Web/API 路由应指向:http://127.0.0.1:8787/' +if ($deliveryMode -eq 'direct') { + Write-Host '当前使用 MX 直投模式:服务端会查询收件人域名的 MX,并连接对方 25 端口发送。' -ForegroundColor Yellow +} else { + Write-Host '当前使用 SMTP 中继模式:邮件会交给你填写的中继服务器发送。' -ForegroundColor Yellow +} +Write-Host '' +Read-Host '按回车键退出' diff --git a/installer/安装填写说明.md b/installer/安装填写说明.md new file mode 100644 index 0000000..41988ef --- /dev/null +++ b/installer/安装填写说明.md @@ -0,0 +1,44 @@ +# wpyw.mail 安装器填写顺序 + +安装器是命令行窗口,所有问题都按顺序出现。看到方括号默认值时直接按回车即可。 + +## 推荐第一次安装 + +```text +1. 程序安装目录 直接回车 +2. 邮件数据目录 直接回车;如果有空间更大的 D 盘,可填 D:\WpywMailData +3. 邮箱密码 输入至少 12 位密码,给 wpy@wpyw.site 使用 +4. SMTP 外发中继服务器 没有就直接回车,使用 MX 直投 +5. SMTP 外发中继端口 只有填写了中继服务器才会出现,默认 587 +6. SMTP 中继账号 只有填写了中继服务器才会出现 +7. SMTP 中继密码 只有填写了中继账号才会出现 +8. PFX 证书路径 没有正式证书就直接回车 +9. PFX 证书密码 没有正式证书时不需要填写 +``` + +## 默认发送方式 + +不填写 SMTP 中继时,服务端会查询收件人域名的 MX 记录,然后直接连接对方的 25 端口发送。你的服务器已经测试确认可以连接 QQ MX 的 25 端口。 + +## 中继信息怎么填 + +只有你希望改用 SMTP 外发中继时才填写。例如中继服务商给出: + +```text +服务器:smtp.example.com +端口:587 +账号:your-account@example.com +密码:中继服务商提供的密码 +``` + +就在安装器中依次填入这四项。中继账号和邮箱账号不一定相同,以中继服务商给出的信息为准。 + +## 证书怎么填 + +正式使用时建议准备包含 `mail.wpyw.site` 的 PFX 证书,例如: + +```text +C:\certs\mail.wpyw.site.pfx +``` + +没有证书时先直接回车可以完成测试安装,但客户端连接时可能提示证书不受信任。安装器会把临时证书保存到邮件数据目录的 `certs` 子目录。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f9f7b2a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3319 @@ +{ + "name": "wpyw-mail", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wpyw-mail", + "version": "0.1.0", + "dependencies": { + "dotenv": "^16.4.7", + "express": "^5.1.0", + "mailparser": "^3.7.2", + "nodemailer": "^10.0.1", + "smtp-server": "^3.15.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.4.1", + "concurrently": "^9.1.2", + "lucide-react": "^0.511.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.16", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz", + "integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.3", + "libqp": "2.1.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge-ts": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.423", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", + "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-japanese": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz", + "integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz", + "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^8.0.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipv6-normalize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz", + "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz", + "integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.3.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", + "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/mailparser": { + "version": "3.9.23", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.23.tgz", + "integrity": "sha512-5jpsKltHt9oudhMu6mXxiwWMWV/CILfly+m9xhKiKW6cdmfOVvcAZ/NCuChV6QlADJqx1e5E2hwDAkP7lTP+Lg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.16", + "encoding-japanese": "2.3.0", + "he": "1.2.0", + "html-to-text": "10.0.1", + "iconv-lite": "0.7.3", + "libmime": "5.4.3", + "linkify-it": "5.0.2", + "nodemailer": "10.0.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nodemailer": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz", + "integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smtp-server": { + "version": "3.19.9", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.9.tgz", + "integrity": "sha512-ljndWZ9km1qI/1fUj+VTQwG0ts5NRX+Lb95NPttoUZRvaOMUSPKdph9YoHpxpQfOomafzWvNHJcZkFL2v6OXYw==", + "license": "MIT-0", + "dependencies": { + "ipv6-normalize": "1.0.1", + "nodemailer": "10.0.1", + "punycode.js": "2.3.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8a8674 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "wpyw-mail", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "concurrently \"node server/index.mjs\" \"vite --host 0.0.0.0\"", + "build": "vite build", + "start": "node server/index.mjs", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^5.1.0", + "mailparser": "^3.7.2", + "nodemailer": "^10.0.1", + "smtp-server": "^3.15.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.4.1", + "concurrently": "^9.1.2", + "vite": "^6.3.5", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "lucide-react": "^0.511.0" + } +} diff --git a/server-native-v2/AccountService.cs b/server-native-v2/AccountService.cs new file mode 100644 index 0000000..81c21df --- /dev/null +++ b/server-native-v2/AccountService.cs @@ -0,0 +1,563 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace WpywMail.Native; + +/// +/// 账号体系的业务层:自助注册、邮箱验证码、失败锁定、密码重置、会话与资料管理。 +/// +/// 设计取舍(都在代码里写清楚,方便以后回看): +/// +/// 1. **先建未激活信箱,密码等验证通过才写入**:本地投递只认已存在的用户,所以注册时必须 +/// 先把信箱行建出来(否则验证码邮件会被判成外发、甚至直接丢弃,用户永远收不到码); +/// 但这一行的密码是**随机不可用值**,真正的密码存在验证码记录的 Payload 里, +/// 验证通过后才写进用户行并激活 —— 于是「谁能读到验证码,谁才能决定这个账号的密码」。 +/// 未激活的信箱不能登录 IMAP/SMTP/API(见各处的 FindUser 只取 active=1)。 +/// ⚠️ 曾经写成「验证通过再建号」,结果是验证码邮件根本送不到,属于致命流程缺陷。 +/// 2. **验证码只存哈希**:库被拖走也不能直接拿来激活账号或改密码。 +/// 3. **登录失败信息不区分原因**:对外一律「邮箱或密码不正确」,避免账号枚举; +/// 真实原因(不存在 / 密码错 / 已停用 / 已锁定)只写进审计日志。 +/// 4. **发信走既有出站队列**:验证码邮件复用 Mime.Build + QueueOutbound + 投递队列, +/// 不另起一条发送路径(否则重试、DKIM、队列状态都要再实现一遍)。 +/// +public sealed class AccountService +{ + private readonly AppConfig config; + private readonly IMailStore store; + + public AccountService(AppConfig config, IMailStore store) + { + this.config = config; + this.store = store; + // 让存储层的审计裁剪跟随配置 + if (store is SqliteStore sqlite) sqlite.AuditKeep = config.Accounts.AuditLimit; + if (store is FileStore file) file.AuditKeep = config.Accounts.AuditLimit; + if (config.Accounts.RequireEmailVerification + && config.Accounts.EffectiveDomains(config.Domain) + .All(d => IsHostedDomain("x@" + d, config.Domain, config.Hostname))) + { + AppLog.Warn("[账号] Accounts.RequireEmailVerification=true,但允许注册的域名都由本机托管 —— " + + "验证码邮件会被投进「验证通过前登录不了」的信箱,形成死循环。" + + "对这些地址已自动跳过邮箱验证(注册授权凭据是邀请码)。" + + "要让邮箱验证真正生效,请把 AllowedDomains 换成托管在别处的域名(如 gmail.com)。"); + } + } + + public AccountsConfig Policy => config.Accounts; + + // ---------------------------------------------------------------- 对外:策略 + + public object PolicyView() => new + { + registration = Policy.Registration, + inviteRequired = Policy.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase), + requireEmailVerification = Policy.RequireEmailVerification, + minPasswordLength = Policy.MinPasswordLength, + allowedDomains = Policy.EffectiveDomains(config.Domain), + codeMinutes = Policy.CodeMinutes, + maxLoginFailures = Policy.MaxLoginFailures, + lockoutMinutes = Policy.LockoutMinutes, + selfHostedDomain = config.Domain, + verificationNote = Policy.RequireEmailVerification + ? $"本机托管的邮箱(@{config.Domain})注册后免验证码直接开通:验证码邮件只能投进这个信箱," + + "而它在验证通过前登录不了,会形成死循环;这类地址的授权凭据是邀请码。" + : "", + }; + + /// 该地址的信箱是否就托管在本机上(域名 = 本服务器自己的域)。 + public bool IsHostedHere(string? email) => IsHostedDomain(email, config.Domain, config.Hostname); + + /// 纯函数版本:不依赖存储,供 --check-config 等只读场景使用。 + public static bool IsHostedDomain(string? email, string? domain, string? hostname) + { + var at = (email ?? "").LastIndexOf('@'); + if (at < 0 || at == email!.Length - 1) return false; + var d = email[(at + 1)..].Trim().ToLowerInvariant(); + if (d.Length == 0) return false; + if (d.Equals((domain ?? "").Trim().ToLowerInvariant(), StringComparison.Ordinal)) return true; + var host = (hostname ?? "").Trim().ToLowerInvariant(); + var dot = host.IndexOf('.'); + return dot > 0 && d.Equals(host[(dot + 1)..], StringComparison.Ordinal); + } + + /// + /// 这个地址要不要走邮箱验证码。 + /// + /// ⚠️ **本机托管的地址必须跳过**,否则是死循环:验证码邮件投进的就是这个信箱, + /// 而它在验证通过前不允许登录(IMAP / Webmail / API 全部进不去)→ 用户永远拿不到验证码。 + /// 这类地址的授权凭据是**邀请码**(管理员亲自发放),注册即开通。 + /// 只有邮箱托管在别处(例如 AllowedDomains 里放了 gmail.com)时,邮箱验证才真正有意义。 + /// + public bool NeedsEmailVerification(string? email) => Policy.RequireEmailVerification && !IsHostedHere(email); + + // ---------------------------------------------------------------- 校验 + + public static bool LooksLikeEmail(string? email) => + !string.IsNullOrWhiteSpace(email) + && email.Length <= 254 + && email.Count(c => c == '@') == 1 + && email.IndexOf('@') > 0 + && email.IndexOf('@') < email.Length - 1 + && !email.Any(char.IsWhiteSpace) + && email.Contains('.'); + + /// 密码强度:长度 + 不能纯数字 + 不能与邮箱相同(够用即可,不搞复杂度表演)。 + public (bool Ok, string Error) CheckPassword(string? password, string email) + { + var min = Math.Max(8, Policy.MinPasswordLength); + if (string.IsNullOrEmpty(password)) return (false, "密码不能为空"); + if (password.Length < min) return (false, $"密码至少需要 {min} 个字符"); + if (password.Length > 200) return (false, "密码过长"); + if (password.All(char.IsDigit)) return (false, "密码不能全是数字"); + if (!string.IsNullOrWhiteSpace(email) && password.Equals(email, StringComparison.OrdinalIgnoreCase)) + return (false, "密码不能与邮箱相同"); + return (true, ""); + } + + private (bool Ok, string Error) CheckDomain(string email) + { + var at = email.LastIndexOf('@'); + if (at < 0) return (false, "邮箱地址不合法"); + var domain = email[(at + 1)..].ToLowerInvariant(); + var allowed = Policy.EffectiveDomains(config.Domain); + if (allowed.Length == 0) return (true, ""); + return allowed.Contains(domain) + ? (true, "") + : (false, $"只允许注册 @{string.Join(" / @", allowed)} 的邮箱"); + } + + // ---------------------------------------------------------------- 验证码 + + private static string NewCode() => RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); + + private static string HashCode(string code, string salt) => + Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(code, Convert.FromBase64String(salt), 60_000, HashAlgorithmName.SHA256, 32)); + + private static bool VerifyCode(string code, string hash, string salt) + { + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(hash), + Convert.FromBase64String(HashCode(code, salt))); + } + catch { return false; } + } + + private string CreateCode(string email, string purpose, string payload) + { + var code = NewCode(); + var record = new VerificationCode + { + Email = email, + Purpose = purpose, + Salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)), + Payload = payload, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(Math.Max(1, Policy.CodeMinutes)), + CreatedAt = DateTimeOffset.UtcNow, + Attempts = 0, + }; + record.CodeHash = HashCode(code, record.Salt); + record.SentAt = DateTimeOffset.UtcNow; + store.SaveVerificationCode(record); + return code; + } + + /// 校验验证码;成功返回 true 并删除该验证码。会处理过期与试错次数。 + public (bool Ok, string Error, VerificationCode? Record) ConsumeCode(string email, string purpose, string code) + { + var record = store.FindVerificationCode(email, purpose); + if (record is null) return (false, "没有待验证的请求,请重新获取验证码", null); + if (record.ExpiresAt < DateTimeOffset.UtcNow) + { + store.RemoveVerificationCode(email, purpose); + return (false, "验证码已过期,请重新获取", null); + } + if (record.Attempts >= Math.Max(1, Policy.MaxCodeAttempts)) + { + store.RemoveVerificationCode(email, purpose); + return (false, "验证码尝试次数过多,已作废,请重新获取", null); + } + if (!VerifyCode((code ?? "").Trim(), record.CodeHash, record.Salt)) + { + var attempts = store.IncrementVerificationAttempts(email, purpose); + var left = Math.Max(0, Policy.MaxCodeAttempts - attempts); + Record(email, "", "code-failed", false, $"purpose={purpose} attempts={attempts}"); + return (false, left > 0 ? $"验证码不正确,还可以试 {left} 次" : "验证码已作废,请重新获取", null); + } + store.RemoveVerificationCode(email, purpose); + return (true, "", record); + } + + // ---------------------------------------------------------------- 注册 + + public sealed record RegisterResult(bool Ok, int Status, string Error, bool VerificationRequired, object? Session); + + public RegisterResult Register(RegisterRequest request, string ip, string userAgent) + { + var email = (request.Email ?? "").Trim().ToLowerInvariant(); + + if (!Policy.Registration.Equals("open", StringComparison.OrdinalIgnoreCase) + && !Policy.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase)) + { + Record(email, ip, "register", false, "registration closed"); + return new RegisterResult(false, 403, "本服务器已关闭自助注册,请联系管理员开设账号", false, null); + } + if (Policy.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase) + && !string.Equals(request.InviteCode?.Trim(), Policy.InviteCode.Trim(), StringComparison.Ordinal)) + { + Record(email, ip, "register", false, "bad invite code"); + return new RegisterResult(false, 403, "邀请码不正确", false, null); + } + if (!LooksLikeEmail(email)) + { + Record(email, ip, "register", false, "bad email"); + return new RegisterResult(false, 400, "邮箱地址不合法", false, null); + } + var domain = CheckDomain(email); + if (!domain.Ok) + { + Record(email, ip, "register", false, "domain not allowed"); + return new RegisterResult(false, 400, domain.Error, false, null); + } + // 已激活的账号:直接拒绝。**未激活的注册允许重来**(上一次验证码没收到 / 输错太多次), + // 重来不会覆盖已有密码 —— 密码只由「读到验证码的人」在验证那一步写入。 + var existing = store.FindUserAnyState(email); + if (existing is not null && existing.Active) + { + Record(email, ip, "register", false, "already exists"); + return new RegisterResult(false, 409, "这个邮箱已经注册过了,请直接登录或使用「忘记密码」", false, null); + } + if (existing is not null && existing.LastLoginAt is not null) + { + // 这一行曾经是正常账号(登录过),现在被管理员停用了。 + // **停用是权威状态**:不能靠「重新注册」把它翻回启用,否则拿到邀请码的人就能推翻管理员的封禁。 + Record(email, ip, "register", false, "account disabled by admin"); + return new RegisterResult(false, 403, "该账号已被管理员停用,请联系管理员", false, null); + } + var password = CheckPassword(request.Password, email); + if (!password.Ok) + { + Record(email, ip, "register", false, "weak password"); + return new RegisterResult(false, 400, password.Error, false, null); + } + // 限流:同一 IP 每小时最多建成几个账号。 + // + // ⚠ 这里**只把「真的建出了账号」算进严格配额**,失败尝试(邀请码填错、密码太短、域名不对) + // 不算 —— 否则一个正常新用户表单填错几次就被挡一小时,而且在 NAT / 手机网络下 + // 会连累同 IP 的其他人(真机验收就撞到过:连续两次运行验收脚本,第二次直接 429)。 + // 失败尝试另有一个宽松上限,避免有人拿邀请码当靶子爆破;审计里两类都记得清清楚楚。 + var perIp = Policy.RegisterPerHourPerIp; + if (perIp > 0 && store.CountAuthEvents(null, ip, "register", true, 60) >= perIp) + { + Record(email, ip, "register", false, "rate limited (ip quota)"); + return new RegisterResult(false, 429, "注册请求过于频繁,请稍后再试", false, null); + } + var failLimit = Math.Max(10, perIp * 4); + if (perIp > 0 && store.CountAuthEvents(null, ip, "register", false, 60) >= failLimit) + { + Record(email, ip, "register", false, "rate limited (failed attempts)"); + return new RegisterResult(false, 429, "注册请求过于频繁,请稍后再试", false, null); + } + // 同一邮箱也有次数上限(防止有人盯着一个地址反复发验证码) + if (perIp > 0 && store.CountAuthEvents(email, null, "register", true, 60) >= Math.Max(2, perIp)) + { + Record(email, ip, "register", false, "email rate limited"); + return new RegisterResult(false, 429, "该邮箱的注册请求过于频繁,请稍后再试", false, null); + } + + var displayName = string.IsNullOrWhiteSpace(request.DisplayName) ? email.Split('@')[0] : request.DisplayName!.Trim(); + var needsVerify = NeedsEmailVerification(email); + + // 密码哈希先算好,但**先不放用户表**(要验证邮箱时才如此) + var salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + var pendingHash = HashPassword(request.Password!, salt); + + // 先把信箱行建出来(未激活),否则本地投递看不到这个收件人、验证码邮件送不进来。 + // 需要验证时,用户行里的密码是随机不可用值 —— 就算有人抢先用你的邮箱注册, + // 也无法在这个账号上留下自己的密码;密码只在验证码通过时写入。 + var unusableHash = HashPassword(Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), salt); + var created = store.CreateUserWithHash(email, needsVerify ? unusableHash : pendingHash, salt, displayName, active: !needsVerify); + if (!needsVerify && !created.Active) + { + store.SetUserActive(email, true); + created = store.FindUser(email) ?? created; + } + + if (!needsVerify) + { + var why = IsHostedHere(email) ? "本机托管的信箱,授权凭据是邀请码" : "策略未要求邮箱验证"; + Record(email, ip, "register", true, $"created without email verification({why})"); + AppLog.Info($"[账号] 新账号已直接开通(免邮箱验证):{email} —— {why}"); + return new RegisterResult(true, 201, "", false, NewSession(created, ip, userAgent)); + } + + var payload = JsonSerializer.Serialize(new PendingRegistration + { + DisplayName = displayName, + PasswordHash = pendingHash, + PasswordSalt = salt, + }); + var code = CreateCode(email, "register", payload); + var sent = SendCodeMail(email, code, "注册验证", "register"); + Record(email, ip, "register", true, sent.Ok ? "code sent" : $"code send failed: {sent.Error}"); + if (!sent.Ok) + return new RegisterResult(false, 502, $"验证邮件发送失败:{sent.Error}", true, null); + + return new RegisterResult(true, 202, "", true, new + { + verificationRequired = true, + email, + expiresInMinutes = Policy.CodeMinutes, + }); + } + + /// 校验注册验证码:通过后写入密码并激活账号(等价于注册即登录)。 + public (bool Ok, int Status, string Error, object? Session) VerifyRegistration(VerifyCodeRequest request, string ip, string userAgent) + { + var email = (request.Email ?? "").Trim().ToLowerInvariant(); + if (!LooksLikeEmail(email)) return (false, 400, "邮箱地址不合法", null); + + var user = store.FindUserAnyState(email); + if (user is null) return (false, 404, "没有待验证的注册,请先提交注册", null); + if (user.Active) return (false, 409, "这个邮箱已经激活过了,请直接登录", null); + + var (ok, error, record) = ConsumeCode(email, "register", request.Code ?? ""); + if (!ok) return (false, 400, error, null); + + PendingRegistration? pending; + try + { + pending = JsonSerializer.Deserialize( + record?.Payload ?? "", new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + catch { pending = null; } + + if (pending is null || string.IsNullOrEmpty(pending.PasswordHash) || string.IsNullOrEmpty(pending.PasswordSalt)) + return (false, 400, "注册信息已失效,请重新提交注册", null); + + // 到这一步验证码已证明邮箱归属,才把密码写进用户行并激活 + store.CreateUserWithHash(email, pending.PasswordHash, pending.PasswordSalt, + string.IsNullOrWhiteSpace(pending.DisplayName) ? user.DisplayName : pending.DisplayName, active: false); + store.SetUserActive(email, true); + var activated = store.FindUser(email) ?? user; + Record(email, ip, "register-verify", true, "activated"); + AppLog.Info($"[账号] 新账号已激活:{email}"); + return (true, 201, "", NewSession(activated, ip, userAgent)); + } + + // ---------------------------------------------------------------- 密码重置 + + public (bool Ok, string Error) RequestReset(string email, string ip) + { + email = (email ?? "").Trim().ToLowerInvariant(); + if (!LooksLikeEmail(email)) return (false, "邮箱地址不合法"); + var user = store.FindUserAnyState(email); + if (user is null) + { + // 不暴露账号是否存在;但仍然记录审计 + Record(email, ip, "reset-request", false, "unknown user"); + return (true, ""); + } + var limit = Math.Max(2, Policy.ResendPerHourPerEmail); + if (store.CountAuthEvents(email, null, "reset-request", null, 60) >= limit) + { + Record(email, ip, "reset-request", false, "rate limited"); + return (false, "请求过于频繁,请稍后再试"); + } + var code = CreateCode(email, "reset", ""); + var sent = SendCodeMail(email, code, "重置密码", "reset"); + Record(email, ip, "reset-request", sent.Ok, sent.Ok ? "code sent" : sent.Error); + return sent.Ok ? (true, "") : (false, $"验证邮件发送失败:{sent.Error}"); + } + + public (bool Ok, int Status, string Error) ResetPassword(ResetPasswordRequest request, string ip) + { + var email = (request.Email ?? "").Trim().ToLowerInvariant(); + if (!LooksLikeEmail(email)) return (false, 400, "邮箱地址不合法"); + var check = CheckPassword(request.Password, email); + if (!check.Ok) return (false, 400, check.Error); + + var (ok, error, _) = ConsumeCode(email, "reset", request.Code ?? ""); + if (!ok) return (false, 400, error); + + try { store.ChangePassword(email, request.Password!); } + catch (InvalidOperationException) { return (false, 404, "账号不存在"); } + + // 未激活的账号走到这里说明邮箱归属已被证明(验证码在本人手里)→ 顺带激活, + // 这就是「注册时验证码没收到、卡在未激活」的兜底恢复路径。 + var anyState = store.FindUserAnyState(email); + var activated = anyState is { Active: false }; + if (activated) store.SetUserActive(email, true); + + // 改密后踢掉所有会话(别人的登录一并失效,这是安全要求) + var revoked = store.RemoveSessions(email, null); + Record(email, ip, "reset-ok", true, $"sessions revoked={revoked} activated={activated}"); + AppLog.Info($"[账号] {email} 通过邮件验证码重置了密码{(activated ? "并激活了账号" : "")},已吊销 {revoked} 个会话。"); + return (true, 200, ""); + } + + /// 重发验证码(注册 / 重置共用)。 + public (bool Ok, int Status, string Error) ResendCode(string email, string purpose, string ip) + { + email = (email ?? "").Trim().ToLowerInvariant(); + if (!LooksLikeEmail(email)) return (false, 400, "邮箱地址不合法"); + if (purpose is not ("register" or "reset")) return (false, 400, "purpose 只能是 register 或 reset"); + + var limit = Math.Max(2, Policy.ResendPerHourPerEmail); + if (store.CountAuthEvents(email, null, "code-sent", null, 60) >= limit) + { + Record(email, ip, "code-sent", false, "rate limited"); + return (false, 429, "重发过于频繁,请稍后再试"); + } + var existing = store.FindVerificationCode(email, purpose); + if (existing is null) return (false, 404, "没有待验证的请求,请重新发起"); + + var code = CreateCode(email, purpose, existing.Payload); + var sent = SendCodeMail(email, code, purpose == "register" ? "注册验证" : "重置密码", purpose); + Record(email, ip, "code-sent", sent.Ok, sent.Ok ? "resent" : sent.Error); + return sent.Ok ? (true, 200, "") : (false, 502, $"验证邮件发送失败:{sent.Error}"); + } + + // ---------------------------------------------------------------- 登录加固 + + /// + /// 登录失败(Authenticate 返回 null)时该怎么回话,以及审计里写什么原因。 + /// + /// 原则:**只在用户真的卡住时多说话**。「注册了但没验证完」的人如果只看到「邮箱或密码不正确」, + /// 会一直以为密码错了 —— 而正确的出路是完成验证或用「忘记密码」。 + /// 其余情形(账号不存在 / 密码错 / 被管理员停用)一律同一句话,不做账号状态探测。 + /// + public (int Status, string Error, bool PendingVerification, string AuditReason) LoginFailureHint(string email) + { + var any = store.FindUserAnyState(email); + if (any is null) return (401, "邮箱或密码不正确", false, "unknown user"); + if (any.Active) return (401, "邮箱或密码不正确", false, "bad password"); + if (store.FindVerificationCode(email, "register") is not null) + { + return (403, + "这个邮箱的注册还没完成邮箱验证。请用注册时收到的验证码完成验证,或用「忘记密码」重设密码。", + true, "pending verification"); + } + // 被管理员停用(或注册早已过期作废)—— 不区分,避免探测账号状态 + return (401, "邮箱或密码不正确", false, "account not activated or disabled"); + } + + /// 返回锁定剩余秒数;0 表示未锁定。 + public int LockRemainingSeconds(string email) + { + var max = Policy.MaxLoginFailures; + if (max <= 0) return 0; + var failures = store.CountAuthEvents(email, null, "login-failed", false, Policy.LockoutMinutes); + if (failures < max) return 0; + var recent = store.ListAuthEvents(email, null, "login-failed", max); + if (recent.Count == 0) return 0; + var unlockAt = recent[0].At.AddMinutes(Policy.LockoutMinutes); + var left = (int)Math.Ceiling((unlockAt - DateTimeOffset.UtcNow).TotalSeconds); + return Math.Max(1, left); + } + + public object? NewSession(MailUser user, string ip, string userAgent) + { + var session = store.CreateSession(user.Email, config.Api.SessionDays); + store.SetLastLogin(user.Email); + Record(user.Email, ip, "login-ok", true, userAgent); + return new + { + token = session.Token, + expiresAt = session.Expires, + user = new { email = user.Email, displayName = user.DisplayName, role = user.Role, domain = user.Email.Split('@').LastOrDefault() }, + }; + } + + public void Record(string email, string ip, string reason, bool success, string detail = "", string userAgent = "") + => store.RecordAuthEvent(new AuthEvent + { + Email = email ?? "", + Ip = ip ?? "", + Reason = reason, + Success = success, + Detail = detail, + UserAgent = userAgent, + }); + + // ---------------------------------------------------------------- 会话 / 资料 + + public object SessionsView(string email, string? currentToken) => + new + { + sessions = store.ListSessions(email).Select(s => new + { + tokenPrefix = s.Token.Length > 10 ? s.Token[..10] : s.Token, + token = s.Token, + current = !string.IsNullOrEmpty(currentToken) && s.Token == currentToken, + createdAt = s.CreatedAt, + expiresAt = s.Expires, + }), + }; + + public int RevokeSessions(string email, string? keepToken) => store.RemoveSessions(email, keepToken); + + public void UpdateProfile(string email, string? displayName) + { + if (displayName is null) return; + store.UpdateProfile(email, displayName.Trim()); + } + + // ---------------------------------------------------------------- 发信 + + /// 把验证码邮件投进出站队列(复用既有的 Mime.Build + QueueOutbound + 投递/重试链路)。 + private (bool Ok, string Error) SendCodeMail(string to, string code, string title, string purpose) + { + try + { + var minutes = Math.Max(1, Policy.CodeMinutes); + var text = new StringBuilder() + .AppendLine($"你正在{(purpose == "register" ? "注册" : "重置")} WpywMail 账号({to})。") + .AppendLine() + .AppendLine($"验证码:{code}") + .AppendLine() + .AppendLine($"验证码 {minutes} 分钟内有效,最多可尝试 {Math.Max(1, Policy.MaxCodeAttempts)} 次。") + .AppendLine("如果不是你本人操作,忽略这封邮件即可,你的账号不受影响。") + .AppendLine() + .AppendLine($"—— {config.Hostname}") + .ToString(); + + var sender = string.IsNullOrWhiteSpace(config.AdminEmail) ? $"postmaster@{config.Domain}" : config.AdminEmail; + var raw = Mime.Build(new ComposeRequest( + sender, + "WpywMail", + [to], + [], + $"【WpywMail】{title}验证码:{code}", + text, + null, + [], + MessageId: null, + InReplyTo: "", + References: ""), config); + + var message = store.QueueOutbound(sender, [to], $"[WpywMail] {title}验证码", text, raw, + string.Join(", ", Array.Empty()), "", "", null); + AppLog.Info($"[账号] 已入队验证码邮件:{to}({title},messageId={message.Id})"); + return (true, ""); + } + catch (Exception ex) + { + AppLog.Error($"[账号] 验证码邮件入队失败:{to} —— {ex.Message}"); + return (false, ex.Message); + } + } + + private static string HashPassword(string password, string salt) => + Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, Convert.FromBase64String(salt), 120_000, HashAlgorithmName.SHA256, 32)); + + /// 注册待验证时暂存的信息(存进验证码记录的 Payload,验证通过后才写进用户表)。 + public sealed class PendingRegistration + { + public string DisplayName { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public string PasswordSalt { get; set; } = ""; + } +} diff --git a/server-native-v2/ApiServer.cs b/server-native-v2/ApiServer.cs new file mode 100644 index 0000000..da45b75 --- /dev/null +++ b/server-native-v2/ApiServer.cs @@ -0,0 +1,877 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WpywMail.Native; + +/// +/// 管理/客户端 API(默认只监听 127.0.0.1,供 Webmail 与桌面客户端经反向代理访问)。 +/// +/// 认证:POST /api/login 换取 token,之后带 Authorization: Bearer <token>。 +/// 会话落盘(sessions.json),因此重启服务不会把已登录的客户端踢掉。 +/// +/// 端点一览见 README.md;v1 的 /api/login、/api/messages、/api/send、/api/config、 +/// /api/me、/api/logout、/api/account/password、/api/admin/users 全部保持兼容。 +/// +public sealed class ApiServer +{ + private readonly AppConfig config; + private readonly IMailStore store; + private readonly AccountService accounts; + private readonly HttpListener listener = new(); + /// 可选的公网监听(只放账号类接口,见 )。为空表示不开。 + private readonly HttpListener? publicListener = null; + private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public ApiServer(AppConfig config, IMailStore store) + { + this.config = config; + this.store = store; + this.accounts = new AccountService(config, store); + listener.Prefixes.Add(config.HttpPrefix); + + // 公网监听(需要在 Windows 里先给这个前缀绑定证书:netsh http add sslcert hostnameport=...) + if (!string.IsNullOrWhiteSpace(config.Api.PublicPrefix)) + { + publicListener = new HttpListener(); + publicListener.Prefixes.Add(config.Api.PublicPrefix); + } + } + + /// + /// 公网监听**只**放行「账号相关」的接口:注册、验证码、找回密码、登录、改密、会话与资料。 + /// 邮件读写(/api/messages、/api/send、/api/queue、/api/watch、/api/drafts)与管理接口 + /// (/api/admin/*)**一律不在公网暴露** —— 那些只能从回环/受控网络访问。 + /// 这是刻意做窄的暴露面:客户端要在公网自助注册与改密码,但读信发信走 IMAP/SMTP。 + /// + public static bool IsPublicAccountRoute(string path, string method) => method switch + { + "GET" => path is "/api/health" or "/api/version" or "/api/auth/policy" + or "/api/me" or "/api/account/sessions" or "/api/account/audit", + "POST" => path is "/api/login" or "/api/logout" or "/api/register" or "/api/register/verify" + or "/api/register/resend" or "/api/auth/forgot" or "/api/auth/reset" + or "/api/account/password" or "/api/account/sessions/revoke", + "PATCH" => path is "/api/account/profile", + _ => false, + }; + + /// 客户端 IP(本机调用时就是回环地址;审计与限流用)。 + private static string ClientIp(HttpListenerRequest request) => + request.RemoteEndPoint?.Address?.ToString() ?? ""; + + private static string UserAgent(HttpListenerRequest request) => + request.UserAgent ?? ""; + + public async Task RunAsync(CancellationToken token) + { + listener.Start(); + AppLog.Info($"[接口] 已监听:{config.HttpPrefix}"); + + // 公网监听走同一套处理器,靠 IsPublicAccountRoute 把路径收窄到账号类接口 + if (publicListener is not null) + { + try + { + publicListener.Start(); + AppLog.Info($"[接口] 公网账号入口已监听:{config.Api.PublicPrefix}" + + "(只放注册/验证/找回密码/登录/改密/会话资料;邮件与管理接口仍只在回环)"); + _ = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + var context = await publicListener.GetContextAsync().WaitAsync(token); + _ = Task.Run(() => SafeHandleAsync(context, isPublic: true), token); + } + }, token); + } + catch (Exception ex) + { + AppLog.Error($"[接口] 公网账号入口启动失败:{ex.Message}" + + "(HTTPS 前缀需要先用 netsh http add sslcert hostnameport= 绑定证书)"); + } + } + + try + { + while (!token.IsCancellationRequested) + { + var context = await listener.GetContextAsync().WaitAsync(token); + _ = Task.Run(() => SafeHandleAsync(context), token); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) { AppLog.Error($"[接口] 监听异常:{ex.Message}"); } + finally + { + listener.Stop(); + try { publicListener?.Stop(); } catch { } + } + } + + private async Task SafeHandleAsync(HttpListenerContext context, bool isPublic = false) + { + try { await HandleAsync(context, isPublic); } + catch (Exception ex) + { + AppLog.Error($"[接口] 未处理异常:{ex.Message}"); + try { await Reply(context.Response, new { error = "服务器内部错误" }, 500); } catch { } + } + } + + private async Task HandleAsync(HttpListenerContext context, bool isPublic = false) + { + var request = context.Request; + var response = context.Response; + response.Headers["Access-Control-Allow-Origin"] = config.Api.CorsOrigin; + response.Headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"; + response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, DELETE, OPTIONS"; + response.Headers["Access-Control-Expose-Headers"] = "Content-Disposition"; + + if (request.HttpMethod == "OPTIONS") { response.StatusCode = 204; response.Close(); return; } + + var path = (request.Url?.AbsolutePath ?? "/").TrimEnd('/'); + var method = request.HttpMethod.ToUpperInvariant(); + + // 公网入口:白名单之外的路径一律当作「不存在」,不给探测者任何信息 + if (isPublic && !IsPublicAccountRoute(path, method)) + { + AppLog.Warn($"[接口] 公网入口拒绝:{method} {request.Url?.PathAndQuery}(来自 {ClientIp(request)})"); + await Reply(response, new { error = "接口不存在" }, 404); + return; + } + + AppLog.Info($"[接口] {method} {request.Url?.PathAndQuery}"); + + // ---- 无需认证 ---- + if (path is "/api/health" && method == "GET") + { + await Reply(response, new { ok = true, service = "wpyw.mail.native", version = BuildInfo.Version, domain = config.Domain, hostname = config.Hostname }); + return; + } + if (path is "/api/version" && method == "GET") + { + await Reply(response, new { version = BuildInfo.Version, domain = config.Domain, hostname = config.Hostname, dkim = config.Dkim.Enabled }); + return; + } + if (path is "/api/login" && method == "POST") + { + await LoginAsync(request, response); + return; + } + + // ---- 账号体系:无需认证 ---- + if (path is "/api/auth/policy" && method == "GET") + { + await Reply(response, accounts.PolicyView()); + return; + } + if (path is "/api/register" && method == "POST") + { + var body = await ReadJsonAsync(request); + if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; } + var result = accounts.Register(body, ClientIp(request), UserAgent(request)); + await Reply(response, result.Ok + ? new { ok = true, verificationRequired = result.VerificationRequired, session = result.Session, + email = body.Email?.Trim().ToLowerInvariant(), + expiresInMinutes = result.VerificationRequired ? config.Accounts.CodeMinutes : (int?)null } + : new { error = result.Error }, result.Status); + return; + } + if (path is "/api/register/verify" && method == "POST") + { + var body = await ReadJsonAsync(request); + if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; } + var (ok, status, error, newSession) = accounts.VerifyRegistration(body, ClientIp(request), UserAgent(request)); + await Reply(response, ok ? new { ok = true, session = newSession } : new { error }, status); + return; + } + if (path is "/api/register/resend" && method == "POST") + { + var body = await ReadJsonAsync>(request) ?? []; + var email = body.GetValueOrDefault("email") ?? ""; + var purpose = body.TryGetValue("purpose", out var p) && !string.IsNullOrWhiteSpace(p) ? p : "register"; + var (ok, status, error) = accounts.ResendCode(email, purpose, ClientIp(request)); + await Reply(response, ok ? new { ok = true } : new { error }, status); + return; + } + if (path is "/api/auth/forgot" && method == "POST") + { + var body = await ReadJsonAsync>(request) ?? []; + var email = body.GetValueOrDefault("email") ?? ""; + var (ok, error) = accounts.RequestReset(email, ClientIp(request)); + // 成功时也统一返回 ok:不暴露「这个邮箱是否存在」 + await Reply(response, ok ? new { ok = true, expiresInMinutes = config.Accounts.CodeMinutes } : new { error }, + ok ? 200 : 429); + return; + } + if (path is "/api/auth/reset" && method == "POST") + { + var body = await ReadJsonAsync(request); + if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; } + var (ok, status, error) = accounts.ResetPassword(body, ClientIp(request)); + await Reply(response, ok ? new { ok = true } : new { error }, status); + return; + } + + // ---- 以下都需要认证 ---- + var token = ExtractToken(request); + var session = store.GetSession(token); + var user = session is null ? null : store.FindUser(session.Email); + if (user is null) + { + await Reply(response, new { error = "登录已失效,请重新登录" }, 401); + return; + } + + try + { + switch (path) + { + case "/api/logout" when method == "POST": + store.RemoveSession(token!); + await Reply(response, new { ok = true }); + return; + + case "/api/me" when method == "GET": + await Reply(response, new { user = Project(user), stats = store.Stats(user.Email) }); + return; + + case "/api/config" when method == "GET": + await Reply(response, new + { + domain = config.Domain, + hostname = config.Hostname, + account = user.Email, + protocols = new { smtp = config.SmtpPort, submission = config.SubmissionPort, api = config.HttpPrefix, tls = config.Smtp.AdvertiseStartTls }, + features = new { dkim = config.Dkim.Enabled, attachments = true, watch = true, drafts = true }, + }); + return; + + case "/api/messages" when method == "GET": + await ListMessagesAsync(request, response, user); + return; + + case "/api/send" when method == "POST": + await SendAsync(request, response, user); + return; + + case "/api/drafts" when method == "POST": + await SaveDraftAsync(request, response, user); + return; + + case "/api/queue" when method == "GET": + await Reply(response, new + { + queue = store.ListQueue(user.Email).Select(x => new + { + x.Id, x.MessageId, x.Recipients, x.Attempts, x.Status, x.LastError, x.LastCode, + nextAttempt = x.NextAttempt, createdAt = x.CreatedAt, lastAttemptAt = x.LastAttemptAt, + }), + }); + return; + + case "/api/watch" when method == "GET": + await WatchAsync(request, response, user); + return; + + case "/api/account/password" when method == "POST": + await ChangePasswordAsync(request, response, user); + return; + + case "/api/account/profile" when method == "PATCH": + await UpdateProfileAsync(request, response, user); + return; + + case "/api/account/sessions" when method == "GET": + await Reply(response, accounts.SessionsView(user.Email, token)); + return; + + case "/api/account/sessions/revoke" when method == "POST": + await RevokeSessionsAsync(request, response, user, token); + return; + + case "/api/account/audit" when method == "GET": + { + var limit = int.TryParse(request.QueryString["limit"], out var l) ? l : 50; + await Reply(response, new + { + events = store.ListAuthEvents(user.Email, null, null, limit).Select(ProjectAudit), + }); + return; + } + + case "/api/admin/audit" when method == "GET": + { + if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; } + var limit = int.TryParse(request.QueryString["limit"], out var l) ? l : 100; + var email = request.QueryString["email"]; + var ipFilter = request.QueryString["ip"]; + var reason = request.QueryString["reason"]; + await Reply(response, new + { + events = store.ListAuthEvents(email, ipFilter, reason, limit).Select(ProjectAudit), + }); + return; + } + + case "/api/admin/users": + if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; } + await AdminUsersAsync(request, response, method); + return; + } + + // /api/messages/{id}... + if (path.StartsWith("/api/messages/", StringComparison.OrdinalIgnoreCase)) + { + await MessageRouteAsync(request, response, user, path["/api/messages/".Length..], method); + return; + } + if (path.StartsWith("/api/queue/", StringComparison.OrdinalIgnoreCase)) + { + var id = path["/api/queue/".Length..]; + if (id.EndsWith("/retry", StringComparison.OrdinalIgnoreCase) && method == "POST") + { + store.RetryQueueItem(user.Email, id[..^"/retry".Length]); + await Reply(response, new { ok = true }); + return; + } + } + if (path.StartsWith("/api/admin/users/", StringComparison.OrdinalIgnoreCase)) + { + if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; } + await AdminUserPatchAsync(request, response, Uri.UnescapeDataString(path["/api/admin/users/".Length..]), method); + return; + } + + await Reply(response, new { error = "接口不存在" }, 404); + } + catch (InvalidOperationException ex) + { + await Reply(response, new { error = ex.Message }, 400); + } + catch (Exception ex) + { + AppLog.Error($"[接口] {method} {path}:{ex.Message}"); + await Reply(response, new { error = ex.Message }, 500); + } + } + + // ---------------------------------------------------------------- 认证 + + /// + /// 登录。相比 v2.0.x 增加了三件事: + /// ① 失败计数与临时锁定(同一账号在窗口内连续失败到阈值即锁定,返回 423 与剩余秒数); + /// ② 审计(成功/失败/锁定都记 IP 与 UA,便于排查与限流); + /// ③ 成功时更新 lastLoginAt 并签发会话。 + /// 对外错误信息统一为「邮箱或密码不正确」,真实原因只进审计,避免账号枚举。 + /// + private async Task LoginAsync(HttpListenerRequest request, HttpListenerResponse response) + { + var body = await ReadJsonAsync(request); + var ip = ClientIp(request); + var agent = UserAgent(request); + if (body is null || string.IsNullOrWhiteSpace(body.Email) || string.IsNullOrEmpty(body.Password)) + { + await Reply(response, new { error = "请提供 email 与 password" }, 400); + return; + } + + var email = body.Email.Trim().ToLowerInvariant(); + + var locked = accounts.LockRemainingSeconds(email); + if (locked > 0) + { + accounts.Record(email, ip, "login-locked", false, $"remaining={locked}s", agent); + AppLog.Warn($"[接口] 登录被拒(锁定中):{email},剩余 {locked}s"); + await Reply(response, new { error = $"失败次数过多,账号已临时锁定,请 {Math.Ceiling(locked / 60.0)} 分钟后再试", retryAfterSeconds = locked }, 423); + return; + } + + var user = store.Authenticate(email, body.Password); + if (user is null) + { + // 失败原因只进审计;对外是否多说一句,由 AccountService 判断(见 LoginFailureHint) + var hint = accounts.LoginFailureHint(email); + accounts.Record(email, ip, "login-failed", false, hint.AuditReason, agent); + var failures = store.CountAuthEvents(email, null, "login-failed", false, config.Accounts.LockoutMinutes); + AppLog.Warn($"[接口] 登录失败:{email}({hint.AuditReason},{failures}/{config.Accounts.MaxLoginFailures})"); + await Reply(response, new { error = hint.Error, pendingVerification = hint.PendingVerification }, hint.Status); + return; + } + + var session = accounts.NewSession(user, ip, agent); + await Reply(response, session!); + } + + private static string? ExtractToken(HttpListenerRequest request) + { + var header = request.Headers["Authorization"]; + if (string.IsNullOrWhiteSpace(header)) return null; + return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : header.Trim(); + } + + // ---------------------------------------------------------------- 邮件列表与详情 + + private static object Project(MailUser user) => new + { + email = user.Email, + displayName = user.DisplayName, + role = user.Role, + active = user.Active, + createdAt = user.CreatedAt, + lastLoginAt = user.LastLoginAt, + domain = user.Email.Split('@').LastOrDefault(), + }; + + private async Task ListMessagesAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var query = request.QueryString; + var folder = query["folder"] ?? "inbox"; + var search = query["q"] ?? ""; + var unreadOnly = query["unread"] is "1" or "true"; + var starredOnly = query["starred"] is "1" or "true"; + var limit = int.TryParse(query["limit"], out var parsedLimit) ? Math.Clamp(parsedLimit, 1, 500) : 100; + var offset = int.TryParse(query["offset"], out var parsedOffset) ? Math.Max(0, parsedOffset) : 0; + + var (total, items) = store.ListMessagesPage(user.Email, folder, search, unreadOnly, starredOnly, limit, offset); + var page = items.Select(Summary).ToArray(); + await Reply(response, new { total, offset, limit, messages = page }); + } + + private static object Summary(MailMessage message) => new + { + message.Id, + message.Folder, + message.From, + message.To, + message.Cc, + message.Subject, + message.Date, + message.ReceivedAt, + message.Unread, + message.Starred, + message.DeliveryStatus, + message.LastError, + message.Size, + attachmentCount = message.Attachments.Count, + hasAttachments = message.Attachments.Count > 0, + preview = Preview(message.Text.Length > 0 ? message.Text : StripTags(message.Html)), + }; + + private static string Preview(string text) + { + var flat = (text ?? "").Replace("\r", " ").Replace("\n", " ").Trim(); + return flat.Length <= 140 ? flat : flat[..140] + "…"; + } + + private static string StripTags(string html) + { + if (string.IsNullOrEmpty(html)) return ""; + var builder = new StringBuilder(html.Length); + var inside = false; + foreach (var c in html) + { + if (c == '<') { inside = true; continue; } + if (c == '>') { inside = false; continue; } + if (!inside) builder.Append(c); + } + return builder.ToString(); + } + + private async Task MessageRouteAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string rest, string method) + { + var segments = rest.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) { await Reply(response, new { error = "缺少邮件 id" }, 400); return; } + var id = Uri.UnescapeDataString(segments[0]); + var message = store.GetMessage(user.Email, id); + if (message is null) { await Reply(response, new { error = "邮件不存在" }, 404); return; } + + // /api/messages/{id}/raw 或 /api/messages/{id}/attachments/{index} + if (segments.Length >= 2) + { + if (segments[1].Equals("raw", StringComparison.OrdinalIgnoreCase) && method == "GET") + { + var bytes = store.ReadRaw(message.RawPath); + await ReplyBinary(response, bytes, "message/rfc822", $"{Sanitize(message.Subject)}.eml"); + return; + } + if (segments[1].Equals("attachments", StringComparison.OrdinalIgnoreCase) && method == "GET") + { + if (segments.Length < 3 || !int.TryParse(segments[2], out var index) || + index < 0 || index >= message.Attachments.Count) + { + await Reply(response, new { error = "附件不存在" }, 404); + return; + } + var attachment = message.Attachments[index]; + var data = store.ReadAttachment(attachment.StoredAs); + await ReplyBinary(response, data, attachment.ContentType, Sanitize(attachment.FileName)); + return; + } + await Reply(response, new { error = "接口不存在" }, 404); + return; + } + + switch (method) + { + case "GET": + { + var markRead = !string.Equals(request.QueryString["markRead"], "false", StringComparison.OrdinalIgnoreCase); + if (markRead && message.Unread) store.MarkRead(user.Email, id, true); + await Reply(response, new { message = Detail(message, markRead) }); + return; + } + case "PATCH": + { + var body = await ReadJsonAsync>(request) ?? []; + if (body.TryGetValue("unread", out var unread) && unread.ValueKind is JsonValueKind.True or JsonValueKind.False) + store.MarkRead(user.Email, id, !unread.GetBoolean()); + if (body.TryGetValue("read", out var read) && read.ValueKind is JsonValueKind.True or JsonValueKind.False) + store.MarkRead(user.Email, id, read.GetBoolean()); + if (body.TryGetValue("starred", out var starred) && starred.ValueKind is JsonValueKind.True or JsonValueKind.False) + store.SetStar(user.Email, id, starred.GetBoolean()); + if (body.TryGetValue("folder", out var folder) && folder.ValueKind == JsonValueKind.String) + store.MoveMessage(user.Email, id, folder.GetString() ?? "inbox"); + var updated = store.GetMessage(user.Email, id)!; + await Reply(response, new { message = Summary(updated) }); + return; + } + case "DELETE": + { + var permanent = string.Equals(request.QueryString["permanent"], "true", StringComparison.OrdinalIgnoreCase); + store.DeleteMessage(user.Email, id, permanent); + await Reply(response, new { ok = true, permanent }); + return; + } + default: + await Reply(response, new { error = "不支持的请求方法" }, 405); + return; + } + } + + private static object Detail(MailMessage message, bool markedRead) => new + { + message.Id, + message.Folder, + message.From, + message.To, + message.Cc, + message.Subject, + message.Text, + message.Html, + message.MessageId, + message.InReplyTo, + message.References, + message.Date, + message.ReceivedAt, + unread = markedRead ? false : message.Unread, + message.Starred, + message.DeliveryStatus, + message.LastError, + message.Size, + message.DkimSigned, + attachments = message.Attachments.Select((a, index) => new + { + index, + a.FileName, + a.ContentType, + a.Size, + a.Inline, + url = $"/api/messages/{message.Id}/attachments/{index}", + }), + rawUrl = $"/api/messages/{message.Id}/raw", + }; + + private static string Sanitize(string name) + { + var cleaned = new string((name ?? "message").Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray()).Trim(); + return cleaned.Length == 0 ? "message" : cleaned; + } + + // ---------------------------------------------------------------- 发信 + + private async Task SendAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var body = await ReadJsonAsync(request); + if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; } + + var recipients = Mime.Addresses(body.To ?? ""); + var cc = Mime.Addresses(body.Cc ?? ""); + if (recipients.Length == 0) { await Reply(response, new { error = "收件人不能为空" }, 400); return; } + if (string.IsNullOrWhiteSpace(body.Text) && string.IsNullOrWhiteSpace(body.Html)) + { + await Reply(response, new { error = "正文不能为空" }, 400); + return; + } + + var subject = string.IsNullOrWhiteSpace(body.Subject) ? "(无主题)" : body.Subject!; + var stored = new List(); + var outgoing = new List(); + foreach (var attachment in body.Attachments ?? []) + { + if (string.IsNullOrWhiteSpace(attachment.Base64)) continue; + byte[] data; + try { data = Convert.FromBase64String(attachment.Base64); } + catch { await Reply(response, new { error = $"附件 {attachment.FileName} 不是合法 base64" }, 400); return; } + + var contentType = string.IsNullOrWhiteSpace(attachment.ContentType) ? "application/octet-stream" : attachment.ContentType!; + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment.bin" : attachment.FileName!; + stored.Add(new Attachment + { + FileName = fileName, + ContentType = contentType, + Size = data.Length, + StoredAs = store.SaveAttachment(data, fileName), + }); + outgoing.Add(new OutgoingAttachment(fileName, contentType, data)); + } + + var raw = Mime.Build(new ComposeRequest( + user.Email, + user.DisplayName, + recipients, + cc, + subject, + body.Text ?? "", + body.Html, + outgoing, + MessageId: null, + InReplyTo: body.InReplyTo ?? "", + References: body.InReplyTo ?? ""), config); + + var message = store.QueueOutbound(user.Email, recipients, subject, body.Text ?? "", raw, + string.Join(", ", cc), body.Html ?? "", body.InReplyTo ?? "", stored); + + AppLog.Info($"[接口] 已入队发信:{user.Email} → {string.Join(", ", recipients)},主题:{subject}"); + await Reply(response, new { queued = true, messageId = message.Id, recipients, cc }, 202); + } + + private async Task SaveDraftAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var body = await ReadJsonAsync(request) ?? new DraftRequest("", "", ""); + var message = new MailMessage + { + OwnerEmail = user.Email, + Folder = "drafts", + From = user.Email, + To = body.To ?? "", + Subject = string.IsNullOrWhiteSpace(body.Subject) ? "(无主题)" : body.Subject!, + Text = body.Text ?? "", + Unread = false, + DeliveryStatus = "draft", + }; + store.SaveMessage(message); + await Reply(response, new { message = Summary(message) }, 201); + } + + // ---------------------------------------------------------------- 长轮询 + + private async Task WatchAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var since = long.TryParse(request.QueryString["since"], out var parsed) ? parsed : store.Version; + var deadline = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(config.Api.LongPollSeconds, 1, 120)); + var stats = store.Stats(user.Email); + + while (DateTimeOffset.UtcNow < deadline && store.Version == since) + { + await Task.Delay(500); + } + + await Reply(response, new + { + version = store.Version, + changed = store.Version != since, + stats = store.Stats(user.Email), + }); + } + + // ---------------------------------------------------------------- 账号与管理员 + + private async Task ChangePasswordAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var body = await ReadJsonAsync>(request) ?? []; + var check = accounts.CheckPassword(body.GetValueOrDefault("password"), user.Email); + if (!check.Ok) + { + await Reply(response, new { error = check.Error }, 400); + return; + } + var password = body["password"]; + if (body.TryGetValue("currentPassword", out var current) && store.Authenticate(user.Email, current) is null) + { + await Reply(response, new { error = "当前密码不正确" }, 403); + return; + } + store.ChangePassword(user.Email, password); + // 改密后把其他设备踢下线(当前这个 token 保留,避免自己也被踢) + var revoked = store.RemoveSessions(user.Email, ExtractToken(request)); + accounts.Record(user.Email, ClientIp(request), "password-changed", true, $"sessions revoked={revoked}", UserAgent(request)); + AppLog.Info($"[接口] {user.Email} 已修改密码,吊销其他会话 {revoked} 个。"); + await Reply(response, new { ok = true, revokedSessions = revoked }); + } + + private async Task UpdateProfileAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var body = await ReadJsonAsync(request); + if (body is null || body.DisplayName is null) + { + await Reply(response, new { error = "没有可更新的字段(目前支持 displayName)" }, 400); + return; + } + var name = body.DisplayName.Trim(); + if (name.Length > 64) { await Reply(response, new { error = "显示名最多 64 个字符" }, 400); return; } + accounts.UpdateProfile(user.Email, name); + accounts.Record(user.Email, ClientIp(request), "profile-updated", true, name, UserAgent(request)); + var fresh = store.FindUser(user.Email); + await Reply(response, new { ok = true, user = Project(fresh ?? user) }); + } + + private async Task RevokeSessionsAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string? currentToken) + { + var body = await ReadJsonAsync>(request) ?? []; + var all = body.TryGetValue("all", out var a) && a.ValueKind is JsonValueKind.True; + if (all) + { + var removed = accounts.RevokeSessions(user.Email, null); + accounts.Record(user.Email, ClientIp(request), "session-revoked", true, $"all={removed}", UserAgent(request)); + await Reply(response, new { ok = true, revoked = removed, selfRevoked = true }); + return; + } + if (body.TryGetValue("token", out var t) && t.ValueKind == JsonValueKind.String) + { + var target = t.GetString() ?? ""; + if (string.IsNullOrWhiteSpace(target)) { await Reply(response, new { error = "token 不能为空" }, 400); return; } + if (target == currentToken) + { + // 允许「把我自己这个会话也吊销」= 等价于登出 + store.RemoveSession(target); + await Reply(response, new { ok = true, revoked = 1, selfRevoked = true }); + return; + } + // 只允许吊销属于该账号的会话,避免越权 + var mine = store.ListSessions(user.Email).Any(s => s.Token == target); + if (!mine) { await Reply(response, new { error = "该会话不属于当前账号" }, 403); return; } + store.RemoveSession(target); + accounts.Record(user.Email, ClientIp(request), "session-revoked", true, "one", UserAgent(request)); + await Reply(response, new { ok = true, revoked = 1, selfRevoked = false }); + return; + } + // 默认行为:退出其他设备(保留当前) + var count = accounts.RevokeSessions(user.Email, currentToken); + accounts.Record(user.Email, ClientIp(request), "session-revoked", true, $"others={count}", UserAgent(request)); + await Reply(response, new { ok = true, revoked = count, selfRevoked = false }); + } + + private static object ProjectAudit(AuthEvent e) => new + { + e.Id, e.Email, e.Ip, e.Reason, e.Success, e.Detail, e.UserAgent, e.At, + }; + + private async Task AdminUsersAsync(HttpListenerRequest request, HttpListenerResponse response, string method) + { + if (method == "GET") + { + await Reply(response, new + { + users = store.ListUsers().Select(x => new { x.Email, x.DisplayName, x.Role, x.Active, x.CreatedAt, x.LastLoginAt }), + }); + return; + } + if (method == "POST") + { + var body = await ReadJsonAsync>(request) ?? []; + if (!body.TryGetValue("email", out var email) || !body.TryGetValue("password", out var password) || password.Length < 12) + { + await Reply(response, new { error = "需要 email 与至少 12 位 password" }, 400); + return; + } + var created = store.CreateUser(email, password, body.GetValueOrDefault("displayName", "") ?? ""); + await Reply(response, new { user = new { created.Email, created.DisplayName, created.Role, created.Active } }, 201); + return; + } + await Reply(response, new { error = "不支持的请求方法" }, 405); + } + + private async Task AdminUserPatchAsync(HttpListenerRequest request, HttpListenerResponse response, string email, string method) + { + if (method != "PATCH") { await Reply(response, new { error = "不支持的请求方法" }, 405); return; } + var body = await ReadJsonAsync>(request) ?? []; + if (body.TryGetValue("active", out var active) && active.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + store.SetUserActive(email, active.GetBoolean()); + await Reply(response, new { ok = true, email, active = active.GetBoolean() }); + return; + } + if (body.TryGetValue("password", out var password) && password.ValueKind == JsonValueKind.String) + { + var value = password.GetString() ?? ""; + if (value.Length < 12) { await Reply(response, new { error = "密码至少需要 12 个字符" }, 400); return; } + store.ChangePassword(email, value); + await Reply(response, new { ok = true, email }); + return; + } + await Reply(response, new { error = "没有可更新的字段" }, 400); + } + + // ---------------------------------------------------------------- HTTP 工具 + + private static async Task ReadJsonAsync(HttpListenerRequest request) + { + if (!request.HasEntityBody) return default; + using var reader = new StreamReader(request.InputStream, Encoding.UTF8); + var text = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(text)) return default; + try { return JsonSerializer.Deserialize(text, new JsonSerializerOptions(JsonSerializerDefaults.Web)); } + catch (JsonException) { return default; } + } + + private Task Reply(HttpListenerResponse response, object value, int status = 200) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, json); + response.StatusCode = status; + response.ContentType = "application/json; charset=utf-8"; + response.ContentLength64 = bytes.Length; + return WriteAndCloseAsync(response, bytes); + } + + private static async Task ReplyBinary(HttpListenerResponse response, byte[] data, string contentType, string fileName) + { + response.StatusCode = 200; + response.ContentType = contentType; + response.ContentLength64 = data.Length; + response.Headers["Content-Disposition"] = $"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}"; + await WriteAndCloseAsync(response, data); + } + + private static async Task WriteAndCloseAsync(HttpListenerResponse response, byte[] data) + { + try + { + await response.OutputStream.WriteAsync(data); + response.OutputStream.Flush(); + } + catch (Exception) { /* 客户端提前断开 */ } + finally { response.Close(); } + } +} + +/// 版本信息(客户端可据此判断兼容性)。 +public static class BuildInfo +{ + /// + /// 2.0.1:修正 DKIM 签名输入顺序(RFC 6376 §3.7)。2.0.0 把 DKIM-Signature 头放在 + /// 签名输入的最前面并多带一个结尾 CRLF,导致所有合规验证器(Gmail/Outlook/port25)判 dkim=fail。 + /// + /// 2.2.0:账号体系(自助注册 / 登录加固 / 邮箱验证码 / 找回密码 / 会话与资料管理 / 认证审计)。 + /// 两条关键设计见 README 第 6.2.2 节:本机托管的邮箱注册免验证(否则验证码邮件投不进 + /// 还没开通的信箱,死循环);未激活账号先建信箱行、密码只存在验证码记录里。 + /// 2.2.1:入站 SPF/DKIM/DMARC 校验 + 垃圾判定(默认标注并把失败件投垃圾箱,不拒收); + /// 新增 --verify-inbound 维护命令;顺带修掉签名端用 ASCII 取签名输入字节的潜在缺陷。 + /// + public const string Version = "2.2.1"; + public const string Product = "wpyw.mail.native"; +} diff --git a/server-native-v2/AppLog.cs b/server-native-v2/AppLog.cs new file mode 100644 index 0000000..fc76920 --- /dev/null +++ b/server-native-v2/AppLog.cs @@ -0,0 +1,54 @@ +using System.Text; + +namespace WpywMail.Native; + +/// 极简日志:控制台 + 文件(超过阈值自动滚动一次)。 +public static class AppLog +{ + private const long MaxBytes = 20 * 1024 * 1024; + private static readonly object Gate = new(); + private static string path = ""; + private static bool consoleEnabled = true; + + public static void Configure(AppConfig config, bool enableConsole = true) + { + Directory.CreateDirectory(config.DataDirectory); + path = Path.Combine(config.DataDirectory, "service.log"); + consoleEnabled = enableConsole; + Roll(); + Info($"日志系统已启动(版本 {BuildInfo.Version})。"); + } + + public static void Info(string message) => Write("信息", message); + public static void Warn(string message) => Write("警告", message); + public static void Error(string message) => Write("错误", message); + + private static void Write(string level, string message) + { + var line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz} [{level}] {message}"; + if (consoleEnabled) + { + try { Console.WriteLine(line); } catch { } + } + if (string.IsNullOrWhiteSpace(path)) return; + try + { + lock (Gate) File.AppendAllText(path, line + Environment.NewLine, new UTF8Encoding(false)); + } + catch { } + } + + private static void Roll() + { + try + { + if (!File.Exists(path)) return; + var info = new FileInfo(path); + if (info.Length < MaxBytes) return; + var backup = path + ".1"; + if (File.Exists(backup)) File.Delete(backup); + File.Move(path, backup); + } + catch { } + } +} diff --git a/server-native-v2/DeliveryQueue.cs b/server-native-v2/DeliveryQueue.cs new file mode 100644 index 0000000..bf1f7af --- /dev/null +++ b/server-native-v2/DeliveryQueue.cs @@ -0,0 +1,94 @@ +namespace WpywMail.Native; + +/// +/// 发件队列。轮询待发任务 → 读取原始报文 →(可选)DKIM 签名 → 投递 → 记录结果。 +/// +/// 相比 v1 的改进: +/// 1. 重试策略可配置(4xx 与 5xx 分开处理,指数退避带上限); +/// 2. DKIM 在投递时签名,因此每次重试都会带上新的时间戳; +/// 3. 彻底失败时给发件人投递一封退信(NDR),不再静默丢失。 +/// +public sealed class DeliveryQueue +{ + private readonly AppConfig config; + private readonly IMailStore store; + private readonly DirectSmtpDelivery delivery; + private readonly DkimSigner? signer; + + public DeliveryQueue(AppConfig config, IMailStore store, DkimSigner? signer) + { + this.config = config; + this.store = store; + this.signer = signer; + delivery = new DirectSmtpDelivery(config, store); + } + + public async Task RunAsync(CancellationToken token) + { + AppLog.Info($"[发送] 投递模式:{(config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) ? "SMTP 中继" : "按 MX 直接投递")}"); + AppLog.Info($"[发送] 重试策略:最多 {config.Retry.MaxAttempts} 次,初始间隔 {config.Retry.InitialDelaySeconds}s,上限 {config.Retry.MaxDelaySeconds}s" + + (config.Retry.RetryOnPermanentFailure ? $",5xx 也重试 {config.Retry.MaxAttemptsForPermanent} 次" : ",5xx 视为永久失败")); + + while (!token.IsCancellationRequested) + { + foreach (var job in store.TakeDueQueue(10)) + { + if (token.IsCancellationRequested) break; + await ProcessAsync(job, token); + } + try { await Task.Delay(TimeSpan.FromSeconds(5), token); } + catch (OperationCanceledException) { break; } + } + } + + private async Task ProcessAsync(QueueItem job, CancellationToken token) + { + var targets = string.Join(", ", job.Recipients); + try + { + var message = store.GetById(job.MessageId) ?? throw new InvalidOperationException("发送队列中的邮件不存在。"); + + // 每次投递都重新读取原始报文。 + // 关键顺序:先把行尾规范化为 CRLF,再签名 —— 这样签名覆盖的字节 + // 与传输时写出的字节完全一致(传输阶段只做 dot-stuffing)。 + var raw = SmtpDataEncoder.Normalize(store.ReadRaw(message.RawPath)); + if (signer is not null) + { + raw = signer.Sign(raw); + message.DkimSigned = true; + } + + await delivery.DeliverAsync(message, job.Recipients, raw, token); + store.CompleteQueue(job); + AppLog.Info($"[发送] 投递成功:{targets}(第 {job.Attempts + 1} 次尝试)"); + } + catch (Exception ex) + { + store.FailQueue(job, ex, config.Retry, out var gaveUp); + if (gaveUp) + { + AppLog.Error($"[发送] 已放弃投递:{targets} —— {ex.Message}"); + if (config.Retry.SendBounceNotification) SendBounce(job, ex); + } + else + { + AppLog.Warn($"[发送] 投递失败(将重试):{targets} —— {ex.Message}"); + } + } + } + + private void SendBounce(QueueItem job, Exception error) + { + try + { + var message = store.GetById(job.MessageId); + if (message is null) return; + store.CreateBounce(job.OwnerEmail, message.Subject, job.Recipients, error.Message, message.RawPath); + AppLog.Info($"[发送] 已向 {job.OwnerEmail} 投递退信通知。"); + } + catch (Exception ex) + { + AppLog.Error($"[发送] 生成退信失败:{ex.Message}"); + } + } +} diff --git a/server-native-v2/DirectSmtpDelivery.cs b/server-native-v2/DirectSmtpDelivery.cs new file mode 100644 index 0000000..8da7068 --- /dev/null +++ b/server-native-v2/DirectSmtpDelivery.cs @@ -0,0 +1,438 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; + +namespace WpywMail.Native; + +/// +/// 出站投递。支持两种模式: +/// direct —— 查 MX 直接投递(默认) +/// relay —— 交给上游 SMTP 中继(可带认证与 STARTTLS) +/// +/// 相比 v1 的关键修正: +/// DATA 阶段按「字节」写出(v1 用 Encoding.ASCII 的 StreamWriter,导致所有中文变成 '?'), +/// 并显式处理 dot-stuffing 与行尾规范化。 +/// +public sealed class DirectSmtpDelivery +{ + private readonly AppConfig config; + private readonly IMailStore store; + + public DirectSmtpDelivery(AppConfig config, IMailStore store) + { + this.config = config; + this.store = store; + } + + public async Task DeliverAsync(MailMessage message, string[] recipients, byte[] raw, CancellationToken token) + { + if (recipients.Length == 0) throw new InvalidOperationException("没有可投递的收件人。"); + + // 本地收件人直接入库,不必绕一圈 SMTP 连回自己(也避免自签名证书导致的 TLS 自校验失败) + var local = recipients.Where(r => IsValidAddress(r) && store.IsLocalAddress(r)).ToArray(); + foreach (var recipient in local) + { + store.DeliverLocal(recipient, raw, message.From); + AppLog.Info($"[发送] 本地投递完成:{recipient}"); + } + + var remote = recipients.Where(r => IsValidAddress(r) && !store.IsLocalAddress(r)).ToArray(); + if (remote.Length == 0) return; + + if (config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase)) + { + var helo = string.IsNullOrWhiteSpace(config.DirectDelivery.HeloName) ? config.Hostname : config.DirectDelivery.HeloName; + await SendAsync(config.Relay.Host, config.Relay.Port, message.From, remote, raw, + startTls: config.Relay.EnableSsl, requireStartTls: config.Relay.EnableSsl, + user: config.Relay.User, password: config.Relay.Password, helo: helo, token: token); + return; + } + + foreach (var group in remote.GroupBy(GetDomain, StringComparer.OrdinalIgnoreCase)) + { + await DeliverDomainAsync(message.From, group.Key, group.ToArray(), raw, token); + } + } + + private async Task DeliverDomainAsync(string sender, string domain, string[] recipients, byte[] raw, CancellationToken token) + { + var mxHosts = await MxResolver.ResolveAsync(domain, config.DirectDelivery, token); + if (mxHosts.Count == 0) throw new SmtpDeliveryException("MX 查询", 550, $"找不到 {domain} 的 MX 记录。", permanent: true); + + var helo = string.IsNullOrWhiteSpace(config.DirectDelivery.HeloName) ? config.Hostname : config.DirectDelivery.HeloName; + Exception? last = null; + + foreach (var mxHost in mxHosts) + { + try + { + await SendAsync(mxHost, 25, sender, recipients, raw, + startTls: config.DirectDelivery.OpportunisticStartTls, + requireStartTls: config.DirectDelivery.RequireStartTls, + user: "", password: "", helo: helo, token: token); + return; + } + catch (StartTlsFailedException ex) when (!config.DirectDelivery.RequireStartTls) + { + // 机会式 TLS:握手失败(例如对方证书不受信)时必须真正回退明文重连, + // 否则一次证书问题就会导致永久投递失败。 + AppLog.Warn($"[发送] {mxHost} 的 STARTTLS 失败({ex.Message}),改用明文重试同一 MX。"); + try + { + await SendAsync(mxHost, 25, sender, recipients, raw, + startTls: false, requireStartTls: false, + user: "", password: "", helo: helo, token: token); + return; + } + catch (Exception inner) when (inner is IOException or SocketException or TimeoutException + or InvalidOperationException or AuthenticationException or SmtpDeliveryException) + { + last = inner; + AppLog.Warn($"[发送] MX {mxHost} 明文重试失败:{inner.Message}"); + } + } + catch (SmtpDeliveryException ex) when (ex.Permanent) + { + // 永久性拒绝:换下一个 MX 没有意义,直接上报 + throw; + } + catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException + or AuthenticationException or StartTlsFailedException) + { + last = ex; + AppLog.Warn($"[发送] MX {mxHost} 失败:{ex.Message}"); + } + } + + if (last is SmtpDeliveryException smtp) throw smtp; + throw new SmtpDeliveryException("投递", 451, $"无法投递到 {domain}:{last?.Message ?? "所有 MX 服务器均失败"}", permanent: false); + } + + /// 与某个 SMTP 服务器完成一次投递事务。 + private async Task SendAsync(string host, int port, string sender, string[] recipients, byte[] raw, + bool startTls, bool requireStartTls, string user, string password, string helo, CancellationToken token) + { + var connectionTimeout = TimeSpan.FromSeconds(Math.Max(5, config.DirectDelivery.ConnectionTimeoutSeconds)); + var commandTimeout = TimeSpan.FromSeconds(Math.Max(5, config.DirectDelivery.CommandTimeoutSeconds)); + + using var client = new TcpClient { NoDelay = true }; + await client.ConnectAsync(host, port, token).AsTask().WaitAsync(connectionTimeout, token); + + Stream stream = client.GetStream(); + var reader = NewReader(stream); + var writer = NewWriter(stream); + + try + { + Expect(await ReadReplyAsync(reader, commandTimeout, token), "连接欢迎语", 220); + + var hello = await CommandAsync(reader, writer, $"EHLO {helo}", commandTimeout, token); + if (hello.Code is < 200 or >= 300) + Expect(await CommandAsync(reader, writer, $"HELO {helo}", commandTimeout, token), "HELO", 250); + else if (startTls && HasCapability(hello, "STARTTLS")) + { + var startTlsReply = await CommandAsync(reader, writer, "STARTTLS", commandTimeout, token); + if (startTlsReply.Code == 220) + { + try + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, _, _, errors) => errors == SslPolicyErrors.None); + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = host, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + }, token).WaitAsync(commandTimeout, token); + stream = ssl; + reader = NewReader(stream); + writer = NewWriter(stream); + hello = await CommandAsync(reader, writer, $"EHLO {helo}", commandTimeout, token); + } + catch (Exception ex) when (ex is AuthenticationException or IOException) + { + if (requireStartTls) + throw new SmtpDeliveryException("STARTTLS", 451, $"{host} 的 TLS 握手失败(要求加密):{ex.Message}", permanent: false); + throw new StartTlsFailedException(ex.Message); + } + } + else if (requireStartTls) + { + throw new SmtpDeliveryException("STARTTLS", startTlsReply.Code, "对方拒绝 STARTTLS。", permanent: false); + } + } + else if (requireStartTls) + { + throw new SmtpDeliveryException("STARTTLS", 451, "对方未广告 STARTTLS。", permanent: false); + } + + // 中继模式需要认证 + if (!string.IsNullOrWhiteSpace(user)) + { + var authPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0" + user + "\0" + password)); + var auth = await CommandAsync(reader, writer, "AUTH PLAIN " + authPayload, commandTimeout, token); + if (auth.Code != 235) throw new SmtpDeliveryException("AUTH", auth.Code, auth.Detail, auth.Code is >= 500 and < 600); + } + + Expect(await CommandAsync(reader, writer, $"MAIL FROM:<{NormalizeAddress(sender)}>", commandTimeout, token), "MAIL FROM", 250, 251); + + var accepted = new List(); + foreach (var recipient in recipients) + { + var reply = await CommandAsync(reader, writer, $"RCPT TO:<{NormalizeAddress(recipient)}>", commandTimeout, token); + if (reply.Code is 250 or 251) { accepted.Add(recipient); continue; } + // 收件人被拒:5xx 视为这封邮件对该收件人永久失败 + throw new SmtpDeliveryException($"RCPT TO {recipient}", reply.Code, reply.Detail, reply.Code is >= 500 and < 600); + } + if (accepted.Count == 0) throw new SmtpDeliveryException("RCPT TO", 550, "所有收件人都被拒绝。", permanent: true); + + Expect(await CommandAsync(reader, writer, "DATA", commandTimeout, token), "DATA", 354); + await WriteDataAsync(stream, raw, token); + var result = await ReadReplyAsync(reader, commandTimeout, token); + if (result.Code != 250) + throw new SmtpDeliveryException("邮件正文", result.Code, result.Detail, result.Code is >= 500 and < 600); + + await TryQuitAsync(reader, writer, token); + } + finally + { + await stream.DisposeAsync(); + } + } + + /// + /// 写出 DATA 段。 + /// + /// 这里是 v1 中文变 '?' 的根因所在(v1 用 Encoding.ASCII 的 StreamWriter 写), + /// 现在改为按字节写出,且除 dot-stuffing 外不改动任何字节—— + /// 行尾规范化已在签名之前完成(见 SmtpDataEncoder.Normalize), + /// 若在此处再改行尾会让 DKIM 正文哈希对不上。 + /// + private static async Task WriteDataAsync(Stream stream, byte[] raw, CancellationToken token) + { + var payload = SmtpDataEncoder.Encode(SmtpDataEncoder.Normalize(raw)); + await stream.WriteAsync(payload, token); + await stream.FlushAsync(token); + } + + // ---------------------------------------------------------------- SMTP 会话 + + private async Task CommandAsync(StreamReader reader, StreamWriter writer, string command, TimeSpan timeout, CancellationToken token) + { + await writer.WriteLineAsync(command).WaitAsync(timeout, token); + return await ReadReplyAsync(reader, timeout, token); + } + + private static async Task ReadReplyAsync(StreamReader reader, TimeSpan timeout, CancellationToken token) + { + var lines = new List(); + var first = await reader.ReadLineAsync(token).AsTask().WaitAsync(timeout, token) + ?? throw new IOException("SMTP 连接提前关闭。"); + lines.Add(first); + if (first.Length < 3 || !int.TryParse(first[..3], out var code)) + throw new InvalidOperationException($"SMTP 返回无效响应:{first}"); + if (first.Length > 3 && first[3] == '-') + { + while (true) + { + var line = await reader.ReadLineAsync(token).AsTask().WaitAsync(timeout, token) + ?? throw new IOException("SMTP 多行响应提前结束。"); + lines.Add(line); + if (line.StartsWith($"{code:D3} ", StringComparison.Ordinal)) break; + } + } + return new SmtpReply(code, lines); + } + + private static bool HasCapability(SmtpReply reply, string capability) => + reply.Lines.Any(x => x.Length > 4 && x[4..].StartsWith(capability, StringComparison.OrdinalIgnoreCase)); + + private static void Expect(SmtpReply reply, string step, params int[] expected) + { + if (!expected.Contains(reply.Code)) + throw new SmtpDeliveryException(step, reply.Code, reply.Detail, reply.Code is >= 500 and < 600); + } + + private static async Task TryQuitAsync(StreamReader reader, StreamWriter writer, CancellationToken token) + { + try + { + await writer.WriteLineAsync("QUIT").WaitAsync(TimeSpan.FromSeconds(5), token); + await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(5), token); + } + catch { } + } + + // SMTP 控制通道只传 ASCII 命令,读响应也只需 ASCII;DATA 走字节通道,与此无关 + private static StreamReader NewReader(Stream stream) => new(stream, Encoding.ASCII, false, 8192, true); + private static StreamWriter NewWriter(Stream stream) => new(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = "\r\n" }; + + private static bool IsValidAddress(string value) => + value.Contains('@') && value.IndexOf('@') > 0 && value.IndexOf('@') < value.Length - 1; + + private static string GetDomain(string value) => value[(value.LastIndexOf('@') + 1)..].Trim().TrimEnd('.').ToLowerInvariant(); + private static string NormalizeAddress(string value) => value.Trim().Trim('<', '>'); + + private sealed record SmtpReply(int Code, IReadOnlyList Lines) + { + /// 取最后一行去掉状态码后的文本,便于写入日志。 + public string Detail => Lines.Count == 0 ? "" : (Lines[^1].Length > 4 ? Lines[^1][4..] : Lines[^1]); + } + + /// STARTTLS 握手失败(机会式 TLS 场景下应回退明文重连)。 + private sealed class StartTlsFailedException(string message) : Exception(message); +} + +/// SMTP 阶段异常,带状态码与「是否永久失败」判定。 +public sealed class SmtpDeliveryException : Exception +{ + public SmtpDeliveryException(string step, int code, string detail, bool permanent) + : base($"{step} 失败:{code} {detail}") + { + Step = step; + Code = code; + Permanent = permanent; + } + + public string Step { get; } + public int Code { get; } + public bool Permanent { get; } +} + +/// 极简 DNS MX 查询(不依赖第三方库,直接走 UDP 53)。 +internal static class MxResolver +{ + public static async Task> ResolveAsync(string domain, DirectDeliveryConfig config, CancellationToken token) + { + foreach (var server in GetDnsServers(config.DnsServer)) + { + try + { + var records = await QueryAsync(domain, server, config.DnsTimeoutSeconds, token); + if (records.Count > 0) return records; + } + catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException) + { + AppLog.Warn($"[DNS] 查询 {domain} 的 MX 失败({server}):{ex.Message}"); + } + } + + // 没有 MX 记录时按 RFC 5321 回退到 A 记录 + try + { + var addresses = await Dns.GetHostAddressesAsync(domain, token); + return addresses.Where(a => a.AddressFamily == AddressFamily.InterNetwork) + .Select(x => x.ToString()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + catch { return []; } + } + + private static async Task> QueryAsync(string domain, IPAddress server, int timeoutSeconds, CancellationToken token) + { + using var udp = new UdpClient(server.AddressFamily); + var query = BuildQuery(domain, out var id); + await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53)); + var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)), token); + return ParseResponse(result.Buffer, id); + } + + private static byte[] BuildQuery(string domain, out ushort id) + { + id = (ushort)Random.Shared.Next(1, ushort.MaxValue); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + writer.Write(ToNetwork(id)); + writer.Write(ToNetwork((ushort)0x0100)); // 标准查询,期望递归 + writer.Write(ToNetwork((ushort)1)); + writer.Write(ToNetwork((ushort)0)); + writer.Write(ToNetwork((ushort)0)); + writer.Write(ToNetwork((ushort)0)); + foreach (var label in domain.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + var bytes = Encoding.ASCII.GetBytes(label); + writer.Write((byte)bytes.Length); + writer.Write(bytes); + } + writer.Write((byte)0); + writer.Write(ToNetwork((ushort)15)); // MX + writer.Write(ToNetwork((ushort)1)); // IN + return stream.ToArray(); + } + + private static IReadOnlyList ParseResponse(byte[] data, ushort expectedId) + { + if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return []; + var flags = ReadUInt16(data, 2); + if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return []; + var questions = ReadUInt16(data, 4); + var answers = ReadUInt16(data, 6); + var authority = ReadUInt16(data, 8); + var additional = ReadUInt16(data, 10); + var offset = 12; + for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; } + + var records = new List<(ushort Preference, string Host)>(); + for (var i = 0; i < answers + authority + additional && offset < data.Length; i++) + { + ReadName(data, ref offset); + if (offset + 10 > data.Length) break; + var type = ReadUInt16(data, offset); + var cls = ReadUInt16(data, offset + 2); + var length = ReadUInt16(data, offset + 8); + offset += 10; + if (offset + length > data.Length) break; + if (type == 15 && cls == 1 && length >= 3) + { + var preference = ReadUInt16(data, offset); + var nameOffset = offset + 2; + var host = ReadName(data, ref nameOffset); + if (!string.IsNullOrWhiteSpace(host)) records.Add((preference, host.TrimEnd('.'))); + } + offset += length; + } + return records.OrderBy(x => x.Preference).Select(x => x.Host).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private static string ReadName(byte[] data, ref int offset) + { + var labels = new List(); + var cursor = offset; + var jumped = false; + var next = offset; + while (cursor < data.Length) + { + var length = data[cursor++]; + if (length == 0) { if (!jumped) next = cursor; break; } + if ((length & 0xC0) == 0xC0) + { + if (cursor >= data.Length) throw new InvalidOperationException("DNS 名称指针无效。"); + var pointer = ((length & 0x3F) << 8) | data[cursor++]; + if (!jumped) next = cursor; + cursor = pointer; + jumped = true; + continue; + } + if (length > 63 || cursor + length > data.Length) throw new InvalidOperationException("DNS 名称长度无效。"); + labels.Add(Encoding.ASCII.GetString(data, cursor, length)); + cursor += length; + } + offset = next; + return string.Join('.', labels); + } + + private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]); + private static ushort ToNetwork(ushort value) => (ushort)((value << 8) | (value >> 8)); + + private static IReadOnlyList GetDnsServers(string configured) + { + if (IPAddress.TryParse(configured, out var parsed)) return [parsed]; + var system = NetworkInterface.GetAllNetworkInterfaces() + .Where(x => x.OperationalStatus == OperationalStatus.Up) + .SelectMany(x => x.GetIPProperties().DnsAddresses) + .Where(x => x.AddressFamily == AddressFamily.InterNetwork) + .Distinct() + .ToArray(); + return system.Length > 0 ? system : [IPAddress.Parse("223.5.5.5"), IPAddress.Parse("1.1.1.1")]; + } +} diff --git a/server-native-v2/Dkim.cs b/server-native-v2/Dkim.cs new file mode 100644 index 0000000..b175c32 --- /dev/null +++ b/server-native-v2/Dkim.cs @@ -0,0 +1,242 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WpywMail.Native; + +/// +/// DKIM 签名(RFC 6376),算法 rsa-sha256,规范化 relaxed/relaxed。 +/// +/// 私钥不存在时会自动生成 2048 位 RSA 并保存为 PEM,同时把需要配置到 DNS 的 +/// TXT 记录打印到日志,方便直接复制到 Cloudflare。 +/// +public sealed class DkimSigner +{ + private const string Crlf = "\r\n"; + + private readonly RSA key; + private readonly string domain; + private readonly string selector; + private readonly string[] signHeaders; + + public string PrivateKeyPath { get; } + + /// SubjectPublicKeyInfo 形式的公钥(自检与 DNS 记录生成使用)。 + public byte[] PublicKeyBytes => key.ExportSubjectPublicKeyInfo(); + + /// DKIM 公钥所在的 DNS 记录名(不含域后缀)。 + public string RecordName => $"{selector}._domainkey"; + + /// DKIM 公钥记录值(TXT)。 + public string RecordValue => "v=DKIM1; k=rsa; p=" + Convert.ToBase64String(PublicKeyBytes); + + private DkimSigner(RSA key, string domain, string selector, string privateKeyPath, string[] headers) + { + this.key = key; + this.domain = domain; + this.selector = selector; + PrivateKeyPath = privateKeyPath; + signHeaders = headers; + } + + /// 按配置创建签名器;未启用或初始化失败时返回 null(调用方继续正常发信)。 + public static DkimSigner? Create(AppConfig config) + { + if (!config.Dkim.Enabled) return null; + try + { + var domain = string.IsNullOrWhiteSpace(config.Dkim.SigningDomain) ? config.Domain : config.Dkim.SigningDomain; + var selector = string.IsNullOrWhiteSpace(config.Dkim.Selector) ? "mail" : config.Dkim.Selector.Trim(); + var path = config.Dkim.PrivateKeyPath; + if (string.IsNullOrWhiteSpace(path)) path = Path.Combine(config.DataDirectory, "dkim", selector + ".private.pem"); + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + RSA rsa; + if (File.Exists(path)) + { + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(path)); + AppLog.Info($"[DKIM] 已加载私钥:{path}({rsa.KeySize} 位,选择器 {selector},域 {domain})"); + } + else + { + rsa = RSA.Create(2048); + File.WriteAllText(path, rsa.ExportPkcs8PrivateKeyPem(), new UTF8Encoding(false)); + AppLog.Info($"[DKIM] 已生成新的 2048 位私钥:{path}"); + } + + var signer = new DkimSigner(rsa, domain, selector, path, config.Dkim.Headers); + signer.PrintDnsRecord(); + return signer; + } + catch (Exception ex) + { + AppLog.Error($"[DKIM] 初始化失败,将不签名继续发信:{ex.Message}"); + return null; + } + } + + /// 把需要配置到 DNS 的公钥记录打印出来(分段给 TXT 用)。 + public void PrintDnsRecord() + { + var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()); + var name = $"{selector}._domainkey.{domain}"; + var value = "v=DKIM1; k=rsa; p=" + publicKey; + AppLog.Info($"[DKIM] 请在 DNS 添加 TXT 记录 —— 名称:{name}"); + AppLog.Info($"[DKIM] 值(TXT,可直接整条粘贴):{value}"); + foreach (var chunk in Chunk(value, 255)) + { + AppLog.Info($"[DKIM] (分段)\"{chunk}\""); + } + } + + public static IEnumerable Chunk(string value, int size) + { + for (var index = 0; index < value.Length; index += size) + { + yield return value.Substring(index, Math.Min(size, value.Length - index)); + } + } + + /// 对整封邮件签名,返回带 DKIM-Signature 头的新报文。失败时原样返回。 + public byte[] Sign(byte[] message) + { + try + { + var (rawHeaders, body) = SplitRaw(message); + if (rawHeaders.Count == 0) return message; + if (!rawHeaders.Any(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))) + { + AppLog.Warn("[DKIM] 报文缺少 From 头,跳过签名。"); + return message; + } + + var bodyHash = Convert.ToBase64String(SHA256.HashData(CanonicalizeBody(body))); + + // 按配置顺序挑出实际存在的头(每个名字只签第一次出现) + var chosen = new List<(string Name, string Value)>(); + foreach (var name in signHeaders) + { + var hit = rawHeaders.FirstOrDefault(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (hit.Name is not null && !chosen.Any(c => c.Name.Equals(hit.Name, StringComparison.OrdinalIgnoreCase))) + chosen.Add(hit); + } + if (!chosen.Any(c => c.Name.Equals("From", StringComparison.OrdinalIgnoreCase))) + chosen.Insert(0, rawHeaders.First(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))); + + var headerList = string.Join(":", chosen.Select(c => c.Name.ToLowerInvariant())); + var baseValue = $"v=1; a=rsa-sha256; c=relaxed/relaxed; d={domain}; s={selector}; " + + $"t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}; h={headerList}; bh={bodyHash}; b="; + + // RFC 6376 §3.7「hash step 2」规定的顺序,必须严格照做: + // 1. 先按 h= 标签里的顺序哈希各被签名头,**每个头后面跟一个 CRLF**; + // 2. **最后**才哈希 DKIM-Signature 头本身(b= 视为空串),且它**结尾不带 CRLF**。 + // 把 DKIM-Signature 放在最前面、或给它补一个结尾 CRLF,都会让合规的验证器 + // (Gmail / Outlook / OpenDKIM / Mail::DKIM / dkimpy)算出不同的摘要 —— + // 表现为对方直接判 dkim=fail,而自家自测却可能因为「签名端与验签端犯同一个错」 + // 而假通过。2026-09-13 的真实教训:port25 的验证器就是这样把这个 bug 揪出来的。 + var signingInput = new StringBuilder(); + foreach (var (name, value) in chosen) + { + signingInput.Append(CanonicalizeHeader(name, value)).Append(Crlf); + } + signingInput.Append(CanonicalizeHeader("DKIM-Signature", baseValue)); + + // ⚠ 必须用 Latin1(字节保真)取输入字节,**不能用 ASCII**: + // ASCII 编码会把非 ASCII 字符替换成 '?',如果报文头是 8bit 裸 UTF-8(不是 RFC2047 + // 编码字),签名输入就与实际字节不一致 → 对方验签失败。 + // 我们自己发出的信因为头都经过 RFC2047 编码,所以一直是 ASCII(这条缺陷因此潜伏着), + // 但验签端按真实字节算,一旦遇到 8bit 头就会对不上。自检的 DKIM 用例把它逼了出来。 + var signature = key.SignData(Encoding.Latin1.GetBytes(signingInput.ToString()), + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var signatureHeader = $"DKIM-Signature: {baseValue}{Convert.ToBase64String(signature)}{Crlf}"; + var output = new MemoryStream(message.Length + signatureHeader.Length + 64); + output.Write(Encoding.ASCII.GetBytes(signatureHeader)); + output.Write(message); + return output.ToArray(); + } + catch (Exception ex) + { + AppLog.Error($"[DKIM] 签名失败,将发送未签名报文:{ex.Message}"); + return message; + } + } + + // ---------------------------------------------------------------- 规范化 + + /// relaxed 头规范化:小写名、展开折行、多空白折成一个空格、去首尾空白。 + private static string CanonicalizeHeader(string name, string value) + { + var unfolded = value.Replace("\r\n", "").Replace("\n", ""); + var collapsed = CollapseWhitespace(unfolded).Trim(); + return name.Trim().ToLowerInvariant() + ":" + collapsed; + } + + /// relaxed 正文规范化:多空白折成一个空格、去行尾空白、去掉末尾空行。 + private static byte[] CanonicalizeBody(byte[] body) + { + var text = Encoding.Latin1.GetString(body).Replace("\r\n", "\n").Replace('\r', '\n'); + var lines = text.Split('\n'); + var normalized = lines.Select(line => CollapseWhitespace(line).TrimEnd(' ', '\t')); + var joined = string.Join(Crlf, normalized); + joined = joined.TrimEnd('\r', '\n'); + if (joined.Length > 0) joined += Crlf; + return Encoding.Latin1.GetBytes(joined); + } + + private static string CollapseWhitespace(string value) + { + var builder = new StringBuilder(value.Length); + var inWhitespace = false; + foreach (var c in value) + { + if (c is ' ' or '\t') + { + if (!inWhitespace) builder.Append(' '); + inWhitespace = true; + } + else + { + builder.Append(c); + inWhitespace = false; + } + } + return builder.ToString(); + } + + /// 把报文拆成「原始头行(未展开)」与「正文字节」,保持字节忠实。 + private static (List<(string Name, string Value)> Headers, byte[] Body) SplitRaw(byte[] message) + { + var headers = new List<(string, string)>(); + var index = 0; + string? currentName = null; + var currentValue = new StringBuilder(); + + while (index < message.Length) + { + var lineEnd = Array.IndexOf(message, (byte)'\n', index); + if (lineEnd < 0) lineEnd = message.Length; + var line = Encoding.Latin1.GetString(message, index, lineEnd - index).TrimEnd('\r'); + index = lineEnd + 1; + + if (line.Length == 0) break; // 头结束 + if ((line[0] == ' ' || line[0] == '\t') && currentName is not null) + { + currentValue.Append(Crlf).Append(line); + continue; + } + + if (currentName is not null) headers.Add((currentName, currentValue.ToString())); + var colon = line.IndexOf(':'); + if (colon <= 0) { currentName = null; currentValue.Clear(); continue; } + currentName = line[..colon].Trim(); + currentValue.Clear(); + currentValue.Append(line[(colon + 1)..]); + } + if (currentName is not null) headers.Add((currentName, currentValue.ToString())); + + var bodyStart = Math.Min(index, message.Length); + return (headers, message[bodyStart..]); + } +} diff --git a/server-native-v2/FileStore.cs b/server-native-v2/FileStore.cs new file mode 100644 index 0000000..b4e57a9 --- /dev/null +++ b/server-native-v2/FileStore.cs @@ -0,0 +1,975 @@ +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() { } +} diff --git a/server-native-v2/IMailStore.cs b/server-native-v2/IMailStore.cs new file mode 100644 index 0000000..98a71f1 --- /dev/null +++ b/server-native-v2/IMailStore.cs @@ -0,0 +1,160 @@ +namespace WpywMail.Native; + +/// +/// 邮件存储的统一接口。 +/// +/// 之所以要抽出接口:v2.0.x 只有 一种实现,任何一次改动 +/// (哪怕只是把一封邮件标记为已读)都要把**全部邮件**重新序列化并整文件重写, +/// 且 UID 分配、搜索、统计全是 O(N) 全表扫描。引入 后 +/// 上层(API / IMAP / SMTP / 投递队列)不需要知道底下是 JSON 还是 SQLite。 +/// +public interface IMailStore : IDisposable +{ + /// 每次写入自增,供长轮询判断「有没有新变化」。 + long Version { get; } + + // ---------------------------------------------------------------- 用户 + MailUser? FindUser(string email); + /// + /// 不看过滤 active 的查号。用途:① 投递(信箱先存在、访问才受控); + /// ② 登录时区分「密码错」与「账号未激活/已停用」(真实原因只进审计)。 + /// + MailUser? FindUserAnyState(string email); + MailUser? Authenticate(string email, string password); + bool IsLocalAddress(string email); + IReadOnlyList ListUsers(); + MailUser CreateUser(string email, string password, string displayName); + /// 用已经算好的哈希建号(注册验证通过时用,避免明文密码再走一遍内存)。 + MailUser CreateUserWithHash(string email, string passwordHash, string passwordSalt, string displayName, bool active = true); + void ChangePassword(string email, string password); + void SetUserActive(string email, bool active); + /// + /// 彻底删除一个账号:用户行 + 它的会话 + 验证码 + 它名下的出站队列。 + /// **邮件不在这里删** —— 调用方要先 ListMessages + DeleteMessage(permanent:true) 逐封删, + /// 那样才会顺带回收无引用的大对象。审计保留(删除动作本身也会写一条审计)。 + /// + bool DeleteUser(string email); + + // ---------------------------------------------------------------- 会话 + SessionRecord CreateSession(string email, int days); + SessionRecord? GetSession(string? token); + void RemoveSession(string token); + /// 列出某个账号的全部活跃会话(用于「在哪登录了 / 退出其他设备」)。 + IReadOnlyList ListSessions(string email); + /// 吊销该账号的会话;keepToken 非空时保留它(即「退出其他设备」)。返回吊销数量。 + int RemoveSessions(string email, string? keepToken); + + // ---------------------------------------------------------------- 账号体系(注册 / 验证码 / 审计) + /// 把用户资料写回存储(目前只有显示名)。 + void UpdateProfile(string email, string displayName); + /// 记录一次成功登录时间。 + void SetLastLogin(string email); + + /// 保存验证码(同一 email+purpose 覆盖旧的)。只存哈希。 + void SaveVerificationCode(VerificationCode code); + VerificationCode? FindVerificationCode(string email, string purpose); + /// 验证码试错次数 +1,返回自增后的次数。 + int IncrementVerificationAttempts(string email, string purpose); + void RemoveVerificationCode(string email, string purpose); + + /// 写一条认证审计。 + void RecordAuthEvent(AuthEvent entry); + /// 按条件查审计(都为 null 表示不限制);limit 为 0 时用实现自己的默认上限。 + IReadOnlyList ListAuthEvents(string? email, string? ip, string? reason, int limit); + /// 统计窗口期内的认证事件数量(登录锁定、注册与重发限流都用它)。 + int CountAuthEvents(string? email, string? ip, string? reason, bool? success, int minutes); + + // ---------------------------------------------------------------- 原始报文与附件 + string SaveRaw(byte[] raw); + byte[] ReadRaw(string relativePath); + string SaveAttachment(byte[] data, string suggestedName); + byte[] ReadAttachment(string relativePath); + + // ---------------------------------------------------------------- 邮件 + MailMessage SaveMessage(MailMessage message, byte[]? raw = null); + MailMessage? DeliverLocal(string recipient, byte[] raw, string sender); + MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text, + byte[] raw, string cc = "", string html = "", string inReplyTo = "", + IReadOnlyList? attachments = null); + MailMessage CreateBounce(string owner, string originalSubject, string[] recipients, string error, string originalRawPath); + IReadOnlyList ListMessages(string owner, string folder, string query); + /// 分页查询:只取需要的一页(SQLite 直接下推到 SQL,不再把整个邮箱读进内存)。 + (int Total, IReadOnlyList Messages) ListMessagesPage( + string owner, string folder, string query, bool unreadOnly, bool starredOnly, int limit, int offset); + MailMessage? GetMessage(string owner, string id); + MailMessage? GetById(string id); + bool MarkRead(string owner, string id, bool read); + bool SetStar(string owner, string id, bool starred); + bool MoveMessage(string owner, string id, string folder); + bool DeleteMessage(string owner, string id, bool permanent); + object Stats(string owner); + + // ---------------------------------------------------------------- IMAP + IReadOnlyList ListForImap(string owner, string folder); + MailMessage? GetByUid(string owner, string folder, int uid); + bool StoreFlags(string owner, string id, bool? seen, bool? flagged); + bool Expunge(string owner, string id); + MailMessage? Append(string owner, string folder, byte[] raw, bool seen); + int CountUnseen(string owner, string folder); + int NextUidFor(string owner, string folder); + + // ---------------------------------------------------------------- 出站队列 + IReadOnlyList TakeDueQueue(int limit); + void CompleteQueue(QueueItem item); + void FailQueue(QueueItem item, Exception error, RetryConfig retry, out bool gaveUp); + void RetryQueueItem(string owner, string queueId); + IReadOnlyList ListQueue(string owner); + + // ---------------------------------------------------------------- 维护 + IReadOnlyList AllMessages(); + IReadOnlyList AllUsers(); + /// 把内存中的改动落盘。JSON 实现会整文件重写;SQLite 实现是空操作(写入即提交)。 + void Persist(); +} + +/// 存储占用统计。 +public sealed class StoreUsage +{ + public string Provider { get; set; } = ""; + public string Database { get; set; } = ""; + public long DbBytes { get; set; } + public long WalBytes { get; set; } + public long Blobs { get; set; } + public long BlobRawBytes { get; set; } + public long BlobStoredBytes { get; set; } + public long BlobGzipped { get; set; } + public long Messages { get; set; } + public long Orphans { get; set; } + /// 正文文本占用的字节数(text_body + html_body),用于判断「重复存储」的成本。 + public long TextBytes { get; set; } + /// 数据库空闲页字节数(未 VACUUM 时会被计入文件大小)。 + public long FreeBytes { get; set; } + /// 数据库实际使用的页字节数(page_count × page_size)。 + public long TotalBytes { get; set; } + /// 文件高水位(含已回收但未归还操作系统的空间)。 + public long PageCount { get; set; } + public long PageSize { get; set; } + /// 最大一封邮件的正文长度与其主题(排查「空间被谁吃了」)。 + public long LargestTextBytes { get; set; } + public string LargestTextSubject { get; set; } = ""; +} + +/// 文件夹常量与名称归一化(IMAP 与 API 共用)。 +public static class MailFolders +{ + /// IMAP 用的文件夹列表(固定集合,未使用也返回,便于客户端订阅)。 + public static readonly string[] ImapFolders = ["inbox", "sent", "drafts", "archive", "trash", "spam"]; + + /// 把客户端给的各种写法(含中文别名)归一化成内部文件夹名;无法识别返回 null。 + public static string? Normalize(string name) + { + var key = (name ?? "").Trim().Trim('"').ToLowerInvariant(); + if (key is "inbox" or "收件箱") return "inbox"; + if (key is "sent" or "sent items" or "sent messages" or "已发送") return "sent"; + if (key is "drafts" or "草稿") return "drafts"; + if (key is "archive" or "archives" or "归档") return "archive"; + if (key is "trash" or "deleted" or "deleted items" or "已删除" or "垃圾箱") return "trash"; + if (key is "junk" or "spam" or "垃圾邮件") return "spam"; + return null; + } +} diff --git a/server-native-v2/ImapServer.cs b/server-native-v2/ImapServer.cs new file mode 100644 index 0000000..f5eaf78 --- /dev/null +++ b/server-native-v2/ImapServer.cs @@ -0,0 +1,1045 @@ +using System.Globalization; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WpywMail.Native; + +/// +/// IMAP4rev1 服务端(RFC 3501 子集)。 +/// +/// 目的:让标准邮件客户端(Outlook / Thunderbird / Foxmail / 手机邮件 App) +/// 直接接入,而不必依赖自研客户端;自研客户端仍可继续用 REST。 +/// +/// 支持:CAPABILITY、NOOP、LOGOUT、STARTTLS、LOGIN、AUTHENTICATE PLAIN、 +/// LIST/LSUB、SELECT/EXAMINE、STATUS、CREATE/DELETE/RENAME(受限)、CLOSE、 +/// EXPUNGE、SEARCH、FETCH/UID FETCH、STORE/UID STORE、COPY/UID COPY、APPEND、IDLE。 +/// 未实现(客户端可正常工作):CONDSTORE、QRESYNC、SORT、THREAD、UIDPLUS、ACL。 +/// +public sealed class ImapServer +{ + private const string Crlf = "\r\n"; + private readonly AppConfig config; + private readonly IMailStore store; + private readonly X509Certificate2? certificate; + + public ImapServer(AppConfig config, IMailStore store) + { + this.config = config; + this.store = store; + if (!string.IsNullOrWhiteSpace(config.TlsCertificatePath) && File.Exists(config.TlsCertificatePath)) + certificate = new X509Certificate2(config.TlsCertificatePath, config.TlsCertificatePassword); + } + + public async Task RunAsync(CancellationToken token) + { + if (!config.Imap.Enabled) + { + AppLog.Info("[IMAP] 已按配置禁用。"); + return; + } + + var tasks = new List(); + if (config.Imap.Port > 0) + { + var plain = new TcpListener(IPAddress.Any, config.Imap.Port); + plain.Start(); + AppLog.Info($"[IMAP] 已监听:{config.Imap.Port}(明文 + STARTTLS)"); + tasks.Add(AcceptLoopAsync(plain, implicitTls: false, token)); + } + if (config.Imap.TlsPort > 0 && certificate is not null) + { + var tls = new TcpListener(IPAddress.Any, config.Imap.TlsPort); + tls.Start(); + AppLog.Info($"[IMAP] 已监听:{config.Imap.TlsPort}(隐式 TLS)"); + tasks.Add(AcceptLoopAsync(tls, implicitTls: true, token)); + } + else if (config.Imap.TlsPort > 0) + { + AppLog.Warn($"[IMAP] {config.Imap.TlsPort} 端口未启动:没有可用证书。"); + } + + await Task.WhenAll(tasks); + } + + private async Task AcceptLoopAsync(TcpListener listener, bool implicitTls, CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(token); + _ = Task.Run(async () => + { + try { await new ImapSession(config, store, certificate, implicitTls, client).RunAsync(token); } + catch (Exception ex) { AppLog.Error($"[IMAP] 会话异常:{ex.Message}"); } + finally { client.Dispose(); } + }, token); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) { AppLog.Error($"[IMAP] 接收循环异常:{ex.Message}"); } + finally { listener.Stop(); } + } +} + +/// 单个 IMAP 连接的状态机。 +internal sealed class ImapSession +{ + private const string Crlf = "\r\n"; + private readonly AppConfig config; + private readonly IMailStore store; + private readonly X509Certificate2? certificate; + private readonly TcpClient client; + private SmtpReader reader = null!; + private Stream stream = null!; + + private string? user; + private string? selected; // 当前选中的文件夹(我们的内部名) + private bool readOnly; + private bool authenticated; + private bool tls; + private readonly HashSet deleted = []; // 会话内 \Deleted 标记(按 message id) + + public ImapSession(AppConfig config, IMailStore store, X509Certificate2? certificate, bool implicitTls, TcpClient client) + { + this.config = config; + this.store = store; + this.certificate = certificate; + this.client = client; + _ = implicitTls; + } + + public async Task RunAsync(CancellationToken token) + { + stream = client.GetStream(); + var implicitTls = client.Client.LocalEndPoint is IPEndPoint { Port: var port } && port == config.Imap.TlsPort && config.Imap.TlsPort > 0; + + if (implicitTls && certificate is not null) + { + var ssl = new SslStream(stream, false); + await ssl.AuthenticateAsServerAsync(BuildTlsOptions(), token); + stream = ssl; + tls = true; + } + + reader = new SmtpReader(stream); + var writer = new StreamWriter(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = Crlf }; + + var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? ""; + await WriteAsync(writer, $"* OK [CAPABILITY {Capabilities()}] WpywMail IMAP4rev1 ready"); + AppLog.Info($"[IMAP] 收到连接:{client.Client.RemoteEndPoint},TLS={(tls ? "是" : "否")}"); + + while (!token.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(token); + if (line is null) break; + if (line.Length == 0) continue; + + var space = line.IndexOf(' '); + var tag = space < 0 ? line : line[..space]; + var rest = space < 0 ? "" : line[(space + 1)..].Trim(); + var command = rest.Length == 0 ? "" : rest.Split(' ')[0].ToUpperInvariant(); + // 各处理器只接受「参数」,因此这里必须把命令名本身剥掉 + var args = rest.Length > command.Length ? rest[command.Length..].Trim() : ""; + + try + { + if (command == "LOGOUT") + { + await WriteAsync(writer, "* BYE WpywMail IMAP4rev1 signing off"); + await WriteAsync(writer, $"{tag} OK LOGOUT completed"); + break; + } + + if (command == "CAPABILITY") { await WriteAsync(writer, $"* CAPABILITY {Capabilities()}"); await OkAsync(writer, tag, "CAPABILITY completed"); continue; } + if (command == "NOOP") { await OkAsync(writer, tag, "NOOP completed"); continue; } + if (command == "STARTTLS") + { + if (tls || certificate is null) { await NoAsync(writer, tag, "STARTTLS not available"); continue; } + await OkAsync(writer, tag, "Begin TLS negotiation now"); // RFC 3501: STARTTLS 的响应是带 tag 的 OK + var ssl = new SslStream(stream, false); + await ssl.AuthenticateAsServerAsync(BuildTlsOptions(), token); + stream = ssl; + reader = new SmtpReader(stream); + try { writer.Dispose(); } catch { } + writer = new StreamWriter(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = Crlf }; + tls = true; + AppLog.Info($"[IMAP] {remoteIp} 已建立 TLS 会话。"); + continue; + } + + if (!authenticated) + { + if (command == "LOGIN") + { + var (u, p) = ParseLogin(rest); + if (!TlsSatisfied(remoteIp)) { await NoAsync(writer, tag, "LOGIN requires TLS (issue STARTTLS first)"); continue; } + var account = store.Authenticate(u, p); + if (account is null) { AppLog.Warn($"[IMAP] {remoteIp} 登录失败:{u}"); await NoAsync(writer, tag, "LOGIN failed"); continue; } + user = account.Email; + authenticated = true; + AppLog.Info($"[IMAP] {remoteIp} 登录成功:{user}"); + await OkAsync(writer, tag, "LOGIN completed"); + continue; + } + if (command == "AUTHENTICATE") + { + if (!TlsSatisfied(remoteIp)) { await NoAsync(writer, tag, "AUTHENTICATE requires TLS"); continue; } + var mechanism = rest.Split(' ', 2).ElementAtOrDefault(1)?.ToUpperInvariant() ?? ""; + if (mechanism != "PLAIN") { await NoAsync(writer, tag, "Unsupported authentication mechanism"); continue; } + var payload = rest.Split(' ', 3).ElementAtOrDefault(2); + if (string.IsNullOrEmpty(payload)) + { + await WriteAsync(writer, "+ "); + payload = (await reader.ReadLineAsync(token) ?? "").Trim(); + } + var ok = TryParsePlain(payload, out var u, out var p); + var account = ok ? store.Authenticate(u, p) : null; + if (account is null) { await NoAsync(writer, tag, "AUTHENTICATE failed"); continue; } + user = account.Email; + authenticated = true; + await OkAsync(writer, tag, "AUTHENTICATE completed"); + continue; + } + await NoAsync(writer, tag, "Please authenticate first"); + continue; + } + + switch (command) + { + case "LIST": + case "LSUB": + await WriteAsync(writer, $"* {(command == "LSUB" ? "LSUB" : "LIST")} (\\HasNoChildren) \"/\" \"INBOX\""); + foreach (var folder in MailFolders.ImapFolders.Where(f => f != "inbox")) + await WriteAsync(writer, $"* {command} (\\HasNoChildren) \"/\" \"{DisplayName(folder)}\""); + await OkAsync(writer, tag, $"{command} completed"); + break; + + case "SELECT": + case "EXAMINE": + await SelectAsync(writer, tag, args, readOnlyRequested: command == "EXAMINE"); + break; + + case "STATUS": + await StatusAsync(writer, tag, args); + break; + + case "CLOSE": + selected = null; + deleted.Clear(); + await OkAsync(writer, tag, "CLOSE completed"); + break; + + case "UNSELECT": + selected = null; + await OkAsync(writer, tag, "UNSELECT completed"); + break; + + case "CREATE": + case "DELETE": + case "RENAME": + case "SUBSCRIBE": + case "UNSUBSCRIBE": + // 文件夹集合固定,接受但不做实际变更 + await OkAsync(writer, tag, $"{command} completed"); + break; + + case "EXPUNGE": + await ExpungeAsync(writer, tag); + break; + + case "SEARCH": + await SearchAsync(writer, tag, args, useUid: false); + break; + + case "UID": + await SearchOrUidAsync(writer, tag, args, token); + break; + + case "FETCH": + await FetchAsync(writer, tag, args, useUid: false); + break; + + case "STORE": + await StoreAsync(writer, tag, args, useUid: false); + break; + + case "COPY": + await CopyAsync(writer, tag, args, useUid: false); + break; + + case "APPEND": + await AppendAsync(writer, tag, args, token); + break; + + case "CHECK": + await OkAsync(writer, tag, "CHECK completed"); + break; + + case "IDLE": + await IdleAsync(writer, tag, token); + break; + + default: + await NoAsync(writer, tag, $"Command not supported: {command}"); + break; + } + } + catch (Exception ex) + { + AppLog.Error($"[IMAP] 处理 {command} 出错:{ex.Message}"); + await NoAsync(writer, tag, "Internal error"); + } + } + + try { writer.Dispose(); } catch { } + AppLog.Info($"[IMAP] 连接结束:{remoteIp}"); + } + + private SslServerAuthenticationOptions BuildTlsOptions() => new() + { + ServerCertificate = certificate, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificateRequired = false, + }; + + private string Capabilities() => + "IMAP4rev1 " + (certificate is not null && !tls ? "STARTTLS " : "") + "AUTH=PLAIN IDLE UIDPLUS CHILDREN NAMESPACE"; + + private bool TlsSatisfied(string remoteIp) + { + if (tls || !config.Imap.RequireTlsForLogin) return true; + return config.Imap.PlaintextLoginAllowFrom.Contains(remoteIp); + } + + // ---------------------------------------------------------------- SELECT / STATUS + + private async Task SelectAsync(StreamWriter writer, string tag, string rest, bool readOnlyRequested) + { + var mailbox = ExtractMailbox(rest); + var folder = MailFolders.Normalize(mailbox); + if (folder is null) { await NoAsync(writer, tag, "Mailbox does not exist"); return; } + + selected = folder; + readOnly = readOnlyRequested; + deleted.Clear(); + + var items = store.ListForImap(user!, folder); + var unseen = items.Count(x => x.Unread); + var nextUid = store.NextUidFor(user!, folder); + + await WriteAsync(writer, $"* {items.Count} EXISTS"); + await WriteAsync(writer, $"* {unseen} RECENT"); + await WriteAsync(writer, "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)"); + await WriteAsync(writer, $"* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Limited"); + await WriteAsync(writer, "* OK [UIDVALIDITY 1] UIDs valid"); + await WriteAsync(writer, $"* OK [UIDNEXT {nextUid}] Predicted next UID"); + await OkAsync(writer, tag, $"[READ-{(readOnly ? "ONLY" : "WRITE")}] {(readOnlyRequested ? "EXAMINE" : "SELECT")} completed"); + } + + private async Task StatusAsync(StreamWriter writer, string tag, string rest) + { + var parts = SplitTokens(rest); + if (parts.Count < 2) { await NoAsync(writer, tag, "STATUS requires mailbox and items"); return; } + var folder = MailFolders.Normalize(parts[0]); + if (folder is null) { await NoAsync(writer, tag, "Mailbox does not exist"); return; } + + var items = store.ListForImap(user!, folder); + var requested = parts[1].Trim('(', ')').ToUpperInvariant(); + var values = new List(); + if (requested.Contains("MESSAGES")) values.Add($"MESSAGES {items.Count}"); + if (requested.Contains("RECENT")) values.Add($"RECENT {items.Count(x => x.Unread)}"); + if (requested.Contains("UNSEEN")) values.Add($"UNSEEN {items.Count(x => x.Unread)}"); + if (requested.Contains("UIDNEXT")) values.Add($"UIDNEXT {store.NextUidFor(user!, folder)}"); + if (requested.Contains("UIDVALIDITY")) values.Add("UIDVALIDITY 1"); + await WriteAsync(writer, $"* STATUS \"{DisplayName(folder)}\" ({string.Join(" ", values)})"); + await OkAsync(writer, tag, "STATUS completed"); + } + + // ---------------------------------------------------------------- SEARCH / FETCH + + private async Task SearchOrUidAsync(StreamWriter writer, string tag, string rest, CancellationToken token) + { + var space = rest.IndexOf(' '); + var sub = space < 0 ? rest.ToUpperInvariant() : rest[..space].ToUpperInvariant(); + var args = space < 0 ? "" : rest[(space + 1)..]; + + switch (sub) + { + case "SEARCH": await SearchAsync(writer, tag, args, useUid: false); break; + case "FETCH": await FetchAsync(writer, tag, args, useUid: true); break; + case "STORE": await StoreAsync(writer, tag, args, useUid: true); break; + case "COPY": await CopyAsync(writer, tag, args, useUid: true); break; + default: await NoAsync(writer, tag, $"UID {sub} not supported"); break; + } + await Task.CompletedTask; + _ = token; + } + + private async Task SearchAsync(StreamWriter writer, string tag, string args, bool useUid) + { + if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } + var items = store.ListForImap(user!, selected); + var criteria = args.ToUpperInvariant(); + if (criteria.StartsWith("CHARSET")) criteria = criteria[(criteria.IndexOf(' ') + 1)..]; + + IEnumerable result = items; + if (criteria.Contains("UNSEEN")) result = result.Where(x => x.Unread); + if (criteria.Contains("SEEN")) result = result.Where(x => !x.Unread); + if (criteria.Contains("FLAGGED")) result = result.Where(x => x.Starred); + if (criteria.Contains("UNFLAGGED")) result = result.Where(x => !x.Starred); + if (criteria.Contains("DELETED")) result = result.Where(x => deleted.Contains(x.Id)); + + var fromIndex = criteria.IndexOf("FROM ", StringComparison.Ordinal); + if (fromIndex >= 0) + { + var needle = Unquote(criteria[(fromIndex + 5)..].Split(' ')[0]); + result = result.Where(x => x.From.Contains(needle, StringComparison.OrdinalIgnoreCase)); + } + var subjectIndex = criteria.IndexOf("SUBJECT ", StringComparison.Ordinal); + if (subjectIndex >= 0) + { + var needle = Unquote(criteria[(subjectIndex + 8)..].Split(' ')[0]); + result = result.Where(x => x.Subject.Contains(needle, StringComparison.OrdinalIgnoreCase)); + } + var textIndex = criteria.IndexOf("TEXT ", StringComparison.Ordinal); + if (textIndex >= 0) + { + var needle = Unquote(criteria[(textIndex + 5)..].Split(' ')[0]); + result = result.Where(x => (x.Subject + " " + x.Text).Contains(needle, StringComparison.OrdinalIgnoreCase)); + } + var uidIndex = criteria.IndexOf("UID ", StringComparison.Ordinal); + if (uidIndex >= 0) + { + var set = criteria[(uidIndex + 4)..].Split(' ')[0]; + var uids = ParseUidSet(set, items); + result = result.Where(x => uids.Contains(x.Uid)); + } + + var ids = result.Select(x => useUid ? x.Uid : IndexOf(items, x) + 1); + await WriteAsync(writer, "* SEARCH " + string.Join(" ", ids)); + await OkAsync(writer, tag, "SEARCH completed"); + } + + private async Task FetchAsync(StreamWriter writer, string tag, string args, bool useUid) + { + if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } + var parts = SplitTokens(args); + if (parts.Count < 2) { await NoAsync(writer, tag, "FETCH requires set and items"); return; } + + var items = store.ListForImap(user!, selected); + var targets = ResolveSet(parts[0], items, useUid); + var spec = args[(args.IndexOf(' ') + 1)..]; // 保留原始大小写,便于解析 BODY[...] + var wantsUid = spec.Contains("UID", StringComparison.OrdinalIgnoreCase) || useUid; + + foreach (var message in targets) + { + var pieces = new List(); + if (wantsUid) pieces.Add($"UID {message.Uid}"); + if (HasToken(spec, "FLAGS")) pieces.Add("FLAGS " + FlagsOf(message)); + if (HasToken(spec, "INTERNALDATE")) pieces.Add("INTERNALDATE \"" + FormatInternalDate(message.Date) + "\""); + if (HasToken(spec, "ENVELOPE")) pieces.Add("ENVELOPE " + Envelope(message)); + + var wantsStructure = SpecNeedsBodyStructure(spec); + var bodyItem = ExtractBodyItem(spec); + + if (wantsStructure) pieces.Add("BODYSTRUCTURE " + BodyStructure(message)); + + // 取正文时顺带拿到原始字节,既能回退计算大小,也避免重复读盘 + byte[]? rawForSize = null; + long ActualSize() + { + if (message.Size > 0) return message.Size; + rawForSize ??= SafeReadRaw(message); + return rawForSize.Length; + } + + if (HasToken(spec, "RFC822.SIZE")) pieces.Add($"RFC822.SIZE {ActualSize()}"); + + // 精确匹配 RFC822 / RFC822.HEADER / RFC822.TEXT,避免 "RFC822.SIZE" 被误判成整封请求 + var wantsRfc822 = HasToken(spec, "RFC822"); + var wantsRfc822Header = HasToken(spec, "RFC822.HEADER"); + var wantsRfc822Text = HasToken(spec, "RFC822.TEXT"); + + if (bodyItem is not null || wantsRfc822 || wantsRfc822Header || wantsRfc822Text) + { + var (section, peek, label, fields) = bodyItem + ?? (wantsRfc822Header ? "HEADER" : wantsRfc822Text ? "TEXT" : "", false, + wantsRfc822Header ? "RFC822.HEADER" : wantsRfc822Text ? "RFC822.TEXT" : "RFC822", Array.Empty()); + var content = RenderSection(message, section, fields); + var responseLabel = label.StartsWith("RFC822") ? label : label; + + // 除了 PEEK 之外,取正文视为已读 + if (!peek && message.Unread) + { + store.MarkRead(user!, message.Id, true); + message.Unread = false; + } + + await WriteAsync(writer, $"* {(useUid ? message.Uid : IndexOf(items, message) + 1)} FETCH ({string.Join(" ", pieces)} {responseLabel} {{{content.Length}}}"); + await WriteBytesAsync(writer, content); + await WriteAsync(writer, ")"); + continue; + } + + await WriteAsync(writer, $"* {(useUid ? message.Uid : IndexOf(items, message) + 1)} FETCH ({string.Join(" ", pieces)})"); + } + + await OkAsync(writer, tag, "FETCH completed"); + } + + private async Task StoreAsync(StreamWriter writer, string tag, string args, bool useUid) + { + if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } + if (readOnly) { await NoAsync(writer, tag, "Mailbox is read-only"); return; } + + var parts = SplitTokens(args); + if (parts.Count < 3) { await NoAsync(writer, tag, "STORE requires set, item and flags"); return; } + + var items = store.ListForImap(user!, selected); + var targets = ResolveSet(parts[0], items, useUid); + var operation = parts[1].ToUpperInvariant(); + var silent = operation.EndsWith(".SILENT"); + var flags = args[(args.IndexOf(parts[2], StringComparison.Ordinal))..].ToUpperInvariant(); + var add = !operation.StartsWith("-FLAGS"); + var remove = operation.StartsWith("-FLAGS"); + + foreach (var message in targets) + { + if (flags.Contains("\\SEEN")) store.StoreFlags(user!, message.Id, seen: add && !remove, flagged: null); + if (flags.Contains("\\FLAGGED")) store.StoreFlags(user!, message.Id, seen: null, flagged: add && !remove); + if (flags.Contains("\\DELETED")) + { + if (add && !remove) deleted.Add(message.Id); + else deleted.Remove(message.Id); + } + + var updated = store.GetMessage(user!, message.Id)!; + if (!silent) + await WriteAsync(writer, $"* {(useUid ? updated.Uid : IndexOf(items, updated) + 1)} FETCH (FLAGS {FlagsOf(updated)})"); + } + + await OkAsync(writer, tag, "STORE completed"); + } + + private async Task CopyAsync(StreamWriter writer, string tag, string args, bool useUid) + { + if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } + var parts = SplitTokens(args); + if (parts.Count < 2) { await NoAsync(writer, tag, "COPY requires set and mailbox"); return; } + var folder = MailFolders.Normalize(parts[1]); + if (folder is null) { await NoAsync(writer, tag, "TRYCREATE Mailbox does not exist"); return; } + + var items = store.ListForImap(user!, selected); + foreach (var message in ResolveSet(parts[0], items, useUid)) + { + try + { + var raw = store.ReadRaw(message.RawPath); + store.Append(user!, folder, raw, seen: true); + } + catch (Exception ex) + { + AppLog.Warn($"[IMAP] 复制 {message.Id} 失败:{ex.Message}"); + } + } + await OkAsync(writer, tag, "COPY completed"); + } + + private async Task ExpungeAsync(StreamWriter writer, string tag) + { + if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } + + // EXPUNGE 的序号必须随删除动态变化,因此按序号从小到大处理 + var items = store.ListForImap(user!, selected).ToList(); + var index = 0; + while (index < items.Count) + { + var message = items[index]; + if (deleted.Contains(message.Id)) + { + store.Expunge(user!, message.Id); + deleted.Remove(message.Id); + items.RemoveAt(index); + await WriteAsync(writer, $"* {index + 1} EXPUNGE"); + continue; + } + index++; + } + await OkAsync(writer, tag, "EXPUNGE completed"); + } + + private async Task AppendAsync(StreamWriter writer, string tag, string rest, CancellationToken token) + { + var parts = SplitTokens(rest); + if (parts.Count == 0) { await NoAsync(writer, tag, "APPEND requires mailbox"); return; } + + var folder = MailFolders.Normalize(parts[0]); + var literalIndex = rest.LastIndexOf('{'); + if (folder is null || literalIndex < 0) { await NoAsync(writer, tag, "APPEND syntax error"); return; } + + var closing = rest.IndexOf('}', literalIndex); + if (closing < 0 || !int.TryParse(rest[(literalIndex + 1)..closing], out var size) || size < 0 || size > config.Smtp.MaxMessageBytes) + { + await NoAsync(writer, tag, "APPEND invalid literal size"); + return; + } + + await WriteAsync(writer, "+ Ready for literal data"); + var raw = await reader.ReadExactlyAsync(size, token); + var flagsArea = rest[..literalIndex].ToUpperInvariant(); + var seen = flagsArea.Contains("\\SEEN"); + var message = store.Append(user!, folder, raw, seen); + AppLog.Info($"[IMAP] {user} APPEND 到 {folder}:{message?.Subject}"); + await OkAsync(writer, tag, "APPEND completed"); + } + + private async Task IdleAsync(StreamWriter writer, string tag, CancellationToken token) + { + await WriteAsync(writer, "+ idling"); + var lastCounts = SnapshotCounts(); + var deadline = DateTimeOffset.UtcNow.AddMinutes(30); + + while (DateTimeOffset.UtcNow < deadline && !token.IsCancellationRequested) + { + // 等待客户端发送 DONE:用带超时的读取探测 + var readTask = reader.ReadLineAsync(token); + var completed = await Task.WhenAny(readTask, Task.Delay(2000, token)); + if (completed == readTask) + { + var line = await readTask; + if (line is null) return; + if (line.Trim().Equals("DONE", StringComparison.OrdinalIgnoreCase)) break; + continue; + } + + var current = SnapshotCounts(); + foreach (var (folder, count) in current) + { + if (lastCounts.TryGetValue(folder, out var previous) && previous != count) + { + if (selected is not null && folder == selected) + await WriteAsync(writer, $"* {count} EXISTS"); + else + await WriteAsync(writer, $"* OK [STATUS] {DisplayName(folder)} changed"); + } + } + lastCounts = current; + } + + await OkAsync(writer, tag, "IDLE terminated"); + } + + private Dictionary SnapshotCounts() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var folder in MailFolders.ImapFolders) + map[folder] = store.ListForImap(user!, folder).Count; + return map; + } + + // ---------------------------------------------------------------- 渲染辅助 + + private static string DisplayName(string folder) => folder switch + { + "inbox" => "INBOX", + "sent" => "Sent", + "drafts" => "Drafts", + "archive" => "Archive", + "trash" => "Trash", + "spam" => "Junk", + _ => folder, + }; + + private static string FlagsOf(MailMessage message) + { + var flags = new List(); + if (!message.Unread) flags.Add("\\Seen"); + if (message.Starred) flags.Add("\\Flagged"); + return "(" + string.Join(" ", flags) + ")"; + } + + private static string FormatInternalDate(DateTimeOffset value) => + value.ToString("dd-MMM-yyyy HH:mm:ss ", CultureInfo.InvariantCulture) + + (value.Offset < TimeSpan.Zero ? "-" : "+") + + value.Offset.Duration().ToString("hhmm", CultureInfo.InvariantCulture); + + private static int IndexOf(IReadOnlyList items, MailMessage message) + { + for (var i = 0; i < items.Count; i++) if (ReferenceEquals(items[i], message) || items[i].Id == message.Id) return i; + return 0; + } + + private static bool Matches(string spec, string token) => + spec.Contains(token, StringComparison.OrdinalIgnoreCase); + + /// 按「独立 token」匹配 FETCH 项,避免 "RFC822.SIZE" 被当成 "RFC822"。 + private static bool HasToken(string spec, string token) + { + var upper = spec.ToUpperInvariant(); + var needle = token.ToUpperInvariant(); + var index = 0; + while ((index = upper.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + var beforeOk = index == 0 || " ()".IndexOf(upper[index - 1]) >= 0; + var end = index + needle.Length; + var afterOk = end >= upper.Length || " ()".IndexOf(upper[end]) >= 0; + if (beforeOk && afterOk) return true; + index = end; + } + return false; + } + + private static bool SpecNeedsBodyStructure(string spec) + { + var upper = spec.ToUpperInvariant(); + return upper.Contains("BODYSTRUCTURE") || (upper.Contains("BODY") && !upper.Contains("BODY[") && !upper.Contains("BODY.PEEK")); + } + + /// 从 FETCH 项里取出 BODY[...] / BODY.PEEK[...] 的 section,并判断是否 PEEK。 + private static (string Section, bool Peek, string Label, string[] Fields)? ExtractBodyItem(string spec) + { + var upper = spec.ToUpperInvariant(); + var peekIndex = upper.IndexOf("BODY.PEEK[", StringComparison.Ordinal); + var plainIndex = peekIndex < 0 ? upper.IndexOf("BODY[", StringComparison.Ordinal) : -1; + var start = peekIndex >= 0 ? peekIndex : plainIndex; + if (start < 0) return null; + + var open = spec.IndexOf('[', start); + // HEADER.FIELDS 的括号里还有括号,必须找到与之配对的 ']' + var depth = 0; + var close = -1; + for (var i = open; i >= 0 && i < spec.Length; i++) + { + if (spec[i] == '[') depth++; + else if (spec[i] == ']') + { + depth--; + if (depth == 0) { close = i; break; } + } + } + if (open < 0 || close < 0) return null; + + var original = spec[(open + 1)..close].Trim(); + // 回显客户端请求的原始 section(客户端按标签匹配,不能改写) + var label = "BODY[" + original + "]"; + var normalized = NormalizeSection(original); + var fields = normalized == "HEADER.FIELDS" ? ExtractFieldNames(original) : Array.Empty(); + return (normalized, peekIndex >= 0, label, fields); + } + + /// 从 HEADER.FIELDS (A B C) 里取出字段名。 + private static string[] ExtractFieldNames(string section) + { + var open = section.IndexOf('('); + var close = section.LastIndexOf(')'); + if (open < 0 || close <= open) return []; + return section[(open + 1)..close].Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToArray(); + } + + /// 把客户端请求的 section 归一化;我们的存储是整封原始报文,多数情况返回整封或头部。 + private static string NormalizeSection(string section) + { + var value = section.Trim(); + if (value.Length == 0) return ""; + if (value.StartsWith("HEADER.FIELDS", StringComparison.OrdinalIgnoreCase)) return "HEADER.FIELDS"; + if (value.StartsWith("HEADER", StringComparison.OrdinalIgnoreCase)) return "HEADER"; + if (value.StartsWith("TEXT", StringComparison.OrdinalIgnoreCase)) return "TEXT"; + return "FULL"; + } + + /// 按 section 渲染字节内容。 + private byte[] SafeReadRaw(MailMessage message) + { + try { return store.ReadRaw(message.RawPath); } + catch { return []; } + } + + private byte[] RenderSection(MailMessage message, string section, string[] fields) + { + var raw = SafeReadRaw(message); + if (raw.Length == 0) + raw = Encoding.UTF8.GetBytes($"From: {message.From}{Crlf}To: {message.To}{Crlf}Subject: {message.Subject}{Crlf}{Crlf}{message.Text}"); + + var text = Encoding.Latin1.GetString(raw); + var separator = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + var header = separator >= 0 ? text[..(separator + 4)] : text; + var body = separator >= 0 ? text[(separator + 4)..] : ""; + + return section switch + { + "HEADER" => Encoding.Latin1.GetBytes(header), + "HEADER.FIELDS" => Encoding.Latin1.GetBytes(FilterHeaderFields(header, fields)), + "TEXT" => Encoding.Latin1.GetBytes(body), + _ => raw, + }; + } + + private static string FilterHeaderFields(string header, string[] requested) + { + var wanted = requested.Length > 0 + ? requested + : new[] { "From", "To", "Cc", "Subject", "Date", "Message-ID", "Content-Type", "Content-Transfer-Encoding", "MIME-Version" }; + var builder = new StringBuilder(); + foreach (var line in header.Split("\r\n")) + { + var colon = line.IndexOf(':'); + if (colon > 0 && wanted.Contains(line[..colon].Trim(), StringComparer.OrdinalIgnoreCase)) builder.Append(line).Append(Crlf); + else if (line.StartsWith(' ') || line.StartsWith('\t')) { /* 折行忽略 */ } + } + return builder.Append(Crlf).ToString(); + } + + private static string Envelope(MailMessage message) + { + var date = message.Date.ToString("ddd, dd MMM yyyy HH:mm:ss ", CultureInfo.InvariantCulture) + FormatZone(message.Date); + var from = AddressList(message.From); + return $"({Quote(date)} {Quote(Mime.EncodeHeaderValue(message.Subject))} {from} {from} NIL {AddressList(message.To)} {AddressList(message.Cc)} NIL NIL {Quote(message.InReplyTo)} {Quote(message.MessageId)})"; + } + + private static string FormatZone(DateTimeOffset value) => + (value.Offset < TimeSpan.Zero ? "-" : "+") + value.Offset.Duration().ToString("hhmm", CultureInfo.InvariantCulture); + + private static string AddressList(string value) + { + var addresses = Mime.Addresses(value); + if (addresses.Length == 0) return "NIL"; + var parts = addresses.Select(a => + { + var at = a.IndexOf('@'); + var mailbox = at > 0 ? a[..at] : a; + var host = at > 0 ? a[(at + 1)..] : ""; + return $"(NIL NIL {Quote(mailbox)} {Quote(host)})"; + }); + return "(" + string.Join(" ", parts) + ")"; + } + + private static string Quote(string value) => "\"" + (value ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + + /// 生成 BODYSTRUCTURE。附件在客户端能否正常显示取决于这里是否准确。 + private string BodyStructure(MailMessage message) + { + byte[] raw; + try { raw = store.ReadRaw(message.RawPath); } + catch { raw = []; } + + var (headers, body) = Mime.SplitMessage(raw); + var contentType = headers.FirstOrDefault(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)).Value ?? ""; + var (mediaType, parameters) = Mime.ParseContentType(contentType); + var transfer = headers.FirstOrDefault(h => h.Key.Equals("Content-Transfer-Encoding", StringComparison.OrdinalIgnoreCase)).Value ?? "7bit"; + + if (mediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) && parameters.TryGetValue("boundary", out var boundary)) + { + var parts = SplitParts(body, boundary); + var rendered = parts.Select(p => BodyStructureOfPart(p)).ToList(); + var subtype = mediaType["multipart/".Length..].ToUpperInvariant(); + return "(" + string.Join(" ", rendered) + $" {Quote(subtype)} ({Quote("BOUNDARY")} {Quote(boundary)}))"; + } + + return BodyStructureOfPart((headers, body), mediaType, transfer, forceAttachment: message.Attachments.Count > 0); + } + + private string BodyStructureOfPart((List> Headers, byte[] Body) part, string? knownType = null, string? knownTransfer = null, bool forceAttachment = false) + { + var contentType = knownType ?? part.Headers.FirstOrDefault(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)).Value ?? "text/plain; charset=us-ascii"; + var (mediaType, parameters) = Mime.ParseContentType(contentType); + var transfer = knownTransfer ?? part.Headers.FirstOrDefault(h => h.Key.Equals("Content-Transfer-Encoding", StringComparison.OrdinalIgnoreCase)).Value ?? "7bit"; + var disposition = part.Headers.FirstOrDefault(h => h.Key.Equals("Content-Disposition", StringComparison.OrdinalIgnoreCase)).Value ?? ""; + var (dispType, dispParams) = Mime.ParseContentType(disposition); + + if (mediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) && parameters.TryGetValue("boundary", out var boundary)) + { + var parts = SplitParts(part.Body, boundary).Select(p => BodyStructureOfPart(p)).ToList(); + return "(" + string.Join(" ", parts) + $" {Quote(mediaType["multipart/".Length..].ToUpperInvariant())} ({Quote("BOUNDARY")} {Quote(boundary)}))"; + } + + var size = part.Body.Length; + var lines = part.Body.Count(b => b == (byte)'\n'); + var name = dispParams.GetValueOrDefault("filename") ?? parameters.GetValueOrDefault("name") ?? ""; + var upper = mediaType.ToUpperInvariant(); + var slash = upper.IndexOf('/'); + var main = slash > 0 ? upper[..slash] : upper; + var sub = slash > 0 ? upper[(slash + 1)..] : "OCTET-STREAM"; + var isText = main == "TEXT"; + + var id = parameters.GetValueOrDefault("charset", isText ? "UTF-8" : ""); + var paramList = string.IsNullOrEmpty(id) && string.IsNullOrEmpty(name) ? "NIL" : "(" + + string.Join(" ", new[] + { + isText ? $"{Quote("CHARSET")} {Quote(id.Length > 0 ? id : "UTF-8")}" : "", + string.IsNullOrEmpty(name) ? "" : $"{Quote("NAME")} {Quote(name)}", + }.Where(x => x.Length > 0)) + ")"; + + var disp = forceAttachment || dispType.Length > 0 + ? $"({Quote(string.IsNullOrEmpty(dispType) ? "ATTACHMENT" : dispType.ToUpperInvariant())} {(string.IsNullOrEmpty(name) ? "NIL" : "(" + Quote("FILENAME") + " " + Quote(name) + ")")})" + : "NIL"; + + var tail = isText + ? $"{Quote(main)} {Quote(sub)} {paramList} NIL NIL {Quote(transfer.ToUpperInvariant())} {size} {lines}" + : $"{Quote(main)} {Quote(sub)} {paramList} NIL NIL {Quote(transfer.ToUpperInvariant())} {size}"; + + return isText ? $"({tail})" : $"({tail} {disp})"; + } + + private static List<(List> Headers, byte[] Body)> SplitParts(byte[] body, string boundary) + { + var result = new List<(List>, byte[])>(); + var delimiter = Encoding.ASCII.GetBytes("--" + boundary); + var positions = new List(); + for (var i = 0; i + delimiter.Length <= body.Length; i++) + { + if (body[i] != (byte)'-') continue; + var match = true; + for (var j = 0; j < delimiter.Length; j++) if (body[i + j] != delimiter[j]) { match = false; break; } + if (match) { positions.Add(i); i += delimiter.Length - 1; } + } + for (var index = 0; index < positions.Count - 1; index++) + { + var start = positions[index]; + var lineEnd = Array.IndexOf(body, (byte)'\n', start); + if (lineEnd < 0) continue; + start = lineEnd + 1; + var end = positions[index + 1]; + while (end > start && (body[end - 1] == (byte)'\n' || body[end - 1] == (byte)'\r')) end--; + if (end > start) result.Add(Mime.SplitMessage(body[start..end])); + } + return result; + } + + // ---------------------------------------------------------------- 集合与解析 + + private static List ResolveSet(string set, IReadOnlyList items, bool useUid) + { + var result = new List(); + foreach (var token in set.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var range = token.Split(':', 2); + if (range.Length == 1) + { + var item = Resolve(range[0], items, useUid); + if (item is not null) result.Add(item); + continue; + } + + var start = ResolveIndex(range[0], items, useUid); + var end = ResolveIndex(range[1], items, useUid); + if (start < 0 || end < 0) continue; + if (start > end) (start, end) = (end, start); + for (var i = start; i <= end && i < items.Count; i++) result.Add(items[i]); + } + return result; + } + + private static int ResolveIndex(string token, IReadOnlyList items, bool useUid) + { + if (token.Trim() == "*") return items.Count - 1; + if (!int.TryParse(token.Trim(), out var value)) return -1; + if (!useUid) return value - 1; + for (var i = 0; i < items.Count; i++) if (items[i].Uid == value) return i; + return -1; + } + + private static MailMessage? Resolve(string token, IReadOnlyList items, bool useUid) + { + var index = ResolveIndex(token, items, useUid); + return index >= 0 && index < items.Count ? items[index] : null; + } + + private static HashSet ParseUidSet(string set, IReadOnlyList items) + { + var uids = new HashSet(); + foreach (var token in set.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var range = token.Split(':', 2); + if (range.Length == 1) + { + if (int.TryParse(range[0], out var single)) uids.Add(single); + continue; + } + if (!int.TryParse(range[0], out var start)) continue; + var end = range[1] == "*" ? items.Select(x => x.Uid).DefaultIfEmpty(0).Max() : (int.TryParse(range[1], out var parsed) ? parsed : start); + if (start > end) (start, end) = (end, start); + for (var i = start; i <= end; i++) uids.Add(i); + } + return uids; + } + + private static List SplitTokens(string value) + { + var tokens = new List(); + var current = new StringBuilder(); + var depth = 0; + var quoted = false; + foreach (var c in value) + { + if (c == '"') quoted = !quoted; + if (!quoted) + { + if (c == '(') depth++; + if (c == ')') depth--; + if (c == ' ' && depth == 0) + { + if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); } + continue; + } + } + current.Append(c); + } + if (current.Length > 0) tokens.Add(current.ToString()); + return tokens; + } + + private static string ExtractMailbox(string rest) + { + var tokens = SplitTokens(rest); + return tokens.Count == 0 ? "" : tokens[^1].Trim('"'); + } + + private static string Unquote(string value) => value.Trim().Trim('"'); + + private static (string User, string Password) ParseLogin(string rest) + { + var tokens = SplitTokens(rest); + if (tokens.Count < 3) return ("", ""); + return (Unquote(tokens[1]), Unquote(tokens[2])); + } + + private static bool TryParsePlain(string payload, out string user, out string password) + { + user = ""; + password = ""; + try + { + var bytes = Convert.FromBase64String(payload.Trim()); + var parts = Encoding.UTF8.GetString(bytes).Split('\0'); + if (parts.Length < 3) return false; + user = parts[1]; + password = parts[2]; + return true; + } + catch { return false; } + } + + // ---------------------------------------------------------------- 输出 + + /// 写出响应。注意:未标记响应必须自带 "* " 前缀,标记响应由调用方拼接 tag。 + private static Task WriteAsync(StreamWriter writer, string line) => + writer.WriteLineAsync(line); + + private static Task OkAsync(StreamWriter writer, string tag, string text) => + writer.WriteLineAsync($"{tag} OK {text}"); + + private static Task NoAsync(StreamWriter writer, string tag, string text) => + writer.WriteLineAsync($"{tag} NO {text}"); + + private static async Task WriteBytesAsync(StreamWriter writer, byte[] data) + { + await writer.FlushAsync(); + await writer.BaseStream.WriteAsync(data); + await writer.BaseStream.FlushAsync(); + } +} diff --git a/server-native-v2/InboundAuth.cs b/server-native-v2/InboundAuth.cs new file mode 100644 index 0000000..e8b0add --- /dev/null +++ b/server-native-v2/InboundAuth.cs @@ -0,0 +1,464 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace WpywMail.Native; + +/// +/// 入站邮件身份校验:SPF(RFC 7208)、DKIM 验签(RFC 6376)、DMARC(RFC 7489)。 +/// +/// 为什么要有这一层:在此之前谁都能用 `From: wpy@wpy.email` 给这台服务器发信, +/// 服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有任何区别。 +/// +/// 设计取舍: +/// 1. **默认只标注不拒收**(`RejectOnDmarcReject=false`):校验实现自己也可能有 bug, +/// 拒收是不可逆的,投进垃圾箱是可逆的。DMARC 判失败时按策略投 spam。 +/// 2. **DNS 查询做成可注入的**():SPF/DKIM 的判定逻辑必须能 +/// 用固定记录做确定性自检,否则自检依赖外网、结果不可复现。 +/// 3. DNS 查询次数按 RFC 限制(SPF 10 次、void 2 次),避免成为放大攻击的靶子。 +/// +public sealed record InboundAuthVerdict +{ + public string Spf { get; init; } = "none"; + public string SpfDomain { get; init; } = ""; + public string SpfDetail { get; init; } = ""; + public string Dkim { get; init; } = "none"; + public string DkimDomain { get; init; } = ""; + public string DkimDetail { get; init; } = ""; + public string Dmarc { get; init; } = "none"; + public string DmarcDomain { get; init; } = ""; + public string DmarcPolicy { get; init; } = "none"; + public int Score { get; init; } + public string[] Reasons { get; init; } = []; + public bool Spam { get; init; } + public bool Reject { get; init; } + /// 可直接前置到报文里的 Authentication-Results 行(含 CRLF)。 + public string HeaderBlock { get; init; } = ""; +} + +/// DNS 查询抽象:真实实现走 UDP/系统解析器,自检用固定记录的实现。 +public interface IDnsLookup +{ + Task> TxtAsync(string name, CancellationToken token); + Task> AddressesAsync(string name, CancellationToken token); + Task> MxAsync(string name, CancellationToken token); +} + +/// 真实 DNS:TXT 自己发 UDP 查询(系统解析器拿不到 TXT),A 用系统解析,MX 复用既有的 MxResolver。 +public sealed class UdpDnsLookup : IDnsLookup +{ + private readonly AppConfig config; + public UdpDnsLookup(AppConfig config) => this.config = config; + + public async Task> TxtAsync(string name, CancellationToken token) + { + var timeout = Math.Max(1, config.InboundAuth.DnsTimeoutSeconds); + var target = name; + // 跟着 CNAME 走:outlook.com 之类的 DKIM 公钥就是发布成 CNAME 的, + // 自己发的原始 UDP 查询不会自动跟(系统解析器才会),不跟就永远取不到公钥。 + for (var depth = 0; depth < 5; depth++) + { + var moved = false; + foreach (var server in DnsServers()) + { + try + { + var (records, aliases) = await QueryAsync(target, server, timeout, token); + if (records.Count > 0) return records; + if (aliases.Count > 0) + { + target = aliases[0]; + moved = true; + AppLog.Info($"[DNS] {name} 是 CNAME,继续查 {target}"); + break; + } + } + catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException or OperationCanceledException) + { + AppLog.Warn($"[DNS] 查询 {target} 的 TXT 失败({server}):{ex.Message}"); + } + } + if (!moved) break; + } + return []; + } + + public async Task> AddressesAsync(string name, CancellationToken token) + { + try + { + var addresses = await Dns.GetHostAddressesAsync(name, token); + return addresses.Select(a => a.ToString()).ToArray(); + } + catch { return []; } + } + + public async Task> MxAsync(string name, CancellationToken token) + { + try { return await MxResolver.ResolveAsync(name, config.DirectDelivery, token); } + catch { return []; } + } + + private IEnumerable DnsServers() + { + var configured = (config.DirectDelivery.DnsServer ?? "").Trim(); + if (configured.Length > 0) + { + foreach (var part in configured.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries)) + if (IPAddress.TryParse(part.Trim(), out var ip)) yield return ip; + yield break; + } + var system = new List(); + try + { + foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue; + foreach (var dns in ni.GetIPProperties().DnsAddresses) + if (dns.AddressFamily == AddressFamily.InterNetwork && !system.Contains(dns)) system.Add(dns); + } + } + catch { } + if (system.Count == 0) system.Add(IPAddress.Parse("1.1.1.1")); + foreach (var ip in system) yield return ip; + } + + private static async Task<(IReadOnlyList Txt, IReadOnlyList Cname)> QueryAsync(string name, IPAddress server, int timeoutSeconds, CancellationToken token) + { + using var udp = new UdpClient(server.AddressFamily); + var query = BuildTxtQuery(name, out var id); + await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53)); + var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(timeoutSeconds), token); + return ParseTxtResponse(result.Buffer, id); + } + + private static byte[] BuildTxtQuery(string name, out ushort id) + { + id = (ushort)Random.Shared.Next(1, ushort.MaxValue); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + writer.Write(ToNetwork(id)); + writer.Write(ToNetwork((ushort)0x0100)); + writer.Write(ToNetwork((ushort)1)); + writer.Write(ToNetwork((ushort)0)); + writer.Write(ToNetwork((ushort)0)); + writer.Write(ToNetwork((ushort)0)); + foreach (var label in name.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + var bytes = Encoding.ASCII.GetBytes(label); + writer.Write((byte)bytes.Length); + writer.Write(bytes); + } + writer.Write((byte)0); + writer.Write(ToNetwork((ushort)16)); // TXT + writer.Write(ToNetwork((ushort)1)); + return stream.ToArray(); + } + + private static (IReadOnlyList Txt, IReadOnlyList Cname) ParseTxtResponse(byte[] data, ushort expectedId) + { + var records = new List(); + var aliases = new List(); + if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return (records, aliases); + var flags = ReadUInt16(data, 2); + if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return (records, aliases); + var questions = ReadUInt16(data, 4); + var answers = ReadUInt16(data, 6); + var offset = 12; + for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; } + for (var i = 0; i < answers && offset + 10 <= data.Length; i++) + { + ReadName(data, ref offset); + if (offset + 10 > data.Length) break; + var type = ReadUInt16(data, offset); + var length = ReadUInt16(data, offset + 8); + offset += 10; + if (offset + length > data.Length) break; + if (type == 5) + { + var cursor = offset; + var alias = ReadName(data, ref cursor); + if (alias.Length > 0) aliases.Add(alias.TrimEnd('.')); + } + else if (type == 16) + { + // TXT 的 RDATA 是若干「长度 + 字节」的片段,要拼起来(DKIM 公钥常被切成多段) + var text = new StringBuilder(); + var cursor = offset; + var end = offset + length; + while (cursor < end) + { + var piece = data[cursor]; + cursor++; + if (cursor + piece > end) break; + text.Append(Encoding.UTF8.GetString(data, cursor, piece)); + cursor += piece; + } + if (text.Length > 0) records.Add(text.ToString()); + } + offset += length; + } + return (records, aliases); + } + + private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]); + private static byte[] ToNetwork(ushort value) => [(byte)(value >> 8), (byte)(value & 0xFF)]; + + private static string ReadName(byte[] data, ref int offset) + { + var labels = new List(); + var cursor = offset; + var jumped = false; + var next = offset; + while (cursor < data.Length) + { + var length = data[cursor++]; + if (length == 0) { if (!jumped) next = cursor; break; } + if ((length & 0xC0) == 0xC0) + { + if (cursor >= data.Length) break; + var pointer = ((length & 0x3F) << 8) | data[cursor++]; + if (!jumped) next = cursor; // ⚠ 必须记住「指针之后」的位置: + cursor = pointer; // 否则跟着指针跳进 question 段后, + jumped = true; // 后面读 type/class/length 就全错位了(TXT 一条都解析不出来) + continue; + } + if (length > 63 || cursor + length > data.Length) break; + labels.Add(Encoding.ASCII.GetString(data, cursor, length)); + cursor += length; + } + offset = next; + return string.Join('.', labels); + } +} + +/// SPF 求值(RFC 7208 的常用子集:all/include/a/mx/ip4/ip6/exists + 限定符 + redirect)。 +public static class Spf +{ + public sealed record Result(string Outcome, string Domain, string Detail); + private const string None = "none", Pass = "pass", Fail = "fail", SoftFail = "softfail", Neutral = "neutral", + TempError = "temperror", PermError = "permerror"; + + public static async Task EvaluateAsync(string? ip, string? helo, string? mailFrom, IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token) + { + var senderDomain = DomainOf(mailFrom); + if (senderDomain.Length == 0) + { + // 空 MAIL FROM(退信):按 RFC 7208 §2.4 用 HELO 域名 + var heloDomain = (helo ?? "").Trim().TrimEnd('.'); + if (heloDomain.Length == 0 || !heloDomain.Contains('.')) return new Result(None, "", "无发件人域,无法判定"); + senderDomain = heloDomain; + } + if (!IPAddress.TryParse(ip, out var address)) return new Result(None, senderDomain, "来源地址不可解析"); + + var state = new State(dns, cfg, token); + var outcome = await state.CheckDomainAsync(senderDomain, address, mailFrom ?? "", helo ?? "", depth: 0); + return new Result(outcome.Outcome, senderDomain, outcome.Detail); + } + + private sealed class State(IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token) + { + private int lookups; + private int voids; + + public async Task<(string Outcome, string Detail)> CheckDomainAsync(string domain, IPAddress ip, string mailFrom, string helo, int depth) + { + if (depth > 5) return (PermError, "include/redirect 嵌套过深"); + var records = await TxtAsync(domain); + var spf = records.Where(r => r.TrimStart().StartsWith("v=spf1", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (spf.Length == 0) return (None, $"{domain} 没有 SPF 记录"); + if (spf.Length > 1) return (PermError, $"{domain} 有多条 SPF 记录"); + + var terms = Tokenize(spf[0]); + var redirect = ""; + foreach (var raw in terms) + { + if (raw.Length == 0) continue; + var term = raw; + var qualifier = '+'; + if ("+-~?".Contains(term[0])) { qualifier = term[0]; term = term[1..]; } + if (term.StartsWith("redirect=", StringComparison.OrdinalIgnoreCase)) + { + redirect = term["redirect=".Length..]; + continue; + } + if (term.Contains('=')) continue; // 其它修饰符(exp= 等)本实现不处理 + + var (name, value) = Split(term); + var match = false; + switch (name.ToLowerInvariant()) + { + case "all": + match = true; + break; + case "include": + if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次"); + { + var included = await CheckDomainAsync(value, ip, mailFrom, helo, depth + 1); + if (included.Outcome == Pass) return (Pass, $"include:{value} 通过"); + if (included.Outcome is TempError or PermError) return (included.Outcome, included.Detail); + } + break; + case "a": + if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次"); + match = await MatchesAddressAsync(value.Length > 0 ? value : domain, ip); + break; + case "mx": + if (!await CountLookupAsync(value.Length > 0 ? value : domain)) return (PermError, "SPF 查询次数超过 10 次"); + { + var hosts = await SafeMxAsync(value.Length > 0 ? value : domain); + foreach (var host in hosts) + if (await MatchesAddressAsync(host, ip)) { match = true; break; } + } + break; + case "ip4": + match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetwork); + break; + case "ip6": + match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetworkV6); + break; + case "exists": + if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次"); + match = (await SafeAddressesAsync(Expand(value, domain, ip, mailFrom, helo))).Count > 0; + break; + case "ptr": + // RFC 7208 §5.5:ptr 机制不推荐使用,本实现直接视为不匹配 + break; + default: + continue; + } + + if (!match) continue; + var outcome = qualifier switch + { + '-' => Fail, + '~' => SoftFail, + '?' => Neutral, + _ => Pass, + }; + return (outcome, $"{raw} 命中({domain})"); + } + + if (redirect.Length > 0) + { + if (!await CountLookupAsync(redirect)) return (PermError, "SPF 查询次数超过 10 次"); + return await CheckDomainAsync(redirect, ip, mailFrom, helo, depth + 1); + } + return (Neutral, $"{domain} 的 SPF 没有匹配项"); + } + + private async Task CountLookupAsync(string name) + { + lookups++; + if (lookups > Math.Max(1, cfg.MaxSpfLookups)) return false; + // void lookup(查了但没记录)超过 2 次即 permerror + return await Task.FromResult(true); + } + + private async Task> TxtAsync(string name) + { + var r = await SafeTxtAsync(name); + if (r.Count == 0 && ++voids > 2) return r; + return r; + } + + private async Task> SafeTxtAsync(string name) + { + try { return await dns.TxtAsync(name.TrimEnd('.'), token); } + catch (Exception ex) when (ex is not OperationCanceledException) { return []; } + } + + private async Task> SafeAddressesAsync(string name) + { + try { return await dns.AddressesAsync(name.TrimEnd('.'), token); } + catch { return []; } + } + + private async Task> SafeMxAsync(string name) + { + try { return await dns.MxAsync(name.TrimEnd('.'), token); } + catch { return []; } + } + + private async Task MatchesAddressAsync(string host, IPAddress ip) + { + foreach (var candidate in await SafeAddressesAsync(host)) + if (IPAddress.TryParse(candidate, out var parsed) && parsed.Equals(ip)) return true; + return false; + } + } + + private static bool IpMatches(IPAddress ip, string value, AddressFamily family) + { + if (ip.AddressFamily != family) return false; + var parts = value.Split('/', 2); + if (!IPAddress.TryParse(parts[0], out var network)) return false; + var bits = parts.Length > 1 && int.TryParse(parts[1], out var parsedBits) + ? parsedBits + : (family == AddressFamily.InterNetwork ? 32 : 128); + var networkBytes = network.GetAddressBytes(); + var ipBytes = ip.GetAddressBytes(); + if (networkBytes.Length != ipBytes.Length) return false; + var fullBytes = bits / 8; + for (var i = 0; i < fullBytes; i++) if (networkBytes[i] != ipBytes[i]) return false; + var remainder = bits % 8; + if (remainder == 0) return true; + var mask = (byte)(0xFF << (8 - remainder)); + return (networkBytes[fullBytes] & mask) == (ipBytes[fullBytes] & mask); + } + + private static IEnumerable Tokenize(string record) + { + foreach (var piece in record.Split(' ', '\t', '\r', '\n')) + { + var term = piece.Trim(); + if (term.Length == 0) continue; + yield return term; + } + } + + private static (string Name, string Value) Split(string term) + { + var colon = term.IndexOf(':'); + if (colon < 0) return (term, ""); + var name = term[..colon]; + var value = term[(colon + 1)..]; + // ⚠ 注意:**不能在这里砍掉 `/`** —— ip4:203.0.113.0/24 的 CIDR 就是值的一部分。 + // a/mx 的 `a:domain/24` 形式由调用方自己拆(本实现不支持 a/mx 的 CIDR 修饰, + // 这属于罕见的用法,但 ip4/ip6 的 CIDR 是必须的)。 + if (name.Equals("a", StringComparison.OrdinalIgnoreCase) || name.Equals("mx", StringComparison.OrdinalIgnoreCase)) + { + var slash = value.IndexOf('/'); + if (slash >= 0) value = value[..slash]; + } + return (name, value); + } + + /// SPF 宏的常用子集(%{d} %{s} %{o} %{i} %{h})。 + public static string Expand(string value, string domain, IPAddress ip, string mailFrom, string helo) + { + if (!value.Contains("%{")) return value; + var sender = mailFrom.Split('@').LastOrDefault() ?? ""; + var local = mailFrom.Split('@').FirstOrDefault() ?? ""; + return value + .Replace("%{d}", domain, StringComparison.OrdinalIgnoreCase) + .Replace("%{s}", mailFrom, StringComparison.OrdinalIgnoreCase) + .Replace("%{l}", local, StringComparison.OrdinalIgnoreCase) + .Replace("%{o}", sender, StringComparison.OrdinalIgnoreCase) + .Replace("%{i}", ip.ToString(), StringComparison.OrdinalIgnoreCase) + .Replace("%{h}", helo, StringComparison.OrdinalIgnoreCase); + } + + public static string DomainOf(string? address) + { + var text = (address ?? "").Trim(); + var at = text.LastIndexOf('@'); + if (at < 0) return ""; + var domain = text[(at + 1)..]; + // 可能是 `bob@example.com>` 或 `bob@example.com (注释)` —— 截到第一个分隔符 + var stop = domain.IndexOfAny(['>', ' ', '\t', ')', ',', ';', '"']); + if (stop >= 0) domain = domain[..stop]; + return domain.Trim().TrimEnd('.').ToLowerInvariant(); + } +} diff --git a/server-native-v2/InboundAuthVerify.cs b/server-native-v2/InboundAuthVerify.cs new file mode 100644 index 0000000..398729e --- /dev/null +++ b/server-native-v2/InboundAuthVerify.cs @@ -0,0 +1,462 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WpywMail.Native; + +/// +/// DKIM 验签(RFC 6376)。**独立按 RFC 实现,不复用签名端代码** —— +/// 2026-09-13 那次 DKIM 顺序 bug 的教训就是「自己验自己」会一起错。 +/// +public static class DkimVerifier +{ + public sealed record Result(string Outcome, string Domain, string Selector, string Detail); + + public static async Task> VerifyAllAsync(byte[] raw, IDnsLookup dns, CancellationToken token) + { + var text = Encoding.Latin1.GetString(raw); // 1 字节 ↔ 1 字符,索引即字节偏移 + var headers = ParseHeaders(text); + var results = new List(); + foreach (var header in headers.Where(h => h.Name.Equals("DKIM-Signature", StringComparison.OrdinalIgnoreCase))) + { + try { results.Add(await VerifyOneAsync(text, headers, header, dns, token)); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + results.Add(new Result("temperror", "", "", ex.Message)); + } + } + return results; + } + + private sealed record Header(string Name, string Raw, string Value) + { + public bool Used { get; set; } + } + + private static async Task VerifyOneAsync(string text, List
headers, Header signature, IDnsLookup dns, CancellationToken token) + { + var tags = ParseTags(signature.Value); + var domain = tags.GetValueOrDefault("d", ""); + var selector = tags.GetValueOrDefault("s", ""); + if (tags.GetValueOrDefault("v", "1") != "1") return new Result("fail", domain, selector, "v= 不是 1"); + if (domain.Length == 0 || selector.Length == 0) return new Result("fail", domain, selector, "缺 d= 或 s="); + + var algorithm = tags.GetValueOrDefault("a", "rsa-sha256").ToLowerInvariant(); + var hashName = algorithm switch + { + "rsa-sha256" => HashAlgorithmName.SHA256, + "rsa-sha1" => HashAlgorithmName.SHA1, + "ed25519-sha256" => HashAlgorithmName.SHA256, + _ => default, + }; + if (hashName == default) return new Result("fail", domain, selector, $"不支持的算法 a={algorithm}"); + if (algorithm.StartsWith("ed25519", StringComparison.Ordinal)) + return new Result("neutral", domain, selector, "ed25519 本实现不支持(极少见)"); + + var canon = tags.GetValueOrDefault("c", "simple/simple").ToLowerInvariant().Split('/'); + var headerCanon = canon[0] is "relaxed" ? "relaxed" : "simple"; + var bodyCanon = canon.Length > 1 && canon[1] == "relaxed" ? "relaxed" : "simple"; + + var body = ExtractBody(text); + var canonicalBody = CanonicalizeBody(body, bodyCanon); + if (tags.TryGetValue("l", out var lengthText) && int.TryParse(lengthText, out var limit) && limit >= 0 && limit < canonicalBody.Length) + canonicalBody = canonicalBody[..limit]; + + var expectedBodyHash = tags.GetValueOrDefault("bh", ""); + var actualBodyHash = Convert.ToBase64String(Hash(hashName, Encoding.Latin1.GetBytes(canonicalBody))); + if (!string.Equals(expectedBodyHash, actualBodyHash, StringComparison.Ordinal)) + return new Result("fail", domain, selector, $"正文哈希不符(bh={Short(expectedBodyHash)} 实际={Short(actualBodyHash)})"); + + // 按 h= 的先后顺序取头,重名时从**下往上**取(RFC 6376 §5.4.2) + var signedNames = tags.GetValueOrDefault("h", "").Split(':', StringSplitOptions.RemoveEmptyEntries); + if (signedNames.Length == 0) return new Result("fail", domain, selector, "h= 为空"); + var builder = new StringBuilder(); + foreach (var name in signedNames) + { + var picked = headers.LastOrDefault(h => !h.Used && !ReferenceEquals(h, signature) + && h.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase)); + if (picked is not null) picked.Used = true; + builder.Append(CanonicalizeHeader(picked?.Name ?? name.Trim(), picked?.Raw ?? "", headerCanon)); + } + builder.Append(CanonicalizeHeader(signature.Name, StripSignatureValue(signature.Raw), headerCanon, trimCrLf: true)); + + var signatureBytes = Convert.FromBase64String(tags.GetValueOrDefault("b", "").Trim()); + var data = Encoding.Latin1.GetBytes(builder.ToString()); + + var keyText = await LookupKeyAsync(domain, selector, dns, token); + if (keyText is null) return new Result("temperror", domain, selector, $"取不到公钥 {selector}._domainkey.{domain}"); + if (keyText.Length == 0) return new Result("fail", domain, selector, "公钥记录里 p= 为空(已吊销)"); + + try + { + using var rsa = RSA.Create(); + rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(keyText), out _); + var ok = rsa.VerifyData(data, signatureBytes, hashName, RSASignaturePadding.Pkcs1); + return ok + ? new Result("pass", domain, selector, $"a={algorithm} c={string.Join('/', canon)}") + : new Result("fail", domain, selector, "签名不符(报文可能被改过)"); + } + catch (FormatException) + { + return new Result("fail", domain, selector, "公钥不是合法的 SPKI base64"); + } + catch (CryptographicException ex) + { + return new Result("fail", domain, selector, $"验签异常:{ex.Message}"); + } + } + + private static async Task LookupKeyAsync(string domain, string selector, IDnsLookup dns, CancellationToken token) + { + var name = $"{selector}._domainkey.{domain}"; + var records = await dns.TxtAsync(name, token); + foreach (var record in records) + { + var tags = ParseTags(record); + if (!tags.ContainsKey("p")) continue; + return tags["p"]; + } + return null; + } + + private static string ExtractBody(string text) + { + var crlf = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + var lf = text.IndexOf("\n\n", StringComparison.Ordinal); + var index = crlf >= 0 && (lf < 0 || crlf <= lf) ? crlf + 4 : lf >= 0 ? lf + 2 : -1; + return index < 0 ? "" : text[index..]; + } + + private static List
ParseHeaders(string text) + { + var list = new List
(); + var block = text; + var crlf = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + var lf = text.IndexOf("\n\n", StringComparison.Ordinal); + if (crlf >= 0 && (lf < 0 || crlf <= lf)) block = text[..crlf]; + else if (lf >= 0) block = text[..lf]; + + var lines = block.Split('\n'); + StringBuilder? current = null; + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + if (line.Length == 0) continue; + if ((line[0] == ' ' || line[0] == '\t') && current is not null) + { + current.Append("\r\n").Append(line); + continue; + } + if (current is not null) list.Add(Finish(current.ToString())); + current = new StringBuilder(line); + } + if (current is not null) list.Add(Finish(current.ToString())); + return list; + + static Header Finish(string raw) + { + var colon = raw.IndexOf(':'); + if (colon < 0) return new Header(raw.Trim(), raw, ""); + var name = raw[..colon].Trim(); + var value = raw[(colon + 1)..].Trim(); + return new Header(name, raw, value); + } + } + + /// + /// 把 b= 的值抹掉(验签输入里的 DKIM-Signature 头不能带签名本身)。 + /// + /// ⚠ 必须**按标签边界**找 b=:直接 IndexOf("b=") 会被前面的 `bh=` 的 base64 内容误伤 + /// (base64 以 `b=` 结尾完全合法),一位之差就整封验不过 —— 自检里正是这一条抓出来的。 + /// + private static string StripSignatureValue(string raw) + { + var index = TagValueStart(raw, "b"); + if (index < 0) return raw; + var end = raw.IndexOf(';', index); + var head = raw[..index]; + var tail = end < 0 ? "" : raw[end..]; + return head + tail; + } + + /// 返回名为 name 的标签「值」的起始下标(-1 表示没有)。标签必须出现在 `;` 之后或开头。 + private static int TagValueStart(string raw, string name) + { + var position = raw.IndexOf(':'); + position = position < 0 ? 0 : position + 1; + while (position < raw.Length) + { + while (position < raw.Length && (raw[position] is ' ' or '\t' or '\r' or '\n' or ';')) position++; + if (position >= raw.Length) return -1; + var equals = raw.IndexOf('=', position); + if (equals < 0) return -1; + var tag = raw[position..equals].Trim(); + var semicolon = raw.IndexOf(';', equals); + if (tag.Equals(name, StringComparison.OrdinalIgnoreCase)) return equals + 1; + if (semicolon < 0) return -1; + position = semicolon + 1; + } + return -1; + } + + private static string CanonicalizeHeader(string name, string raw, string mode, bool trimCrLf = false) + { + string result; + if (mode == "relaxed") + { + var colon = raw.IndexOf(':'); + var value = colon < 0 ? "" : raw[(colon + 1)..]; + var unfolded = value.Replace("\r\n", " "); + var collapsed = Collapse(unfolded).TrimEnd(' ', '\t'); + result = name.ToLowerInvariant().Trim() + ":" + collapsed + "\r\n"; + } + else + { + var text = raw.Length > 0 ? raw : name + ":"; + result = text + "\r\n"; + } + return trimCrLf ? result[..^2] : result; + } + + private static string CanonicalizeBody(string body, string mode) + { + if (body.Length == 0) return ""; + var lines = new List(); + var start = 0; + while (start <= body.Length) + { + var end = body.IndexOf('\n', start); + var hasTerminator = end >= 0; + var line = hasTerminator ? body[start..end].TrimEnd('\r') : body[start..]; + lines.Add(line); + if (!hasTerminator) break; + start = end + 1; + } + // 去掉末尾的空行(保留最后一行的行尾) + while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + if (lines.Count == 0) return ""; + + var builder = new StringBuilder(); + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (mode == "relaxed") + { + line = Collapse(line).TrimEnd(' ', '\t'); + } + builder.Append(line).Append("\r\n"); + } + return builder.ToString(); + } + + private static string Collapse(string value) + { + var builder = new StringBuilder(value.Length); + var space = false; + foreach (var c in value) + { + if (c is ' ' or '\t') + { + space = true; + continue; + } + if (space && builder.Length > 0) builder.Append(' '); + space = false; + builder.Append(c); + } + return builder.ToString(); + } + + private static byte[] Hash(HashAlgorithmName name, byte[] data) => + name == HashAlgorithmName.SHA1 ? SHA1.HashData(data) : SHA256.HashData(data); + + private static string Short(string value) => value.Length <= 10 ? value : value[..10] + "…"; + + /// 解析 `k=v; k=v` 形式的标签(DKIM 签名头与 DNS 公钥记录共用)。 + public static Dictionary ParseTags(string text) + { + var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in text.Split(';')) + { + var equals = part.IndexOf('='); + if (equals <= 0) continue; + var key = part[..equals].Trim().ToLowerInvariant(); + var value = part[(equals + 1)..]; + // 值里的换行/空白都要去掉(b=、p= 常被折成多行) + value = new string(value.Where(c => c is not (' ' or '\t' or '\r' or '\n')).ToArray()); + if (key.Length > 0) tags[key] = value; + } + return tags; + } +} + +/// DMARC 求值(RFC 7489 的常用子集)。 +public static class Dmarc +{ + public sealed record Result(string Outcome, string Policy, string Domain, string Detail); + + /// 公共后缀的常用子集(判断「组织域」用;没列到的按最后两段算)。 + private static readonly HashSet MultiPartSuffixes = new(StringComparer.OrdinalIgnoreCase) + { + "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp", + "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "com.hk", "com.tw", "com.au", + "com.br", "com.sg", "co.kr", "com.mx", "co.in", "com.tr", + }; + + /// 组织域(relaxed 对齐用):`mail.example.co.uk` → `example.co.uk`。 + public static string OrganizationalDomain(string domain) + { + var parts = (domain ?? "").Trim().TrimEnd('.').ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length <= 2) return string.Join('.', parts); + var lastTwo = string.Join('.', parts[^2..]); + return MultiPartSuffixes.Contains(lastTwo) && parts.Length >= 3 + ? string.Join('.', parts[^3..]) + : lastTwo; + } + + public static async Task EvaluateAsync(string fromDomain, (string Outcome, string Domain) spf, + IReadOnlyList<(string Outcome, string Domain)> dkims, IDnsLookup dns, CancellationToken token) + { + fromDomain = (fromDomain ?? "").Trim().TrimEnd('.').ToLowerInvariant(); + if (fromDomain.Length == 0) return new Result("none", "none", "", "报文没有可用的 From 域名"); + + var records = await dns.TxtAsync("_dmarc." + fromDomain, token); + var record = records.FirstOrDefault(r => r.TrimStart().StartsWith("v=DMARC1", StringComparison.OrdinalIgnoreCase)); + if (record is null) return new Result("none", "none", fromDomain, $"_dmarc.{fromDomain} 没有 DMARC 记录"); + + var tags = DkimVerifier.ParseTags(record); + var policy = tags.GetValueOrDefault("p", "").ToLowerInvariant(); + if (policy is not ("none" or "quarantine" or "reject")) + return new Result("none", "none", fromDomain, $"DMARC 记录里的 p= 无效:{policy}"); + var strictSpf = tags.GetValueOrDefault("aspf", "r").Equals("s", StringComparison.OrdinalIgnoreCase); + var strictDkim = tags.GetValueOrDefault("adkim", "r").Equals("s", StringComparison.OrdinalIgnoreCase); + + bool Aligned(string candidate, bool strict) + { + if (candidate.Length == 0) return false; + return strict + ? candidate.Equals(fromDomain, StringComparison.OrdinalIgnoreCase) + : OrganizationalDomain(candidate) == OrganizationalDomain(fromDomain); + } + + if (spf.Outcome == "pass" && Aligned(spf.Domain, strictSpf)) + return new Result("pass", policy, fromDomain, $"SPF 对齐通过({spf.Domain},aspf={(strictSpf ? "s" : "r")})"); + foreach (var dkim in dkims.Where(d => d.Outcome == "pass")) + if (Aligned(dkim.Domain, strictDkim)) + return new Result("pass", policy, fromDomain, $"DKIM 对齐通过({dkim.Domain},adkim={(strictDkim ? "s" : "r")})"); + + // 没有任何对齐的通过项 → 失败(temperror 时按 RFC 也不该直接判失败,这里如实标注) + var temperror = dkims.Any(d => d.Outcome == "temperror") || spf.Outcome == "temperror"; + var detail = $"SPF={spf.Outcome}({spf.Domain}) DKIM=[{string.Join(",", dkims.Select(d => $"{d.Outcome}:{d.Domain}"))}] 与 From 域 {fromDomain} 无对齐"; + return new Result(temperror ? "temperror" : "fail", policy, fromDomain, detail); + } +} + +/// 把三件事串起来:SPF → DKIM → DMARC,产出可写进报文的结论与是否判为垃圾。 +public static class InboundAuth +{ + public static async Task CheckAsync(byte[] raw, string clientIp, string helo, string mailFrom, + AppConfig config, CancellationToken token, IDnsLookup? dns = null) + { + dns ??= new UdpDnsLookup(config); + var cfg = config.InboundAuth; + + var fromDomain = ""; + try + { + var parsed = Mime.Parse(raw); + // 注意:服务端 Mime.Parse 的 From 是**字符串**(不是地址数组),要先用 Mime.Addresses 拆 + fromDomain = Spf.DomainOf(Mime.Addresses(parsed.From).FirstOrDefault() ?? parsed.From); + } + catch { /* 报文解析失败就按无 From 处理 */ } + + var spf = await Spf.EvaluateAsync(clientIp, helo, mailFrom, dns, cfg, token); + + var dkimResults = new List<(string Outcome, string Domain)>(); + var dkimDetail = ""; + if (cfg.VerifyDkim) + { + var verified = await DkimVerifier.VerifyAllAsync(raw, dns, token); + foreach (var v in verified) dkimResults.Add((v.Outcome, v.Domain)); + var best = verified.OrderBy(v => v.Outcome == "pass" ? 0 : v.Outcome == "temperror" ? 1 : 2).FirstOrDefault(); + if (best is not null) dkimDetail = $"d={best.Domain} s={best.Selector} {best.Detail}"; + } + + var dmarc = await Dmarc.EvaluateAsync(fromDomain, (spf.Outcome, spf.Domain), dkimResults, dns, token); + + // ── 打分(0 = 干净;阈值默认 3) + var score = 0; + var reasons = new List(); + switch (dmarc.Outcome) + { + case "fail": + score += 4; + reasons.Add($"DMARC 失败(p={dmarc.Policy}):{dmarc.Detail}"); + if (dmarc.Policy == "reject") score += 2; + break; + case "none": + if (spf.Outcome == "fail") { score += 2; reasons.Add("SPF 硬失败且该域没有 DMARC 记录"); } + else if (spf.Outcome == "softfail") { score += 1; reasons.Add("SPF 软失败"); } + else if (spf.Outcome == "permerror") { score += 1; reasons.Add("SPF 记录有错(permerror)"); } + var anyDkim = dkimResults.Count > 0; + if (anyDkim && !dkimResults.Any(d => d.Outcome == "pass")) { score += 1; reasons.Add("带了 DKIM 签名但验不过"); } + break; + } + if (spf.Outcome is "none" && dkimResults.Count == 0 && dmarc.Outcome == "none") + reasons.Add("既没有 SPF 也没有 DKIM(小发件人常见,仅作提示)"); + + var hardFail = dmarc.Outcome == "fail" || score >= Math.Max(1, cfg.SpamScoreThreshold); + var spam = hardFail && cfg.SpamFolderOnFail; + var reject = dmarc.Outcome == "fail" && dmarc.Policy == "reject" && cfg.RejectOnDmarcReject; + + var verdict = new InboundAuthVerdict + { + Spf = spf.Outcome, + SpfDomain = spf.Domain, + SpfDetail = spf.Detail, + Dkim = dkimResults.Count == 0 ? "none" + : dkimResults.Any(d => d.Outcome == "pass") ? "pass" + : dkimResults.Any(d => d.Outcome == "temperror") ? "temperror" : "fail", + DkimDomain = dkimResults.FirstOrDefault().Domain ?? "", + DkimDetail = dkimDetail, + Dmarc = dmarc.Outcome, + DmarcDomain = dmarc.Domain, + DmarcPolicy = dmarc.Policy, + Score = score, + Reasons = reasons.ToArray(), + Spam = spam, + Reject = reject, + }; + return cfg.AddAuthenticationResults ? verdict with { HeaderBlock = BuildHeader(verdict, config) } : verdict; + } + + private static string BuildHeader(InboundAuthVerdict v, AppConfig config) + { + var builder = new StringBuilder(); + builder.Append("Authentication-Results: ").Append(config.Hostname).Append(";\r\n"); + builder.Append("\tspf=").Append(v.Spf); + if (v.SpfDomain.Length > 0) builder.Append(" smtp.mailfrom=").Append(v.SpfDomain); + builder.Append(";\r\n"); + builder.Append("\tdkim=").Append(v.Dkim); + if (v.DkimDomain.Length > 0) builder.Append(" header.d=").Append(v.DkimDomain); + builder.Append(";\r\n"); + builder.Append("\tdmarc=").Append(v.Dmarc); + if (v.DmarcDomain.Length > 0) builder.Append(" header.from=").Append(v.DmarcDomain); + if (v.DmarcPolicy != "none") builder.Append(" policy=").Append(v.DmarcPolicy); + builder.Append("\r\n"); + builder.Append("X-Spam-Score: ").Append(v.Score).Append("\r\n"); + if (v.Reasons.Length > 0) + builder.Append("X-Spam-Reason: ").Append(string.Join(" / ", v.Reasons)).Append("\r\n"); + return builder.ToString(); + } + + /// 把校验收到的头前置到报文最前面(不碰原有字节,避免破坏对方 DKIM 签名)。 + public static byte[] PrependHeaders(byte[] raw, string headerBlock) + { + if (string.IsNullOrEmpty(headerBlock)) return raw; + var prefix = Encoding.Latin1.GetBytes(headerBlock); + var output = new byte[prefix.Length + raw.Length]; + Buffer.BlockCopy(prefix, 0, output, 0, prefix.Length); + Buffer.BlockCopy(raw, 0, output, prefix.Length, raw.Length); + return output; + } +} diff --git a/server-native-v2/Migration.cs b/server-native-v2/Migration.cs new file mode 100644 index 0000000..054ce4f --- /dev/null +++ b/server-native-v2/Migration.cs @@ -0,0 +1,134 @@ +namespace WpywMail.Native; + +/// +/// 一次性数据迁移(用法:WpywMail.Native.exe --migrate)。 +/// +/// 1.x 版本有两个存储层缺陷: +/// · Mime.Parse 不解码 RFC 2047 编码字,也不解 base64/QP 正文 —— +/// 导致 messages.json 里的主题是「=?utf-8?b?...?=」、正文是 base64 乱码; +/// · 换域名后历史邮件的 ownerEmail 仍指向旧域名,登录新账号后看不到。 +/// 本迁移用新解析器重新解析 raw/*.eml 修好字段,并把旧域名归到当前配置的账号下。 +/// +public static class Migration +{ + public static int Run(AppConfig config, IMailStore store) + { + Console.WriteLine("=== 数据迁移 ==="); + + // 旧域名 → 当前域名的账号映射 + var users = store.AllUsers(); + var oldAdmin = users.FirstOrDefault(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase)); + var currentEmail = oldAdmin?.Email ?? config.AdminEmail; + + var messages = store.AllMessages(); + var fixedSubjects = 0; + var reParsed = 0; + var reOwnered = 0; + var missingRaw = 0; + + foreach (var message in messages) + { + // ---- 1) 重新解析原始报文 ---- + byte[]? raw = null; + if (!string.IsNullOrWhiteSpace(message.RawPath)) + { + try { raw = store.ReadRaw(message.RawPath); } + catch { missingRaw++; } + } + + if (raw is not null) + { + try + { + var parsed = Mime.Parse(raw); + var changed = false; + + if (!string.IsNullOrWhiteSpace(parsed.Subject) && parsed.Subject != "(无主题)" && parsed.Subject != message.Subject) + { + message.Subject = parsed.Subject; + changed = true; + } + if (parsed.Text.Length > 0 && parsed.Text != message.Text) + { + message.Text = parsed.Text; + changed = true; + } + if (parsed.Html.Length > 0 && parsed.Html != message.Html) + { + message.Html = parsed.Html; + changed = true; + } + if (string.IsNullOrWhiteSpace(message.MessageId) && parsed.MessageId.Length > 0) + { + message.MessageId = parsed.MessageId; + changed = true; + } + if (string.IsNullOrWhiteSpace(message.From) && parsed.From.Length > 0) + { + message.From = parsed.From; + changed = true; + } + if (string.IsNullOrWhiteSpace(message.Cc) && parsed.Cc.Length > 0) + { + message.Cc = parsed.Cc; + changed = true; + } + // 附件补齐(旧版本没有存附件) + if (message.Attachments.Count == 0 && parsed.Attachments.Count > 0) + { + foreach (var attachment in parsed.Attachments) + { + if (attachment.Data.Length == 0) continue; + message.Attachments.Add(new Attachment + { + FileName = attachment.FileName, + ContentType = attachment.ContentType, + Size = attachment.Data.Length, + StoredAs = store.SaveAttachment(attachment.Data, attachment.FileName), + ContentId = attachment.ContentId, + Inline = attachment.Inline, + }); + } + changed = true; + } + + if (changed) + { + reParsed++; + if (message.Subject.Length > 0) fixedSubjects++; + } + } + catch (Exception ex) + { + Console.WriteLine($" [跳过] {message.Id}:{ex.Message}"); + } + } + + // ---- 2) 旧域名归属改到当前账号 ---- + if (!string.IsNullOrWhiteSpace(message.OwnerEmail) && + !message.OwnerEmail.EndsWith("@" + config.Domain, StringComparison.OrdinalIgnoreCase) && + store.FindUser(message.OwnerEmail) is null) + { + var localPart = message.OwnerEmail.Split('@')[0]; + var target = users.FirstOrDefault(x => x.Email.StartsWith(localPart + "@", StringComparison.OrdinalIgnoreCase))?.Email + ?? currentEmail; + message.OwnerEmail = target; + // 归属改了以后,发件人地址也一并按新域名改写,避免列表里显示成不存在的账号 + if (message.From.Equals(message.OwnerEmail, StringComparison.OrdinalIgnoreCase) == false && + message.From.Contains('@') && !message.From.Contains('@' + config.Domain, StringComparison.OrdinalIgnoreCase)) + { + // 仅当 From 就是旧账号本身时才改写 + } + reOwnered++; + } + } + + store.Persist(); + + Console.WriteLine($" 重新解析并修正:{reParsed} 封"); + Console.WriteLine($" 归属域名改写 :{reOwnered} 封"); + if (missingRaw > 0) Console.WriteLine($" 原始文件缺失 :{missingRaw} 封(仅修正索引字段)"); + Console.WriteLine("迁移完成。"); + return 0; + } +} diff --git a/server-native-v2/Mime.cs b/server-native-v2/Mime.cs new file mode 100644 index 0000000..45eb53b --- /dev/null +++ b/server-native-v2/Mime.cs @@ -0,0 +1,713 @@ +using System.Globalization; +using System.Text; + +namespace WpywMail.Native; + +/// 一个解析出来的附件。 +public sealed record ParsedAttachment(string FileName, string ContentType, byte[] Data, string ContentId, bool Inline); + +/// 解析结果。Text / Html 已经解码成可直接展示的字符串。 +public sealed record ParsedMime( + string From, + string To, + string Cc, + string Subject, + string MessageId, + string InReplyTo, + string References, + DateTimeOffset? Date, + string Text, + string Html, + IReadOnlyList Attachments); + +/// 待发送的附件。 +public sealed record OutgoingAttachment(string FileName, string ContentType, byte[] Data, string ContentId = "", bool Inline = false); + +/// 组装一封待发送邮件的入参。 +public sealed record ComposeRequest( + string From, + string? FromDisplay, + string[] To, + string[] Cc, + string Subject, + string Text, + string? Html = null, + IReadOnlyList? Attachments = null, + string? MessageId = null, + string InReplyTo = "", + string References = ""); + +/// +/// MIME 组装与解析。 +/// +/// 相比 v1 的关键修正: +/// 1. 组装:Message-ID 的域来自配置(不再写死),正文与附件一律 base64, +/// 非 ASCII 头一律 RFC 2047 编码并按 75 字符上限切分。 +/// 2. 解析:正确解码 RFC 2047 编码字、Content-Transfer-Encoding(base64/QP)、 +/// charset(含 GBK/GB18030),并支持 multipart 与附件。 +/// +public static class Mime +{ + private const string Crlf = "\r\n"; + + // ---------------------------------------------------------------- 解析 + + public static ParsedMime Parse(byte[] raw) + { + var (headers, body) = SplitMessage(raw); + return ParseEntity(headers, body); + } + + private static ParsedMime ParseEntity(List> headers, byte[] body) + { + string Header(string name) => headers.FirstOrDefault(h => h.Key.Equals(name, StringComparison.OrdinalIgnoreCase)).Value ?? ""; + + var from = AddressText(DecodeHeader(Header("From"))); + var to = AddressText(DecodeHeader(Header("To"))); + var cc = AddressText(DecodeHeader(Header("Cc"))); + var subject = DecodeHeader(Header("Subject")); + if (string.IsNullOrWhiteSpace(subject)) subject = "(无主题)"; + + var contentType = Header("Content-Type"); + var (mediaType, parameters) = ParseContentType(contentType); + var transferEncoding = Header("Content-Transfer-Encoding").Trim().ToLowerInvariant(); + + var text = new StringBuilder(); + var html = new StringBuilder(); + var attachments = new List(); + + if (mediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) && + parameters.TryGetValue("boundary", out var boundary) && !string.IsNullOrEmpty(boundary)) + { + foreach (var part in SplitMultipart(body, boundary)) + { + var (partHeaders, partBody) = SplitMessage(part); + var nested = ParseEntity(partHeaders, partBody); + // 嵌套的多部分(例如 mixed 里套 alternative) + if (nested.Text.Length > 0) text.Append(nested.Text); + if (nested.Html.Length > 0) html.Append(nested.Html); + attachments.AddRange(nested.Attachments); + } + } + else + { + var decoded = DecodeTransfer(body, transferEncoding); + var charset = parameters.TryGetValue("charset", out var cs) ? cs : null; + var content = DecodeCharset(decoded, charset); + + var isAttachment = parameters.TryGetValue("name", out var name) && !string.IsNullOrWhiteSpace(name); + var disposition = Header("Content-Disposition"); + var (dispType, dispParams) = ParseContentType(disposition); + if (dispType.Equals("attachment", StringComparison.OrdinalIgnoreCase) || + (dispType.Equals("inline", StringComparison.OrdinalIgnoreCase) && !mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))) + isAttachment = true; + + if (isAttachment) + { + var fileName = DecodeParameter(dispParams.GetValueOrDefault("filename*")) + ?? DecodeHeader(dispParams.GetValueOrDefault("filename") ?? "") + ?? DecodeParameter(parameters.GetValueOrDefault("name*")) + ?? DecodeHeader(parameters.GetValueOrDefault("name") ?? "") + ?? "attachment.bin"; + if (string.IsNullOrWhiteSpace(fileName)) fileName = "attachment.bin"; + attachments.Add(new ParsedAttachment( + fileName, + mediaType, + decoded, + Header("Content-ID").Trim('<', '>'), + dispType.Equals("inline", StringComparison.OrdinalIgnoreCase))); + } + else if (mediaType.Equals("text/html", StringComparison.OrdinalIgnoreCase)) + { + html.Append(content); + } + else + { + text.Append(content); + } + } + + return new ParsedMime( + from, to, cc, subject, + Header("Message-ID").Trim(), + Header("In-Reply-To").Trim(), + Header("References").Trim(), + ParseDate(Header("Date")), + text.ToString().TrimEnd(), + html.ToString().TrimEnd(), + attachments); + } + + private static DateTimeOffset? ParseDate(string value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (DateTimeOffset.TryParse(value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var parsed)) return parsed; + // 常见变体:缺秒、单数字日等 + var cleaned = value.Replace("GMT", "+0000").Replace("UT", "+0000").Trim(); + if (DateTimeOffset.TryParse(cleaned, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out parsed)) return parsed; + return null; + } + + /// 把原始字节切成「头(已展开)」与「正文」。 + internal static (List> Headers, byte[] Body) SplitMessage(byte[] raw) + { + var separator = IndexOf(raw, Crlf + Crlf); + var separatorLength = 4; + if (separator < 0) + { + separator = IndexOf(raw, "\n\n"); + separatorLength = 2; + } + + var headerBytes = separator >= 0 ? raw[..separator] : raw; + var body = separator >= 0 ? raw[(separator + separatorLength)..] : []; + var headerText = DecodeHeaderBlock(headerBytes); + + var headers = new List>(); + string? currentName = null; + var currentValue = new StringBuilder(); + void Flush() + { + if (currentName is not null) headers.Add(new KeyValuePair(currentName, currentValue.ToString().Trim())); + currentName = null; + currentValue.Clear(); + } + + foreach (var line in headerText.Split('\n')) + { + if (line.Length == 0) continue; + if ((line[0] == ' ' || line[0] == '\t') && currentName is not null) + { + currentValue.Append(' ').Append(line.Trim()); + continue; + } + Flush(); + var colon = line.IndexOf(':'); + if (colon <= 0) continue; + currentName = line[..colon].Trim(); + currentValue.Append(line[(colon + 1)..].Trim()); + } + Flush(); + return (headers, body); + } + + /// + /// 解码头部块。 + /// + /// 头部按标准应当是 ASCII(非 ASCII 必须用 RFC 2047 编码字),但现实中不少客户端 + /// 直接把裸 UTF-8 写进头里(8bit 头)。这里优先按 UTF-8 严格解码,失败再回退 + /// Latin-1(保证字节不丢);否则裸 UTF-8 的中文主题会变成 «æµè¯» 这种乱码。 + /// + private static string DecodeHeaderBlock(byte[] bytes) + { + try + { + return new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(bytes).Replace("\r\n", "\n").Replace('\r', '\n'); + } + catch (DecoderFallbackException) + { + return Encoding.Latin1.GetString(bytes).Replace("\r\n", "\n").Replace('\r', '\n'); + } + } + + /// 按 boundary 切分 multipart 正文。返回每个子部分的原始字节。 + private static List SplitMultipart(byte[] body, string boundary) + { + var parts = new List(); + var delimiter = Encoding.ASCII.GetBytes("--" + boundary); + var positions = new List(); + for (var i = 0; i + delimiter.Length <= body.Length; i++) + { + if (body[i] != (byte)'-') continue; + var match = true; + for (var j = 0; j < delimiter.Length; j++) + { + if (body[i + j] != delimiter[j]) { match = false; break; } + } + if (match) { positions.Add(i); i += delimiter.Length - 1; } + } + if (positions.Count < 2) return parts; + + for (var index = 0; index < positions.Count - 1; index++) + { + var start = positions[index]; + // 跳过边界行自身 + var lineEnd = IndexOf(body, start, "\n"); + if (lineEnd < 0) continue; + start = lineEnd + 1; + var end = positions[index + 1]; + // 去掉结尾的 CRLF + while (end > start && (body[end - 1] == (byte)'\n' || body[end - 1] == (byte)'\r')) end--; + if (end > start) parts.Add(body[start..end]); + } + return parts; + } + + /// 解码 Content-Transfer-Encoding。 + private static byte[] DecodeTransfer(byte[] body, string encoding) => encoding switch + { + "base64" => TryBase64(body), + "quoted-printable" => DecodeQuotedPrintable(body), + _ => body, // 7bit / 8bit / binary / 空 + }; + + private static byte[] TryBase64(byte[] body) + { + // 去掉所有空白字符后解码;容错处理不完整的 base64 + var buffer = new StringBuilder(body.Length); + foreach (var b in body) + { + if (b is (byte)' ' or (byte)'\r' or (byte)'\n' or (byte)'\t') continue; + if (b == (byte)'=') { buffer.Append('='); continue; } + buffer.Append((char)b); + } + var text = buffer.ToString(); + var padding = text.Length % 4; + if (padding == 1) text = text[..^1]; + else if (padding is 2 or 3) text += new string('=', 4 - padding); + try { return Convert.FromBase64String(text); } + catch (FormatException) { return []; } + } + + private static byte[] DecodeQuotedPrintable(byte[] body) + { + using var output = new MemoryStream(body.Length); + for (var i = 0; i < body.Length; i++) + { + var b = body[i]; + if (b != (byte)'=') { output.WriteByte(b); continue; } + + // 软换行 =CRLF / =LF + if (i + 1 < body.Length && body[i + 1] == (byte)'\n') { i += 1; continue; } + if (i + 2 < body.Length && body[i + 1] == (byte)'\r' && body[i + 2] == (byte)'\n') { i += 2; continue; } + if (i + 2 < body.Length && IsHex(body[i + 1]) && IsHex(body[i + 2])) + { + output.WriteByte((byte)((HexValue(body[i + 1]) << 4) | HexValue(body[i + 2]))); + i += 2; + continue; + } + output.WriteByte(b); + } + return output.ToArray(); + + static bool IsHex(byte b) => (b >= '0' && b <= '9') || (b >= 'A' && b <= 'F') || (b >= 'a' && b <= 'f'); + static int HexValue(byte b) => b switch + { + >= (byte)'0' and <= (byte)'9' => b - '0', + >= (byte)'A' and <= (byte)'F' => b - 'A' + 10, + _ => b - 'a' + 10, + }; + } + + // ---------------------------------------------------------------- 头编码 + + /// 解码 RFC 2047 编码字;相邻编码字之间的空白会被丢弃。 + public static string DecodeHeader(string value) + { + if (string.IsNullOrEmpty(value) || !value.Contains("=?")) return value ?? ""; + + var result = new StringBuilder(); + var index = 0; + var lastWasEncoded = false; + while (index < value.Length) + { + var start = value.IndexOf("=?", index, StringComparison.Ordinal); + if (start < 0) { result.Append(value, index, value.Length - index); break; } + + // 编码字之间的空格应被忽略 + var gap = value[index..start]; + if (!(lastWasEncoded && gap.Trim().Length == 0)) result.Append(gap); + else if (lastWasEncoded) { /* 丢弃 */ } + + var firstQuestion = value.IndexOf('?', start + 2); + var secondQuestion = firstQuestion < 0 ? -1 : value.IndexOf('?', firstQuestion + 1); + var end = secondQuestion < 0 ? -1 : value.IndexOf("?=", secondQuestion + 1, StringComparison.Ordinal); + if (firstQuestion < 0 || secondQuestion < 0 || end < 0) + { + result.Append(value, start, value.Length - start); + break; + } + + var charset = value[(start + 2)..firstQuestion]; + var encoding = value[(firstQuestion + 1)..secondQuestion]; + var payload = value[(secondQuestion + 1)..end]; + try + { + byte[] bytes; + if (encoding.Equals("B", StringComparison.OrdinalIgnoreCase)) + bytes = Convert.FromBase64String(payload); + else if (encoding.Equals("Q", StringComparison.OrdinalIgnoreCase)) + bytes = DecodeQ(payload); + else { result.Append(value, start, end + 2 - start); index = end + 2; lastWasEncoded = false; continue; } + + result.Append(DecodeCharset(bytes, charset)); + lastWasEncoded = true; + } + catch + { + result.Append(value, start, end + 2 - start); + lastWasEncoded = false; + } + index = end + 2; + } + return result.ToString(); + + static byte[] DecodeQ(string payload) + { + using var output = new MemoryStream(); + for (var i = 0; i < payload.Length; i++) + { + var c = payload[i]; + if (c == '_') { output.WriteByte((byte)' '); continue; } + if (c == '=' && i + 2 < payload.Length) + { + try { output.WriteByte(Convert.ToByte(payload.Substring(i + 1, 2), 16)); i += 2; continue; } + catch { } + } + output.WriteByte((byte)c); + } + return output.ToArray(); + } + } + + /// 解码 RFC 2231 参数值(形如 UTF-8''%E4%B8%AD%文)。 + private static string? DecodeParameter(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var text = value.Trim(); + var separator = text.IndexOf("''", StringComparison.Ordinal); + if (separator < 0) return null; + var charset = text[..separator]; + var encoded = text[(separator + 2)..]; + try + { + using var buffer = new MemoryStream(encoded.Length); + for (var i = 0; i < encoded.Length; i++) + { + if (encoded[i] == '%' && i + 2 < encoded.Length) + { + buffer.WriteByte(Convert.ToByte(encoded.Substring(i + 1, 2), 16)); + i += 2; + } + else buffer.WriteByte((byte)encoded[i]); + } + return DecodeCharset(buffer.ToArray(), charset); + } + catch { return null; } + } + + /// 对外暴露的 RFC 2047 编码入口(IMAP ENVELOPE 等需要把非 ASCII 头值变成 ASCII)。 + public static string EncodeHeaderValue(string value) => + EncodeHeader(value ?? "").Replace("\r\n", " ").Replace("\n", " "); + + /// 需要时把文本编码成 RFC 2047 编码字(按 75 字符上限切分)。 + private static string EncodeHeader(string value) + { + if (string.IsNullOrEmpty(value)) return ""; + if (value.All(c => c is >= ' ' and <= '~')) return value; + + var bytes = Encoding.UTF8.GetBytes(value); + var chunks = new List(); + var offset = 0; + while (offset < bytes.Length) + { + // 每个编码字最多 75 字符;"=?UTF-8?B?" + "?=" 占 12 个字符 → base64 最多 63 + var take = Math.Min(45, bytes.Length - offset); + // 不要把多字节字符切开 + while (take > 1 && offset + take < bytes.Length && (bytes[offset + take] & 0xC0) == 0x80) take--; + chunks.Add("=?UTF-8?B?" + Convert.ToBase64String(bytes, offset, take) + "?="); + offset += take; + } + return chunks.Count == 1 ? chunks[0] : string.Join(Crlf + " ", chunks); + } + + /// 解析地址列表,抽出纯地址。 + public static string[] Addresses(string value) + { + if (string.IsNullOrWhiteSpace(value)) return []; + var result = new List(); + var depth = 0; + var current = new StringBuilder(); + foreach (var c in value) + { + switch (c) + { + case '<': depth++; current.Append(c); break; + case '>': depth = Math.Max(0, depth - 1); current.Append(c); break; + case ',' or ';' when depth == 0: Add(); break; + default: current.Append(c); break; + } + } + Add(); + return result.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + void Add() + { + var item = current.ToString().Trim(); + current.Clear(); + if (item.Length == 0) return; + var start = item.IndexOf('<'); + var end = item.IndexOf('>', start + 1); + var address = start >= 0 && end > start ? item[(start + 1)..end].Trim() : item; + if (address.Contains('@') && address.IndexOf('@') > 0 && address.IndexOf('@') < address.Length - 1) result.Add(address); + } + } + + private static string AddressText(string value) => value ?? ""; + + internal static (string MediaType, Dictionary Parameters) ParseContentType(string value) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(value)) return ("text/plain", parameters); + var segments = value.Split(';'); + var mediaType = segments[0].Trim().ToLowerInvariant(); + for (var i = 1; i < segments.Length; i++) + { + var part = segments[i].Trim(); + var equals = part.IndexOf('='); + if (equals <= 0) continue; + var name = part[..equals].Trim().ToLowerInvariant(); + var val = part[(equals + 1)..].Trim().Trim('"'); + parameters[name] = val; + } + return (mediaType, parameters); + } + + /// 按 charset 解码字节;未知字符集回退 UTF-8。 + public static string DecodeCharset(byte[] bytes, string? charset) + { + if (bytes.Length == 0) return ""; + var encoding = ResolveEncoding(charset); + try { return encoding.GetString(bytes); } + catch { return Encoding.UTF8.GetString(bytes); } + } + + private static Encoding ResolveEncoding(string? charset) + { + if (string.IsNullOrWhiteSpace(charset)) return Encoding.UTF8; + var name = charset.Trim().Trim('"').ToLowerInvariant(); + if (name is "utf8" or "utf-8" or "unicode-1-1-utf-8") return new UTF8Encoding(false); + if (name is "us-ascii" or "ascii") return Encoding.ASCII; + if (name is "latin1" or "iso-8859-1" or "iso8859-1" or "windows-1252") return Encoding.Latin1; + try { return Encoding.GetEncoding(name); } + catch + { + if (name is "gb2312" or "gbk" or "gb18030" or "csgb2312" or "x-gbk") + { + var provider = CodePagesShim.Provider; + if (provider is not null) + { + try + { + Encoding.RegisterProvider(provider); + var codePage = name == "gb18030" ? 54936 : 936; + return Encoding.GetEncoding(codePage); + } + catch { } + } + } + AppLog.Warn($"[MIME] 不支持的字符集 {charset},按 UTF-8 处理。"); + return Encoding.UTF8; + } + } + + // ---------------------------------------------------------------- 组装 + + public static byte[] Build(ComposeRequest request, AppConfig config) + { + var messageId = string.IsNullOrWhiteSpace(request.MessageId) + ? $"<{Guid.NewGuid():N}@{config.Domain}>" + : (request.MessageId.StartsWith('<') ? request.MessageId : $"<{request.MessageId}>"); + + var attachments = request.Attachments ?? []; + var hasHtml = !string.IsNullOrWhiteSpace(request.Html); + var textPartBytes = Encoding.UTF8.GetBytes(request.Text ?? ""); + var htmlPartBytes = hasHtml ? Encoding.UTF8.GetBytes(request.Html!) : []; + + var headers = new StringBuilder(); + headers.Append("Date: ").Append(FormatDate(DateTimeOffset.UtcNow)).Append(Crlf); + headers.Append("From: ").Append(FormatAddress(request.From, request.FromDisplay)).Append(Crlf); + headers.Append("To: ").Append(string.Join(", ", request.To.Select(a => FormatAddress(a, null)))).Append(Crlf); + if (request.Cc.Length > 0) headers.Append("Cc: ").Append(string.Join(", ", request.Cc.Select(a => FormatAddress(a, null)))).Append(Crlf); + headers.Append("Subject: ").Append(EncodeHeader(request.Subject ?? "")).Append(Crlf); + headers.Append("Message-ID: ").Append(messageId).Append(Crlf); + if (!string.IsNullOrWhiteSpace(request.InReplyTo)) headers.Append("In-Reply-To: ").Append(request.InReplyTo.Trim()).Append(Crlf); + if (!string.IsNullOrWhiteSpace(request.References)) headers.Append("References: ").Append(request.References.Trim()).Append(Crlf); + headers.Append("MIME-Version: 1.0").Append(Crlf); + + var body = new MemoryStream(); + if (attachments.Count > 0) + { + var mixedBoundary = "mix-" + Guid.NewGuid().ToString("N")[..16]; + headers.Append("Content-Type: multipart/mixed; boundary=\"").Append(mixedBoundary).Append('"').Append(Crlf); + headers.Append(Crlf); + + WriteBoundary(body, mixedBoundary, false); + body.Write(hasHtml ? BuildAlternativePart(textPartBytes, htmlPartBytes) : BuildTextPart(textPartBytes)); + + foreach (var attachment in attachments) + { + WriteBoundary(body, mixedBoundary, false); + var part = new StringBuilder(); + part.Append("Content-Type: ").Append(attachment.ContentType).Append("; ") + .Append(FileNameParameters(attachment.FileName, "name")).Append(Crlf); + part.Append("Content-Transfer-Encoding: base64").Append(Crlf); + part.Append("Content-Disposition: ").Append(attachment.Inline ? "inline" : "attachment").Append("; ") + .Append(FileNameParameters(attachment.FileName, "filename")).Append(Crlf); + if (!string.IsNullOrWhiteSpace(attachment.ContentId)) + part.Append("Content-ID: <").Append(attachment.ContentId.Trim('<', '>')).Append('>').Append(Crlf); + part.Append(Crlf); + part.Append(WrapBase64(attachment.Data)); + body.Write(Encoding.ASCII.GetBytes(part.ToString())); + } + WriteBoundary(body, mixedBoundary, true); + } + else if (hasHtml) + { + var boundary = "alt-" + Guid.NewGuid().ToString("N")[..16]; + headers.Append("Content-Type: multipart/alternative; boundary=\"").Append(boundary).Append('"').Append(Crlf); + headers.Append(Crlf); + body.Write(BuildAlternativeWithBoundary(textPartBytes, htmlPartBytes, boundary)); + } + else + { + headers.Append("Content-Type: text/plain; charset=UTF-8").Append(Crlf); + headers.Append("Content-Transfer-Encoding: base64").Append(Crlf); + headers.Append(Crlf); + body.Write(Encoding.ASCII.GetBytes(WrapBase64(textPartBytes))); + } + + var output = new MemoryStream(); + output.Write(Encoding.ASCII.GetBytes(headers.ToString())); + body.Position = 0; + body.CopyTo(output); + return output.ToArray(); + } + + private static byte[] BuildTextPart(byte[] textBytes) + { + var part = new StringBuilder(); + part.Append("Content-Type: text/plain; charset=UTF-8").Append(Crlf); + part.Append("Content-Transfer-Encoding: base64").Append(Crlf); + part.Append(Crlf); + part.Append(WrapBase64(textBytes)); + return Encoding.ASCII.GetBytes(part.ToString()); + } + + /// 作为 multipart/mixed 的子部分时,必须带上自己的 Content-Type 头。 + private static byte[] BuildAlternativePart(byte[] textBytes, byte[] htmlBytes) + { + var boundary = "alt-" + Guid.NewGuid().ToString("N")[..16]; + var output = new MemoryStream(); + output.Write(Encoding.ASCII.GetBytes("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"" + Crlf + Crlf)); + output.Write(BuildAlternativeWithBoundary(textBytes, htmlBytes, boundary)); + return output.ToArray(); + } + + /// 附件名参数:ASCII 回退 + RFC 2231 扩展写法,保证中文文件名不乱码。 + private static string FileNameParameters(string fileName, string parameterName) + { + var ascii = new string(fileName.Select(c => c is >= ' ' and <= '~' && c != '"' && c != '\\' ? c : '_').ToArray()); + if (ascii.Length == 0) ascii = "attachment"; + return $"{parameterName}=\"{ascii}\"; {parameterName}*=UTF-8''{Uri.EscapeDataString(fileName)}"; + } + + private static byte[] BuildAlternativeWithBoundary(byte[] textBytes, byte[] htmlBytes, string boundary) + { + var output = new MemoryStream(); + WriteBoundary(output, boundary, false); + output.Write(BuildTextPart(textBytes)); + WriteBoundary(output, boundary, false); + var html = new StringBuilder(); + html.Append("Content-Type: text/html; charset=UTF-8").Append(Crlf); + html.Append("Content-Transfer-Encoding: base64").Append(Crlf); + html.Append(Crlf); + html.Append(WrapBase64(htmlBytes)); + output.Write(Encoding.ASCII.GetBytes(html.ToString())); + WriteBoundary(output, boundary, true); + return output.ToArray(); + } + + private static void WriteBoundary(Stream stream, string boundary, bool closing) + { + var line = "--" + boundary + (closing ? "--" : "") + Crlf; + stream.Write(Encoding.ASCII.GetBytes(line)); + } + + private static string FormatAddress(string address, string? display) + { + address = address.Trim().Trim('<', '>'); + if (string.IsNullOrWhiteSpace(display)) return address; + return $"{EncodeHeader(display)} <{address}>"; + } + + /// RFC 5322 日期(必须是 ±HHMM 形式的时区)。 + public static string FormatDate(DateTimeOffset value) + { + var offset = value.Offset; + var sign = offset < TimeSpan.Zero ? "-" : "+"; + var absolute = offset.Duration(); + return value.ToString("ddd, dd MMM yyyy HH:mm:ss ", CultureInfo.InvariantCulture) + + $"{sign}{absolute.Hours:D2}{absolute.Minutes:D2}"; + } + + private static string WrapBase64(byte[] bytes) + { + var encoded = Convert.ToBase64String(bytes); + var builder = new StringBuilder(encoded.Length + encoded.Length / 76 * 2 + 2); + for (var index = 0; index < encoded.Length; index += 76) + { + builder.Append(encoded, index, Math.Min(76, encoded.Length - index)).Append(Crlf); + } + return builder.ToString(); + } + + // ---------------------------------------------------------------- 工具 + + private static int IndexOf(byte[] data, string text) => IndexOf(data, 0, text); + + private static int IndexOf(byte[] data, int start, string text) + { + var pattern = Encoding.ASCII.GetBytes(text); + for (var i = start; i + pattern.Length <= data.Length; i++) + { + var match = true; + for (var j = 0; j < pattern.Length; j++) + { + if (data[i + j] != pattern[j]) { match = false; break; } + } + if (match) return i; + } + return -1; + } +} + +/// +/// 可选注册 .NET 的代码页编码提供程序(用于 GBK/GB18030)。 +/// 未引用 System.Text.Encoding.CodePages 包时静默降级为 UTF-8。 +/// +internal static class CodePagesShim +{ + private static EncodingProvider? provider; + private static bool resolved; + + public static EncodingProvider? Provider + { + get + { + if (!resolved) + { + resolved = true; + try + { + var type = Type.GetType("System.Text.CodePagesEncodingProvider, System.Text.Encoding.CodePages", throwOnError: false); + provider = type?.GetProperty("Instance", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)?.GetValue(null) as EncodingProvider; + if (provider is not null) AppLog.Info("[MIME] 已启用代码页编码支持(GBK/GB18030 可正常解码)。"); + else AppLog.Warn("[MIME] 未找到代码页编码支持;GBK/GB2312 编码的中文邮件可能解码异常。"); + } + catch { provider = null; } + } + return provider; + } + } +} diff --git a/server-native-v2/Models.cs b/server-native-v2/Models.cs new file mode 100644 index 0000000..7fb1fe3 --- /dev/null +++ b/server-native-v2/Models.cs @@ -0,0 +1,409 @@ +using System.Text.Json.Serialization; + +namespace WpywMail.Native; + +/// 应用配置。对应 appsettings.json 的根对象。 +public sealed class AppConfig +{ + public string Domain { get; set; } = ""; + public string Hostname { get; set; } = ""; + public string HttpPrefix { get; set; } = "http://127.0.0.1:8787/"; + public int SmtpPort { get; set; } = 25; + public int SubmissionPort { get; set; } = 587; + public string DataDirectory { get; set; } = ""; + public string AdminEmail { get; set; } = ""; + public string AdminPassword { get; set; } = ""; + public string TlsCertificatePath { get; set; } = ""; + public string TlsCertificatePassword { get; set; } = ""; + + /// direct = 按 MX 直接投递;relay = 走上游 SMTP 中继。 + public string DeliveryMode { get; set; } = "direct"; + + public DirectDeliveryConfig DirectDelivery { get; set; } = new(); + public RelayConfig Relay { get; set; } = new(); + public RetryConfig Retry { get; set; } = new(); + public DkimConfig Dkim { get; set; } = new(); + public ApiConfig Api { get; set; } = new(); + public ImapConfig Imap { get; set; } = new(); + public SmtpConfig Smtp { get; set; } = new(); + public StorageConfig Storage { get; set; } = new(); + public AccountsConfig Accounts { get; set; } = new(); + public InboundAuthConfig InboundAuth { get; set; } = new(); + + /// 启动时做基本校验,尽早暴露配置错误。 + public void Validate() + { + if (string.IsNullOrWhiteSpace(Domain)) throw new InvalidOperationException("appsettings.json 必须设置 Domain。"); + if (string.IsNullOrWhiteSpace(Hostname)) throw new InvalidOperationException("appsettings.json 必须设置 Hostname。"); + if (string.IsNullOrWhiteSpace(AdminEmail) || !AdminEmail.Contains('@')) throw new InvalidOperationException("AdminEmail 必须是完整的邮箱地址。"); + if (!AdminEmail.EndsWith("@" + Domain, StringComparison.OrdinalIgnoreCase)) + AppLog.Warn($"[配置] AdminEmail({AdminEmail})不在 Domain({Domain})之下,请确认这是有意的。"); + if (AdminPassword.Length < 12) throw new InvalidOperationException("请在 appsettings.json 设置至少 12 位 AdminPassword。"); + if (AdminPassword.Contains("replace-with", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AdminPassword 还是示例值,请改成真实密码。"); + if (string.IsNullOrWhiteSpace(DataDirectory)) throw new InvalidOperationException("appsettings.json 必须设置 DataDirectory。"); + if (!DeliveryMode.Equals("direct", StringComparison.OrdinalIgnoreCase) && !DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("DeliveryMode 只能是 direct 或 relay。"); + if (DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(Relay.Host)) + throw new InvalidOperationException("DeliveryMode=relay 时必须设置 Relay.Host。"); + if (SmtpPort is < 1 or > 65535 || SubmissionPort is < 1 or > 65535) throw new InvalidOperationException("SMTP 端口配置非法。"); + if (SmtpPort == SubmissionPort) throw new InvalidOperationException("SmtpPort 与 SubmissionPort 不能相同。"); + if (!Storage.Provider.Equals("json", StringComparison.OrdinalIgnoreCase) && + !Storage.Provider.Equals("sqlite", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Storage.Provider 只能是 json 或 sqlite。"); + + var registration = (Accounts.Registration ?? "").Trim().ToLowerInvariant(); + if (registration is not ("open" or "invite" or "closed")) + throw new InvalidOperationException("Accounts.Registration 只能是 open / invite / closed。"); + if (registration == "invite" && string.IsNullOrWhiteSpace(Accounts.InviteCode)) + throw new InvalidOperationException("Accounts.Registration=invite 时必须设置 Accounts.InviteCode。"); + if (Accounts.MinPasswordLength < 8) + throw new InvalidOperationException("Accounts.MinPasswordLength 不能小于 8。"); + if (Accounts.CodeMinutes < 1 || Accounts.CodeMinutes > 24 * 60) + throw new InvalidOperationException("Accounts.CodeMinutes 应在 1..1440 之间。"); + } +} + +/// +/// 账号体系配置:自助注册策略、邮箱验证、密码强度、登录锁定、限流。 +/// +/// 默认值刻意偏保守:**注册默认 invite(需要邀请码)**,且注册的邮箱域名默认只允许 +/// 服务器自己的 Domain —— 公网上的邮件服务器一旦开放注册,很快就会变成垃圾邮件跳板。 +/// 要真正开放,请显式改 Registration=open 并配置 AllowedDomains。 +/// +public sealed class AccountsConfig +{ + /// open = 任何人可注册;invite = 需要邀请码;closed = 关闭注册(只能管理员建号)。 + public string Registration { get; set; } = "invite"; + + /// invite 模式下的邀请码。 + public string InviteCode { get; set; } = ""; + + /// 允许注册的邮箱域名(含服务器自身域名)。留空表示只允许 Domain。 + public string[] AllowedDomains { get; set; } = []; + + /// 注册后是否必须用邮箱里的验证码激活(强烈建议 true)。 + public bool RequireEmailVerification { get; set; } = true; + + /// 密码最小长度(同时会检查:不能是纯数字、不能与邮箱相同)。 + public int MinPasswordLength { get; set; } = 12; + + /// 验证码有效期(分钟)。 + public int CodeMinutes { get; set; } = 30; + + /// 同一个验证码最多尝试几次(超过即作废,需重新获取)。 + public int MaxCodeAttempts { get; set; } = 5; + + /// 同一账号在窗口期内连续登录失败多少次后锁定。 + public int MaxLoginFailures { get; set; } = 8; + + /// 登录失败统计窗口与锁定时长(分钟)。 + public int LockoutMinutes { get; set; } = 15; + + /// 同一 IP 每小时最多发起几次注册 / 重发验证码(防刷)。 + public int RegisterPerHourPerIp { get; set; } = 5; + + /// 同一邮箱每小时最多重发几次验证码。 + public int ResendPerHourPerEmail { get; set; } = 5; + + /// 审计日志最多保留多少条(超出后按时间淘汰)。 + public int AuditLimit { get; set; } = 2000; + + /// 把配置里的域名规则解析成实际允许的域名集合。 + public string[] EffectiveDomains(string serverDomain) + { + var list = AllowedDomains + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim().TrimStart('@').ToLowerInvariant()) + .ToList(); + if (list.Count == 0 && !string.IsNullOrWhiteSpace(serverDomain)) + list.Add(serverDomain.Trim().TrimStart('@').ToLowerInvariant()); + return [.. list.Distinct()]; + } +} + +/// +/// 存储后端选择。 +/// +/// - json :v2.0.x 的原始实现,users/messages/queue/sessions 各一个 JSON 文件, +/// **任何一次改动都会整文件重写**,随邮件量增长呈 O(N) 放大。 +/// - sqlite :SQLite 单文件数据库(元数据 + 索引 + 事务),原始报文仍落在 raw/ 目录。 +/// 默认值,也是推荐值;改回 json 即可一键回滚(两套数据互不覆盖)。 +/// +public sealed class StorageConfig +{ + public string Provider { get; set; } = "sqlite"; + + /// SQLite 数据库文件路径;留空则用 DataDirectory/wpywmail.db。 + public string DatabasePath { get; set; } = ""; + + /// WAL 模式下定期检查点阈值(页数),0 表示交给 SQLite 默认策略。 + public int WalAutoCheckpointPages { get; set; } + + /// + /// 是否为正文建立 FTS5(trigram)全文索引。 + /// 打开后搜索从「全表 LIKE 扫描」变成索引命中,代价是**索引本身会额外占用接近正文大小的磁盘** + /// (trigram 索引通常与正文同量级)。默认关闭,因为本机磁盘偏紧、而 LIKE 在数千封量级仍是毫秒级。 + /// + public bool FullTextSearch { get; set; } +} + +public sealed class DirectDeliveryConfig +{ + public int ConnectionTimeoutSeconds { get; set; } = 30; + public int CommandTimeoutSeconds { get; set; } = 30; + public int DnsTimeoutSeconds { get; set; } = 5; + public bool OpportunisticStartTls { get; set; } = true; + public bool RequireStartTls { get; set; } + public string DnsServer { get; set; } = ""; + /// 投递时使用的 HELO 名称,留空则用 Hostname。 + public string HeloName { get; set; } = ""; +} + +public sealed class RelayConfig +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = 587; + public string User { get; set; } = ""; + public string Password { get; set; } = ""; + public bool EnableSsl { get; set; } = true; +} + +/// 失败重投策略。4xx(临时)与 5xx(永久)分开处理。 +public sealed class RetryConfig +{ + public int MaxAttempts { get; set; } = 12; + public int InitialDelaySeconds { get; set; } = 60; + public int MaxDelaySeconds { get; set; } = 3600; + /// 5xx 默认也重试若干次:封锁/策略类 5xx 往往是临时的。 + public bool RetryOnPermanentFailure { get; set; } = true; + public int MaxAttemptsForPermanent { get; set; } = 3; + /// 彻底失败时给发件人投递退信(NDR)。 + public bool SendBounceNotification { get; set; } = true; +} + +/// DKIM 签名配置。私钥不存在时会自动生成并打印需要配置的 DNS 记录。 +public sealed class DkimConfig +{ + public bool Enabled { get; set; } + public string Selector { get; set; } = "mail"; + /// 留空则用 Domain。 + public string SigningDomain { get; set; } = ""; + /// 留空则放在 DataDirectory/dkim/<selector>.private.pem。 + public string PrivateKeyPath { get; set; } = ""; + public string[] Headers { get; set; } = + ["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type", "Content-Transfer-Encoding"]; +} + +/// +/// 入站邮件身份校验(SPF / DKIM / DMARC)与垃圾邮件判定。 +/// +/// 默认策略:**标注 + 投垃圾箱,不拒收** —— 校验实现自身也可能有 bug,拒收不可逆, +/// 投进垃圾箱可逆。要严格拒收把 打开。 +/// +public sealed class InboundAuthConfig +{ + public bool Enabled { get; set; } = true; + /// 是否往报文里写 Authentication-Results / X-Spam-Score 头(标准做法,保留证据)。 + public bool AddAuthenticationResults { get; set; } = true; + /// 判定为垃圾时投进 spam 文件夹而不是收件箱。 + public bool SpamFolderOnFail { get; set; } = true; + /// DMARC p=reject 且校验失败时直接在 SMTP 阶段 550 拒收。默认关(怕误杀)。 + public bool RejectOnDmarcReject { get; set; } = false; + /// DKIM 验签(含 DNS 取公钥)开关;关掉只做 SPF/DMARC 的 SPF 部分。 + public bool VerifyDkim { get; set; } = true; + /// 判为垃圾的分数阈值(DMARC 失败固定 +4)。 + public int SpamScoreThreshold { get; set; } = 3; + public int DnsTimeoutSeconds { get; set; } = 5; + /// SPF 的 DNS 查询次数上限(RFC 7208 规定 10)。 + public int MaxSpfLookups { get; set; } = 10; +} + +public sealed class ApiConfig +{ + public int SessionDays { get; set; } = 30; + /// 允许的跨域来源;默认 * 便于本机客户端调试,公网使用建议收紧。 + public string CorsOrigin { get; set; } = "*"; + /// 推送新邮件的长轮询上限(秒)。 + public int LongPollSeconds { get; set; } = 25; + /// + /// 可选的公网 HTTPS 前缀(例如 https://mail.example.com:9443/),只为客户端在公网 + /// 自助注册 / 找回密码 / 管理会话资料而开。**只放行账号类接口**,邮件读写与管理接口不在这里暴露。 + /// 留空 = 不开(默认)。HTTPS 前缀必须先绑定证书:netsh http add sslcert hostnameport=mail.example.com:9443 ... + /// + public string PublicPrefix { get; set; } = ""; +} + +/// IMAP 服务配置(让标准邮件客户端也能接入)。 +public sealed class ImapConfig +{ + public bool Enabled { get; set; } = true; + /// 143:明文 + STARTTLS。 + public int Port { get; set; } = 143; + /// 993:隐式 TLS。设为 0 表示不监听。 + public int TlsPort { get; set; } = 993; + /// 是否要求先建立 TLS 才允许 LOGIN(推荐 true)。 + public bool RequireTlsForLogin { get; set; } = true; + /// 允许未加密登录的来源地址(默认仅本机,便于自检/调试)。 + public string[] PlaintextLoginAllowFrom { get; set; } = ["127.0.0.1", "::1"]; +} + +public sealed class SmtpConfig +{ + /// 单封邮件最大字节数。 + public int MaxMessageBytes { get; set; } = 25 * 1024 * 1024; + /// 是否始终广告 STARTTLS(只要加载到证书就广告,含自签名)。 + public bool AdvertiseStartTls { get; set; } = true; + /// 25 端口也允许 AUTH(默认否;587 端口始终允许)。 + public bool AllowAuthOnInbound { get; set; } + /// 给收到的邮件补 Received 头。 + public bool AddReceivedHeader { get; set; } = true; + /// 同一 IP 连续认证失败多少次后临时封禁。 + public int AuthFailuresBeforeBan { get; set; } = 8; + public int BanMinutes { get; set; } = 15; + /// 已认证用户是否必须使用自己的地址作为发件人。 + public bool EnforceSenderMatch { get; set; } = true; +} + +public sealed class MailUser +{ + public string Email { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public string PasswordSalt { get; set; } = ""; + public bool Active { get; set; } = true; + public string Role { get; set; } = "user"; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastLoginAt { get; set; } +} + +public sealed class Attachment +{ + public string FileName { get; set; } = ""; + public string ContentType { get; set; } = "application/octet-stream"; + public long Size { get; set; } + /// 相对 DataDirectory 的存储路径,例如 attachments/xxx.bin。 + public string StoredAs { get; set; } = ""; + public string ContentId { get; set; } = ""; + public bool Inline { get; set; } +} + +public sealed class MailMessage +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string OwnerEmail { get; set; } = ""; + /// inbox / sent / drafts / archive / trash / spam + public string Folder { get; set; } = "inbox"; + public string From { get; set; } = ""; + public string To { get; set; } = ""; + public string Cc { get; set; } = ""; + public string Subject { get; set; } = "(无主题)"; + public string Text { get; set; } = ""; + public string Html { get; set; } = ""; + public string RawPath { get; set; } = ""; + public string MessageId { get; set; } = ""; + public string InReplyTo { get; set; } = ""; + public string References { get; set; } = ""; + public DateTimeOffset Date { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ReceivedAt { get; set; } = DateTimeOffset.UtcNow; + public bool Unread { get; set; } = true; + public bool Starred { get; set; } + /// IMAP UID:在同一文件夹内单调递增且稳定,首次入库时分配。 + public int Uid { get; set; } + /// received / queued / sent / failed + public string DeliveryStatus { get; set; } = "received"; + public string LastError { get; set; } = ""; + public long Size { get; set; } + public List Attachments { get; set; } = []; + public bool HasAttachments => Attachments.Count > 0; + /// DKIM 是否签名成功(发件侧)。 + public bool DkimSigned { get; set; } +} + +public sealed class QueueItem +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string MessageId { get; set; } = ""; + public string OwnerEmail { get; set; } = ""; + public string[] Recipients { get; set; } = []; + public int Attempts { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset NextAttempt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastAttemptAt { get; set; } + /// pending / processing / retry / sent / failed + public string Status { get; set; } = "pending"; + public string LastError { get; set; } = ""; + public int LastCode { get; set; } +} + +public sealed class SessionRecord +{ + public string Token { get; set; } = ""; + public string Email { get; set; } = ""; + public DateTimeOffset Expires { get; set; } = DateTimeOffset.UtcNow.AddDays(30); + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed record LoginRequest(string Email, string Password); + +// ─────────────────────────────────────────────────────────── 账号体系 + +/// +/// 邮箱验证码(注册激活 / 密码重置共用一个表)。 +/// +/// 只存**验证码的哈希**,不存明文 —— 数据库被人拿到也不能直接拿来激活账号或改密码。 +/// 注册场景下,密码的哈希与显示名先暂存在 Payload 里,验证通过后才真正建号, +/// 这样「未验证的注册」不会在用户表里留下垃圾数据。 +/// +public sealed class VerificationCode +{ + public string Email { get; set; } = ""; + /// register = 注册激活;reset = 重置密码。 + public string Purpose { get; set; } = "register"; + public string CodeHash { get; set; } = ""; + public string Salt { get; set; } = ""; + /// register 时是 JSON:{ displayName, passwordHash, passwordSalt }。 + public string Payload { get; set; } = ""; + public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddMinutes(30); + public int Attempts { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? SentAt { get; set; } +} + +/// +/// 认证事件审计:登录成功/失败、注册、验证码发送与校验、密码重置、会话吊销。 +/// 用途有三个:排查问题、登录锁定判定、按 IP/邮箱做限流。 +/// +public sealed class AuthEvent +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Email { get; set; } = ""; + public string Ip { get; set; } = ""; + /// login-ok / login-failed / login-locked / register / register-verify / code-sent / reset-ok / session-revoked + public string Reason { get; set; } = ""; + public bool Success { get; set; } + public string Detail { get; set; } = ""; + public string UserAgent { get; set; } = ""; + public DateTimeOffset At { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed record RegisterRequest(string Email, string Password, string? DisplayName = null, string? InviteCode = null); + +public sealed record VerifyCodeRequest(string Email, string Code); + +public sealed record ResetPasswordRequest(string Email, string Code, string Password); + +public sealed record ProfileRequest(string? DisplayName = null); + +/// 附件上传:内容用 base64 传递。 +public sealed record AttachmentRequest(string FileName, string ContentType, string Base64); + +public sealed record SendRequest( + string To, + string Subject, + string? Text, + string? Html = null, + string? Cc = null, + string? InReplyTo = null, + List? Attachments = null); + +public sealed record DraftRequest(string To, string Subject, string Text); diff --git a/server-native-v2/Program.cs b/server-native-v2/Program.cs new file mode 100644 index 0000000..41a0be2 --- /dev/null +++ b/server-native-v2/Program.cs @@ -0,0 +1,249 @@ +using System.Text; + +namespace WpywMail.Native; + +public static class Program +{ + public static async Task Main(string[] args) + { + if (args.Contains("--version")) + { + Console.WriteLine($"{BuildInfo.Product} {BuildInfo.Version}"); + return 0; + } + if (args.Contains("--selftest")) + { + return SelfTest.Run(); + } + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(settingsPath)) + { + Console.Error.WriteLine($"未找到 {settingsPath},请复制 appsettings.example.json 并填写。"); + return 2; + } + + AppConfig config; + try + { + config = System.Text.Json.JsonSerializer.Deserialize( + await File.ReadAllTextAsync(settingsPath), + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)) + ?? throw new InvalidOperationException("appsettings.json 解析结果为空。"); + config.Validate(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"配置错误:{ex.Message}"); + return 3; + } + + if (args.Contains("--check-config")) + { + Console.WriteLine("配置校验通过。"); + Console.WriteLine($" 域名 :{config.Domain}"); + Console.WriteLine($" 主机名 :{config.Hostname}"); + Console.WriteLine($" 管理员 :{config.AdminEmail}"); + Console.WriteLine($" 数据目录 :{config.DataDirectory}"); + Console.WriteLine($" 收信/发信 :{config.SmtpPort} / {config.SubmissionPort}"); + Console.WriteLine($" 投递模式 :{config.DeliveryMode}"); + Console.WriteLine($" 存储后端 :{config.Storage.Provider}" + + (config.Storage.Provider.Equals("sqlite", StringComparison.OrdinalIgnoreCase) + ? $"({Path.Combine(config.DataDirectory, "wpywmail.db")})" : "(JSON 文件)")); + Console.WriteLine($" DKIM :{(config.Dkim.Enabled ? $"启用(选择器 {config.Dkim.Selector})" : "未启用")}"); + + // 账号策略:这里必须把「本机托管地址免验证」的原因写清楚 —— + // 否则运维看到 RequireEmailVerification=true 却收不到验证码会一头雾水。 + var acc = config.Accounts; + var domains = acc.EffectiveDomains(config.Domain); + var allHosted = domains.Length > 0 + && domains.All(d => d.Equals(config.Domain, StringComparison.OrdinalIgnoreCase)); + var svc = AccountService.IsHostedDomain; + var hostedDomains = domains.Where(d => svc("x@" + d, config.Domain, config.Hostname)).ToArray(); + Console.WriteLine($" 自助注册 :{acc.Registration}" + + (acc.Registration.Equals("invite", StringComparison.OrdinalIgnoreCase) ? "(需邀请码)" : "") + + $",允许域名:{string.Join(" / ", domains)}"); + Console.WriteLine($" 邮箱验证 :{(acc.RequireEmailVerification ? "要求" : "不要求")}" + + (acc.RequireEmailVerification && allHosted + ? " —— 【注意】允许的域名都由本机托管:验证码邮件投进的正是「验证通过前登录不了」的信箱," + + "已对这些地址自动跳过验证(授权凭据是邀请码)。要让邮箱验证生效,请把 AllowedDomains 改成托管在别处的域名。" + : acc.RequireEmailVerification && hostedDomains.Length > 0 + ? $" —— 其中 {string.Join(" / ", hostedDomains)} 由本机托管,这些地址注册时免验证码直接开通" + : "")); + return 0; + } + + // 机器可读地输出 DKIM 公钥记录,供部署脚本写入 DNS。 + // 这一模式必须保持 stdout 干净(只输出 KEY=VALUE),因此关闭控制台日志。 + if (args.Contains("--dkim-dns")) + { + AppLog.Configure(config, enableConsole: false); + var dnsSigner = DkimSigner.Create(config); + if (dnsSigner is null) + { + Console.Error.WriteLine("DKIM 未启用或初始化失败(检查 Dkim.Enabled)。"); + return 4; + } + Console.WriteLine("NAME=" + dnsSigner.RecordName); + Console.WriteLine("VALUE=" + dnsSigner.RecordValue); + Console.WriteLine("DMARC_NAME=_dmarc." + config.Domain); + Console.WriteLine("DMARC_VALUE=v=DMARC1; p=none; rua=mailto:" + config.AdminEmail); + return 0; + } + + AppLog.Configure(config); + + // 彻底删除账号(管理员维护命令)。会先逐封永久删除该账号的邮件(顺带回收大对象), + // 再删用户行、会话、验证码与出站队列;审计保留,并写一条 account-purged。 + if (args.Contains("--purge-user")) + { + var index = Array.IndexOf(args, "--purge-user"); + var target = index >= 0 && index + 1 < args.Length ? args[index + 1] : ""; + if (string.IsNullOrWhiteSpace(target) || !target.Contains('@')) + { + Console.Error.WriteLine("用法:--purge-user (例如 --purge-user spam@wpy.email)"); + return 2; + } + using var purgeStore = CreateStore(config); + var existing = purgeStore.FindUserAnyState(target); + if (existing is null) + { + Console.Error.WriteLine($"找不到账号:{target}"); + return 1; + } + var mails = purgeStore.ListMessages(existing.Email, "", ""); + foreach (var mail in mails) purgeStore.DeleteMessage(existing.Email, mail.Id, permanent: true); + var removed = purgeStore.DeleteUser(existing.Email); + purgeStore.RecordAuthEvent(new AuthEvent + { + Email = existing.Email, + Ip = "local", + Reason = "account-purged", + Success = true, + Detail = $"邮件 {mails.Count} 封已永久删除(active={existing.Active},命令 --purge-user)", + }); + purgeStore.Persist(); + Console.WriteLine(removed + ? $"已彻底删除账号 {existing.Email}:邮件 {mails.Count} 封、会话/验证码/队列一并清除(审计保留)" + : $"删除失败:{target}"); + return removed ? 0 : 1; + } + + // 对**已收到的真实邮件**做 DKIM 验签(走真实 DNS 取发件域公钥)。 + // 用途:① 检验验签实现是否真的对(真邮件是外部签名器签的,自己造的签名骗不过它); + // ② 运维排查「这封信到底是不是伪造的」。SPF 需要收信当时的来源 IP,历史邮件没有,故只做 DKIM。 + if (args.Contains("--verify-inbound")) + { + var index = Array.IndexOf(args, "--verify-inbound"); + var limit = index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var parsed) ? parsed : 25; + using var verifyStore = CreateStore(config); + var dns = new UdpDnsLookup(config); + var all = verifyStore.AllMessages().OrderByDescending(m => m.ReceivedAt > m.Date ? m.ReceivedAt : m.Date).Take(Math.Max(1, limit)).ToList(); + Console.WriteLine($"检查最近 {all.Count} 封已收邮件的 DKIM 签名(真实 DNS):"); + int signed = 0, passed = 0, failed = 0, unsigned = 0; + foreach (var message in all) + { + byte[] raw; + try { raw = verifyStore.ReadRaw(message.RawPath); } + catch { continue; } + var results = DkimVerifier.VerifyAllAsync(raw, dns, CancellationToken.None).GetAwaiter().GetResult(); + if (results.Count == 0) { unsigned++; continue; } + signed++; + var ok = results.Any(r => r.Outcome == "pass"); + if (ok) passed++; else failed++; + var head = $"[{(ok ? "通过" : results.Any(r => r.Outcome == "temperror") ? "查询失败" : "不通过")}]"; + Console.WriteLine($"{head} {message.From,-38} {message.Subject[..Math.Min(38, message.Subject.Length)]}"); + foreach (var r in results) + Console.WriteLine($" d={r.Domain} s={r.Selector} → {r.Outcome}:{r.Detail}"); + } + Console.WriteLine(); + Console.WriteLine($"合计:带签名 {signed} 封(通过 {passed} / 不通过 {failed}),无签名 {unsigned} 封。"); + return failed == 0 ? 0 : 1; + } + + if (args.Contains("--migrate")) { + using var migrationStore = new FileStore(config); + return Migration.Run(config, migrationStore); + } + + // ---------------- 存储后端维护命令 ---------------- + if (args.Contains("--migrate-to-sqlite")) + return StorageMigration.ToSqlite(config, deleteSourceFiles: args.Contains("--delete-source")); + + if (args.Contains("--migrate-to-json")) + return StorageMigration.ToJson(config); + + if (args.Contains("--storage-status")) + return StorageMigration.Status(config); + + if (args.Contains("--compact")) + return StorageMigration.Compact(config); + + if (args.Contains("--vacuum")) + return StorageMigration.Vacuum(config); + + if (args.Contains("--bench-store")) + { + var count = args.Select(a => int.TryParse(a, out var n) ? n : 0).FirstOrDefault(n => n > 0); + return StorageBench.Run(config, count > 0 ? count : 400, mutationSamples: 150); + } + + // 任何未捕获异常都记录后再退出,避免静默消失 + AppDomain.CurrentDomain.UnhandledException += (_, e) => + AppLog.Error($"[致命] 未处理异常:{(e.ExceptionObject as Exception)?.Message ?? e.ExceptionObject?.ToString()}"); + TaskScheduler.UnobservedTaskException += (_, e) => + { + AppLog.Error($"[致命] 未观察的任务异常:{e.Exception.Message}"); + e.SetObserved(); + }; + + var store = CreateStore(config); + var signer = DkimSigner.Create(config); + + using var cancellation = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + AppLog.Info("收到退出信号,正在停止……"); + cancellation.Cancel(); + }; + + AppLog.Info($"中文邮箱服务正在启动,域名:{config.Domain},主机名:{config.Hostname},版本:{BuildInfo.Version}"); + if (!config.Dkim.Enabled) + AppLog.Warn("[DKIM] 未启用签名。建议开启并配置 DNS 记录,否则外发邮件容易被判为垃圾邮件。"); + + var api = new ApiServer(config, store); + var smtp = new SmtpServer(config, store); + var queue = new DeliveryQueue(config, store, signer); + var imap = new ImapServer(config, store); + + if (config.Imap.Enabled) + AppLog.Info($"[IMAP] 已启用:明文/STARTTLS 端口 {config.Imap.Port},隐式 TLS 端口 {config.Imap.TlsPort}," + + $"登录要求 TLS={config.Imap.RequireTlsForLogin}"); + + try + { + await Task.WhenAll( + api.RunAsync(cancellation.Token), + smtp.RunAsync(cancellation.Token), + queue.RunAsync(cancellation.Token), + imap.RunAsync(cancellation.Token)); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + AppLog.Error($"[致命] 服务异常退出:{ex}"); + return 1; + } + + AppLog.Info("服务已停止。"); + store.Dispose(); + return 0; + } + + /// 按 Storage.Provider 创建存储后端。sqlite 为默认。 + public static IMailStore CreateStore(AppConfig config) => + config.Storage.Provider.Equals("json", StringComparison.OrdinalIgnoreCase) + ? new FileStore(config) + : new SqliteStore(config); +} diff --git a/server-native-v2/README.md b/server-native-v2/README.md new file mode 100644 index 0000000..e3e985a --- /dev/null +++ b/server-native-v2/README.md @@ -0,0 +1,556 @@ +# WpywMail.Native v2 + +自建中文邮件服务(Windows / .NET 8,单文件进程,零外部依赖)。 +本目录是 v2 重写版本;v1 原样保留在 `../server-native`,部署现场备份在 `C:\Program Files\WpywMail.v1-backup`。 + +--- + +## 1. 它做什么 + +``` + ┌──────────────── WpywMail.Native.exe ────────────────┐ + 公网 25 ─────────►│ SmtpServer 收信(只收本地收件人,绝不中继) │ + 客户端 587 ──────►│ SmtpServer 发信(STARTTLS + AUTH 后提交) │ + Webmail/客户端 ──►│ ApiServer REST + 长轮询(默认仅监听 127.0.0.1) │ + │ DeliveryQueue 出站队列:DKIM 签名 → MX 直投/中继 │ + │ FileStore users/messages/queue/sessions + raw/ │ + └────────────────────────────────────────────────────┘ +``` + +| 文件 | 职责 | +|---|---| +| `Program.cs` | 入口、配置校验、启动/停止、`--selftest` / `--check-config` / `--migrate` | +| `Models.cs` | 配置与数据模型 | +| `Mime.cs` | MIME 组装与解析(RFC 5322 / 2047 / 2231、base64、QP、multipart、附件、字符集) | +| `Dkim.cs` | DKIM 签名(RFC 6376,rsa-sha256,relaxed/relaxed,自动生成密钥并打印 DNS 记录) | +| `SmtpServer.cs` | 25 收信 / 587 提交服务端 | +| `SmtpReader.cs` | 字节级 SMTP 行读取器(命令与 DATA 共用缓冲区,保证 8bit 正文不被破坏) | +| `SmtpDataEncoder.cs` | DATA 段编码/还原(行尾规范化、dot-stuffing),与 DKIM 签名顺序严格配合 | +| `DirectSmtpDelivery.cs` | 出站投递(MX 直投 / relay 中继)+ 极简 DNS MX 解析 | +| `DeliveryQueue.cs` | 出站队列:重试、退避、退信通知 | +| `FileStore.cs` | 文件存储与全部状态变更 | +| `ApiServer.cs` | REST API(Webmail 与桌面客户端共用) | +| `Migration.cs` | 从 v1 数据一次性迁移/修复 | + +--- + +## 2. v1 的问题与本版修法 + +| # | v1 问题(位置) | 后果 | v2 处理 | +|---|---|---|---| +| 1 | `DirectSmtpDelivery.cs:192` 用 `Encoding.ASCII` 的 StreamWriter 写 DATA | **所有非 ASCII 变 `?`**,中文彻底不可用 | 按字节写出;`SmtpDataEncoder` 只做 dot-stuffing | +| 2 | `Mime.cs:28` 写死 `Message-ID: <...@wpyw.site>` | 声明域 ≠ 发信域,垃圾邮件特征 | 取配置 `Domain` | +| 3 | `Mime.Parse` 不解码 RFC 2047、不解 base64/QP 正文 | 收件显示 `=?utf-8?b?...?=`、正文乱码 | 完整解码(含量化可打印、软换行合并) | +| 4 | 头部按 Latin-1 解码 | 裸 UTF-8 主题变 `[æµè¯]` | UTF-8 优先、失败回退 Latin-1 | +| 5 | `SmtpServer.ReadData` 用 StreamReader 读文本再拼回 | 8bit 内容/行尾被破坏、O(n²) 长度统计、无上限 | 字节级读取 + 大小上限 + dot-unstuffing | +| 6 | 自签名证书时不广告 STARTTLS,但 AUTH 又要求加密 | **587 完全死锁(538)** | 只要加载到证书就广告 STARTTLS;AUTH 在 TLS 后提供 | +| 7 | 无 DKIM | 进垃圾箱 | 新增 DKIM 签名(含密钥生成与 DNS 记录输出) | +| 8 | 4xx/5xx 一律按 8 次封顶,无退信 | 封锁类 5xx 直接丢信且无通知 | 可配置重试策略 + 彻底失败发退信 | +| 9 | 无 multipart / 无附件 | 前端已在读 `attachments` 但后端没有 | 完整 multipart 收/发 + 附件存储与下载 | +| 10 | 会话仅存内存 | 每次重启把客户端踢下线 | 会话落盘 `sessions.json` | +| 11 | 无文件夹/星标/删除/队列接口 | 客户端只能读收件箱 | 补齐(见第 6 节) | +| 12 | 签名后才改行尾 | DKIM 正文哈希对不上,签名失效 | **先规范化 → 再签名 → 传输不改字节**(自检覆盖) | + +### 2.1 v2.0.0 自身的 DKIM 缺陷(2026-09-13 由外部验证器揪出,v2.0.1 修复) + +| # | 问题(位置) | 后果 | 修法 | +|---|---|---|---| +| 13 | `Dkim.cs` 构造签名输入时,把 `DKIM-Signature` 头放在**最前面**且**多带一个结尾 CRLF** | 违反 RFC 6376 §3.7 第 2 步 → **Gmail / Outlook / port25 等所有合规验证器一律判 `dkim=fail`**,等于白签 | 改为:各被签名头按 `h=` 顺序、每个后跟一个 CRLF;`DKIM-Signature` 头**放最后且结尾不带 CRLF** | + +**这条为什么差点被漏掉(重要教训)**:v2.0.0 的 `SelfTest.VerifyDkim` 与本机 Python 验签脚本 +当初都是**照着签名端的实现写的**,两边犯了同一个错,于是自检 38/38、加上手写验签脚本全部"通过"—— +属于典型的**假通过**。真正把它暴露出来的是**外部独立验证器**(把信发给 +`check-auth@verifier.port25.com`,报告里明确写着 `dkim=fail reason="signature doesn't verify"`, +并且它打印的「Canonicalized Headers」里签名头的顺序与我们对不上,一眼看出顺序错误)。 + +因此本版把「验签」当成**独立实现**来写,并加了两道防线: + +1. `SelfTest` 新增**反向对照**用例:故意用非规范顺序(签名头在前 + 带结尾 CRLF)重建签名输入, + 断言它**必须验不过**。若哪天验签器又被写成与签名端"同错",这项会立刻失败。 +2. `tools/verify_published_dkim.py`:只吃 **DNS 上已发布的 TXT**、不接触私钥, + 对真实投递报文按 RFC 6376 顺序验签 —— 直接复现收件方会看到的结果。 + +修好之后的实测证据(同一把 DNS 公钥、同一个验签器): + +* v2.0.0 签的报文(`14:09` 落盘)→ **失败**(`DigestInfo 不匹配`) +* v2.0.1 签的报文(`14:24` 落盘)→ **通过** +* port25 外部验证器第三次报告 → `DKIM check: pass`,对方写入 + `Authentication-Results: ... dkim=pass (matches From: wpy@wpy.email) header.d=wpy.email` + +--- + +## 3. 配置(appsettings.json) + +```jsonc +{ + "Domain": "wpy.email", + "Hostname": "mail.example.com", + "HttpPrefix": "http://127.0.0.1:8787/", + "SmtpPort": 25, + "SubmissionPort": 587, + "DataDirectory": "C:\\WpywMailData", + "AdminEmail": "wpy@wpy.email", + "AdminPassword": "至少12位;会同步为该账号的登录密码", + "TlsCertificatePath": "C:\\WpywMailData\\certs\\mail.example.com.pfx", + "TlsCertificatePassword": "...", + "DeliveryMode": "direct", // direct=MX直投 | relay=上游中继 + + "Storage": { + "Provider": "sqlite", // sqlite(默认,推荐)| json(旧实现,可一键回滚) + "DatabasePath": "", // 留空 = DataDirectory\\wpywmail.db + "WalAutoCheckpointPages": 0, // 0 = 用 SQLite 默认(约 1000 页) + "FullTextSearch": false // 见第 10 节:打开后搜索快约 20 倍,但索引要占正文量级的空间 + }, + + "DirectDelivery": { + "ConnectionTimeoutSeconds": 30, + "CommandTimeoutSeconds": 30, + "DnsTimeoutSeconds": 5, + "OpportunisticStartTls": true, // 对方支持就加密,握手失败自动回退明文重连 + "RequireStartTls": false, + "DnsServer": "", // 留空用系统 DNS + "HeloName": "" // 留空用 Hostname + }, + + "Relay": { "Host": "", "Port": 587, "User": "", "Password": "", "EnableSsl": true }, + + "Retry": { + "MaxAttempts": 12, + "InitialDelaySeconds": 60, + "MaxDelaySeconds": 3600, + "RetryOnPermanentFailure": true, // 5xx 也重试(封锁/策略类常是临时的) + "MaxAttemptsForPermanent": 3, + "SendBounceNotification": true // 彻底失败给发件人发退信 + }, + + "Dkim": { + "Enabled": true, + "Selector": "mail", // DNS 名:mail._domainkey. + "SigningDomain": "", // 留空用 Domain + "PrivateKeyPath": "", // 留空 = DataDirectory/dkim/.private.pem + "Headers": ["From","To","Subject","Date","Message-ID","MIME-Version","Content-Type","Content-Transfer-Encoding"] + }, + + "Api": { "SessionDays": 30, "CorsOrigin": "*", "LongPollSeconds": 25 }, + + "Accounts": { + "Registration": "invite", // open=自助注册 | invite=需要邀请码 | closed=关闭注册 + "InviteCode": "换成一串只有你知道的随机串", + "AllowedDomains": ["wpy.email"], // 允许注册的域名;留空 = 只允许本机域 + "RequireEmailVerification": true, // ⚠ 本机托管的域名会自动跳过,见第 6.2.2 节 + "MinPasswordLength": 12, + "CodeMinutes": 30, // 验证码有效期 + "MaxCodeAttempts": 5, + "MaxLoginFailures": 8, // 连续失败多少次后临时锁定 + "LockoutMinutes": 15, + "RegisterPerHourPerIp": 5, // 严格配额只算「真的建出的账号」 + "ResendPerHourPerEmail": 5, + "AuditLimit": 2000 // 单账号保留的审计条数 + }, + + "InboundAuth": { + "Enabled": true, // 收信时做 SPF/DKIM/DMARC 校验 + "AddAuthenticationResults": true, // 把结论写进报文的 Authentication-Results 头(标准做法) + "SpamFolderOnFail": true, // 判定为垃圾 → 投 spam 文件夹(可逆) + "RejectOnDmarcReject": false, // DMARC p=reject 且失败时直接 550 拒收(默认关:拒收不可逆) + "VerifyDkim": true, // DKIM 验签(要查发件域 DNS 公钥;公钥发布成 CNAME 也会跟) + "SpamScoreThreshold": 3, // 判垃圾的分数阈值(DMARC 失败固定 +4) + "DnsTimeoutSeconds": 5, + "MaxSpfLookups": 10 // RFC 7208 规定 10 + }, + + "Smtp": { + "MaxMessageBytes": 26214400, + "AdvertiseStartTls": true, + "AllowAuthOnInbound": false, // 25 端口默认不允许认证 + "AddReceivedHeader": true, + "AuthFailuresBeforeBan": 8, + "BanMinutes": 15, + "EnforceSenderMatch": true // 已认证用户必须用自己的地址发件 + } +} +``` + +--- + +## 4. 构建与部署 + +```powershell +# 构建 + 自检(202 项;含 DKIM 可验证性、传输一致性、存储双后端契约、账号体系全套) +dotnet build -c Release +.\bin\Release\net8.0\win-x64\WpywMail.Native.exe --selftest + +# 发布自包含版本(目标机无需安装 .NET) +dotnet publish -c Release -r win-x64 --self-contained true -o ..\work\publish-v2 +``` + +增量部署(只换主程序集,不动配置与数据): + +```powershell +# ⚠ 顺序不能变:Stop-ScheduledTask 会触发任务的失败重启策略,把服务又拉起来并锁住 DLL +Disable-ScheduledTask -TaskName WpywMail +Stop-ScheduledTask -TaskName WpywMail +Get-Process -Name 'WpywMail.Native' -ErrorAction SilentlyContinue | Stop-Process -Force +Copy-Item .\bin\Release\net8.0\win-x64\WpywMail.Native.dll 'C:\Program Files\WpywMail\WpywMail.Native.dll' -Force +Enable-ScheduledTask -TaskName WpywMail +Start-ScheduledTask -TaskName WpywMail +``` + +部署(计划任务名 `WpywMail`,动作指向 `C:\Program Files\WpywMail\WpywMail.Native.exe`): + +```powershell +Stop-ScheduledTask -TaskName WpywMail +Copy-Item ..\work\publish-v2\* 'C:\Program Files\WpywMail\' -Recurse -Force # 运行时首次需全量,之后只需 *.dll/*.exe +Start-ScheduledTask -TaskName WpywMail +``` + +**回滚**:删掉 `C:\Program Files\WpywMail`,把 `C:\Program Files\WpywMail.v1-backup` 改回来,重启计划任务。 + +维护命令: + +```powershell +WpywMail.Native.exe --version # 版本 +WpywMail.Native.exe --check-config # 校验配置并打印关键项(含账号策略与「本机域免验证」提醒) +WpywMail.Native.exe --selftest # 202 项自检(中文编解码、附件、DKIM、传输一致性、存储双后端契约、账号体系) +WpywMail.Native.exe --migrate # 从 v1 数据迁移:重解析 raw/*.eml 修正主题/正文/附件、改写旧域名归属 +``` + +### 4.1 真机验收脚本(账号体系) + +`tools/account-acceptance.ps1` —— 在邮件服务器本机上跑,打**真实 HTTP API + 真实 IMAP(993)**: + +```powershell +# 上传后在服务器上执行(脚本必须存成「UTF-8 带 BOM」,否则 PowerShell 5.1 会把中文按 GBK 解) +powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1 +# 退出码 = 失败项数;报告默认写到 C:\Windows\Temp\wpyw-acct-verify.txt(UTF-8) +``` + +覆盖 53 项:策略接口 → 邀请码/域名/弱密码拦截 → 注册即开通 → 会话/资料/审计 → +真实 IMAP 登录与收件箱计数 → 自己发信并被本地投递 → 忘记密码(**真去邮箱里读验证码**)→ 重置 → +改密踢其他会话 → 登录失败锁定(423 + retryAfterSeconds)→ 管理员视角 → 停用与「不能靠重新注册复活」。 + +⚠ 脚本一小时内重复跑会撞到 `RegisterPerHourPerIp` 配额:此时它会把自助注册那几项标成 `[SKIP]` +并改用管理员接口建号,其余用例照常跑完(断言的是产品行为,不是配额余量)。 +测试会留下两个 `selftest-*@` / `locktest-*@` 账号,脚本结束前会**停用**它们。 + +--- + +## 5. DNS 配置(决定能否进收件箱) + +以 `wpy.email` + IP `` 为例: + +| 类型 | 名称 | 值 | 说明 | +|---|---|---|---| +| A | `mail` | `` | **必须灰云(DNS only)**,MX 指向的主机不能走代理 | +| MX | `@` | `mail.example.com`(优先级 10) | | +| TXT | `@` | `v=spf1 ip4: -all` | 注意是**半角**冒号;`-all` 比 `~all` 严格 | +| TXT | `mail._domainkey` | 服务启动日志里打印的 `v=DKIM1; k=rsa; p=...` | 一字不能改,Cloudflare 会自动分段 | +| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:wpy@wpy.email` | 先 `p=none` 观察,再收紧 | +| PTR | `56.55.236.103`(反向) | `mail.example.com` | **只能由 IDC 设置**,无 PTR 时大厂几乎必判垃圾 | + +--- + +## 6. REST API + +- 基址:`config.HttpPrefix`(默认 `http://127.0.0.1:8787/`,仅供反向代理/本机访问) +- 认证:`POST /api/login` 换 `token`,之后所有请求带 `Authorization: Bearer ` +- 编码:请求与响应均为 UTF-8 JSON;错误统一为 `{ "error": "说明" }` +- 会话有效期 `Api.SessionDays` 天,落盘保存,重启不掉线 +- 所有路径保持 v1 兼容(`/api/login`、`/api/messages`、`/api/send`、`/api/config`、`/api/me`、`/api/logout`、`/api/account/password`、`/api/admin/users`) + +### 6.1 无需认证 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/health` | `{ ok, service, version, domain, hostname }` | +| GET | `/api/version` | `{ version, domain, hostname, dkim }` | +| POST | `/api/login` | 入参 `{ email, password }` → `{ token, expiresAt, user }` | + +### 6.2 账号 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/me` | `{ user, stats }`;stats 含 inbox/unread/starred/drafts/sent/trash/queue/failed | +| GET | `/api/config` | 域名、主机名、协议端口、特性开关(客户端据此决定是否显示附件/草稿入口) | +| POST | `/api/logout` | 使当前 token 失效 | +| POST | `/api/account/password` | `{ password, currentPassword? }`(新密码 ≥ 12 位);成功后**吊销其他会话**,返回 `{ ok, revokedSessions }` | +| PATCH | `/api/account/profile` | `{ displayName }`(≤64 字)→ `{ ok, user }` | +| GET | `/api/account/sessions` | `{ sessions: [{ tokenPrefix, token, current, createdAt, expiresAt }] }` | +| POST | `/api/account/sessions/revoke` | 空体/`{}` = 退出其他设备(保留当前);`{ all: true }` = 全部退出;`{ token }` = 指定会话 | +| GET | `/api/account/audit?limit=50` | `{ events: [{ at, email, ip, reason, success, detail, userAgent }] }`(自己的登录/改密/注册轨迹) | + +### 6.2.1 自助注册与密码找回(v2.2 新增,无需认证) + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/auth/policy` | 注册策略:`{ registration, inviteRequired, requireEmailVerification, minPasswordLength, allowedDomains, codeMinutes, maxLoginFailures, lockoutMinutes, selfHostedDomain, verificationNote }` | +| POST | `/api/register` | `{ email, password, displayName?, inviteCode? }` → `201 { ok, verificationRequired:false, session }`(直接开通)或 `202 { ok, verificationRequired:true, email, expiresInMinutes }`(等验证码) | +| POST | `/api/register/verify` | `{ email, code }` → `201 { ok, session }`(验证通过即登录) | +| POST | `/api/register/resend` | `{ email, purpose:"register"\|"reset" }` → `{ ok }` | +| POST | `/api/auth/forgot` | `{ email }` → 恒返回 `200 { ok, expiresInMinutes }`(**不暴露邮箱是否存在**;命中则发验证码邮件) | +| POST | `/api/auth/reset` | `{ email, code, password }` → `{ ok }`;成功后**吊销该账号全部会话**;若账号此前未激活(注册没验证完)顺带激活 | + +登录失败的状态码(客户端要按码分支,别只看文案): + +| 码 | 何时 | 响应 | +|---|---|---| +| `401` | 账号不存在 / 密码错 / **账号被停用** | `{ error: "邮箱或密码不正确" }`(一句话,不区分原因,防账号枚举) | +| `403` | 账号存在但**注册的邮箱验证没做完** | `{ error: "...请用验证码完成验证,或用「忘记密码」重设密码", pendingVerification: true }` | +| `423` | 连续失败达到 `maxLoginFailures` 后的锁定期 | `{ error, retryAfterSeconds }`(客户端应显示倒计时并禁用提交) | + +### 6.2.2 两条必须知道的设计决策(踩过坑才定下来的) + +**① 本机托管的邮箱注册后免邮箱验证码。** +理由是个死循环:验证码邮件只能投进「这个」信箱,而这个信箱在验证通过前不允许登录(IMAP / Webmail / API 全进不去), +用户永远拿不到码。所以域名等于本服务器自己的域(`config.Domain` / `config.Hostname` 的域)时, +`RequireEmailVerification` 会被自动跳过,注册授权凭据是**邀请码**(管理员发放)。 +`GET /api/auth/policy` 的 `verificationNote` 会把这件事讲给用户听;`--check-config` 也会打印同一提醒。 +要让邮箱验证真正生效,得把 `Accounts.AllowedDomains` 换成托管在别处的域名(如 `gmail.com`)。 +判定逻辑:`AccountService.IsHostedDomain(email, Domain, Hostname)`。 + +**② 未激活的账号先建信箱行,但密码只存在验证码记录里。** +注册时就把用户行建出来(`active=0`,密码是随机不可用值),否则本地投递看不到收件人、验证码邮件会被静默丢弃; +真正的密码哈希随验证码一起存进验证码记录的 Payload,**验证码通过那一刻才写进用户行并激活**。 +于是「谁能读到验证码,谁才能决定这个账号的密码」—— 抢注者单独无法在别人的邮箱上留下自己的密码。 +未激活的账号不能登录 IMAP/SMTP/API(各处 `FindUser` 只取 `active=1`),但**能收信**(投递用 `FindUserAnyState`)。 + +其它已实现的加固:注册/登录限流(严格配额只算**真的建出的账号**,填错表单不吃配额,另有宽松上限防邀请码爆破)、 +验证码只存 PBKDF2 哈希 + 试错上限 + 过期、审计表(可按账号/IP/原因查询)、 +**管理员停用是权威状态**(被停用的账号不能靠重新注册复活)、改密/重置后踢掉其他会话。 + +### 6.3 邮件 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/messages` | 查询:`folder`(inbox/sent/drafts/archive/trash/spam,留空=全部)、`q`、`unread=1`、`starred=1`、`limit`(≤500,默认 100)、`offset` → `{ total, offset, limit, messages[] }` | +| GET | `/api/messages/{id}` | 详情;查询 `markRead=false` 可只看不改已读状态 | +| PATCH | `/api/messages/{id}` | `{ unread?, read?, starred?, folder? }`(`folder` 即移动/归档/删除到垃圾箱) | +| DELETE | `/api/messages/{id}` | 默认移入垃圾箱;`?permanent=true` 彻底删除(同时删除原始报文与附件文件) | +| GET | `/api/messages/{id}/raw` | 下载原始 `.eml` | +| GET | `/api/messages/{id}/attachments/{index}` | 下载附件(`Content-Disposition` 为 RFC 5987 编码,中文名正常) | +| POST | `/api/send` | `{ to, subject, text, html?, cc?, inReplyTo?, attachments?[] }` → `202 { queued, messageId, recipients, cc }` | +| POST | `/api/drafts` | `{ to, subject, text }` → `201`,存入 drafts | + +`messages[]` 摘要字段:`id, folder, from, to, cc, subject, date, receivedAt, unread, starred, deliveryStatus, lastError, size, attachmentCount, hasAttachments, preview`。 + +详情额外含:`text, html, messageId, inReplyTo, references, dkimSigned, attachments[{ index, fileName, contentType, size, inline, url }], rawUrl`。 + +`attachments` 元素格式:`{ fileName, contentType, base64 }`(单封上限见 `Smtp.MaxMessageBytes`)。 + +### 6.4 出站队列 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/queue` | `{ queue: [{ id, messageId, recipients, attempts, status, lastError, lastCode, nextAttempt, createdAt, lastAttemptAt }] }` | +| POST | `/api/queue/{id}/retry` | 立即重试(重置 attempts 与退避) | + +`status`:`pending` / `processing` / `retry` / `sent` / `failed`。 + +### 6.5 新邮件推送(长轮询) + +``` +GET /api/watch?since= +→ 挂起至多 Api.LongPollSeconds 秒,直到数据发生变化 +→ { version, changed, stats } +``` + +客户端流程:启动时 `version = (await /api/watch?since=0).version`,之后循环 +`/api/watch?since=<上次的 version>`;`changed=true` 时刷新列表,并把返回的 `version` 作为下次的 `since`。 + +### 6.6 管理员 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/admin/users` | 用户列表(含 active / createdAt / lastLoginAt) | +| POST | `/api/admin/users` | `{ email, password(≥12), displayName? }` → `201` | +| PATCH | `/api/admin/users/{email}` | `{ active? }` 或 `{ password? }` | + +--- + +## 7. 客户端接入要点 + +**登录** + +```http +POST /api/login {"email":"wpy@wpy.email","password":"..."} +→ 200 {"token":"...","expiresAt":"...","user":{"email":"...","displayName":"...","role":"admin","domain":"wpy.email"}} +``` +之后每个请求加 `Authorization: Bearer `;收到 `401` 即视为需要重新登录(本地清 token)。 + +**SMTP 客户端(手机/电脑上的邮件 App)**:`587` + STARTTLS + 普通密码认证(`AUTH LOGIN/PLAIN`),发件人必须等于登录账号;`25` 端口只用于收信,不接受认证与中继。 + +**WinUI 客户端建议**:走 REST + `/api/watch` 长轮询即可,无需实现 IMAP/SMTP; +若要支持系统级邮件 App,则需另加 IMAP(v2 尚未实现,见第 8 节)。 + +**错误处理**:`400` 参数问题、`401` 未登录、`403` 权限/密码错误、`404` 对象不存在、`405` 方法不支持、`500` 服务端异常;响应体固定为 `{ error }`。 + +**并发**:长轮询会占用一个连接,建议单个客户端同时最多 1 个 watch 请求。 + +--- + +## 8. 用标准邮件客户端接入(IMAP) + +v2 内置 IMAP4rev1 服务端,Thunderbird / Outlook / Foxmail / 手机邮件 App 可直接连接。 + +| 项目 | 值 | +|---|---| +| 明文 + STARTTLS | 端口 `143` | +| 隐式 TLS | 端口 `993` | +| 用户名 | 完整邮箱地址,如 `wpy@wpy.email` | +| 密码 | `appsettings.json` 里的 `AdminPassword` | +| 发信(SMTP) | `587` + STARTTLS + 同一套账号密码 | +| 收信(SMTP) | 无需配置(`25` 由公网直接投递) | + +文件夹映射(客户端里显示名 → 内部名): +`INBOX`→inbox、`Sent`→sent、`Drafts`→drafts、`Archive`→archive、`Trash`→trash、`Junk`→spam。 +中文名(收件箱/已发送/草稿/归档/垃圾箱/垃圾邮件)也可被识别。 + +已实现的命令:`CAPABILITY`、`NOOP`、`LOGOUT`、`STARTTLS`、`LOGIN`、`AUTHENTICATE PLAIN`、 +`LIST`/`LSUB`、`SELECT`/`EXAMINE`、`STATUS`、`CLOSE`、`UNSELECT`、`EXPUNGE`、`SEARCH`、 +`FETCH`/`UID FETCH`、`STORE`/`UID STORE`、`COPY`/`UID COPY`、`APPEND`、`IDLE`、 +`CREATE`/`DELETE`/`RENAME`/`SUBSCRIBE`(接受但文件夹集合固定)。 +未实现(不影响常规使用):`SORT`、`THREAD`、`CONDSTORE`、`QRESYNC`、`ACL`。 + +行为约定: + +- `RequireTlsForLogin=true` 时,**必须先 STARTTLS 或用 993** 才能 LOGIN;仅 `PlaintextLoginAllowFrom` 列表内的地址(默认本机)可明文登录。 +- `SELECT` 后 `FETCH` 的序号按 **UID 升序**(稳定顺序)。 +- 非 `PEEK` 的正文请求会把邮件标记为已读;`\Flagged` ↔ 星标;`\Deleted` 在 `EXPUNGE` 时生效(inbox 等移入垃圾箱,垃圾箱内则彻底删除)。 +- `EXPUNGE` 的序号会随删除动态变化(符合 RFC 3501)。 +- 自签名证书下客户端会提示证书不受信;换成受信任证书后即无提示。 + +配置示例(appsettings.json): + +```jsonc +"Imap": { + "Enabled": true, + "Port": 143, + "TlsPort": 993, + "RequireTlsForLogin": true, + "PlaintextLoginAllowFrom": ["127.0.0.1", "::1"] +} +``` + +--- + +## 9. 存储后端(SQLite / JSON) + +### 为什么要有这一节 + +v2.0.x 只有一种存储:`users.json / messages.json / queue.json / sessions.json` 四个文件, +**任何一次改动都会把全部邮件重新序列化并整文件重写**。把一封邮件标记为已读也是 O(N), +UID 分配、未读计数、统计、搜索全是全表扫描,而且全部邮件常驻内存。 + +v2.1 起默认使用 **SQLite**(元数据进库 + 索引 + 事务),原始报文与附件以 +**gzip 压缩 + 按内容 SHA256 去重**的形式存进 `blobs` 表 —— **不再写 `raw/` 文件**。 + +### 基准数据(同一份负载,`--bench-store`) + +| 指标 | JSON(整文件重写) | SQLite | 结论 | +|---|---|---|---| +| 入库 每封 @400 封 | 3.74 ms | 0.27 ms | **快 14×** | +| 入库 每封 @2000 封 | 9.94 ms | 0.36 ms | **快 28×**(JSON 随规模劣化) | +| 标记已读 每次 @2000 | 16.50 ms | 0.10 ms | **快 167×** | +| 收件箱首页(50 条) | 0.42 ms | 0.97 ms | 都在毫秒级 | +| 未读数 | 0.13 ms | 0.06 ms | 快 2× | +| 统计 | 0.17 ms | 0.18 ms | 持平 | +| 搜索(LIKE 扫描) | 6.27 ms | 7.30 ms | 持平;**开 FTS5 后 0.35 ms(快 18×)** | +| 常驻内存增量 @2000 | 24.5 MB | 7.5 MB | **省 69%** | +| 磁盘 @2000 | 10.56 MB | 7.03 MB | **省 33%**(raw 808 KB→库内 273 KB) | + +关键不是单点快多少,而是**增长曲线**:JSON 的单次改动成本随邮件数线性上涨(400→2000 封时 +每封入库从 3.7 ms 涨到 9.9 ms),SQLite 基本恒定。 + +### 命令 + +```powershell +WpywMail.Native.exe --storage-status # 当前后端、各表占用、大对象压缩率、有无孤儿 +WpywMail.Native.exe --migrate-to-sqlite # JSON → SQLite(逐封 SHA256 校验,默认保留源文件) +WpywMail.Native.exe --migrate-to-sqlite --delete-source # 校验通过后删除 raw/ 与 attachments/ 文件 +WpywMail.Native.exe --migrate-to-json # SQLite → JSON(回滚用,把 blobs 还原成文件) +WpywMail.Native.exe --compact # 清理没有引用的大对象 +WpywMail.Native.exe --vacuum # 合并 WAL + 回收空闲页 +WpywMail.Native.exe --bench-store 2000 # 两套后端的基准对比 +``` + +### 迁移与回滚 + +1. 停服务(**先 `Disable-ScheduledTask WpywMail`**,否则任务的失败重启策略会把它拉起来、锁住 DLL); +2. 备份 `C:\WpywMailData`; +3. `--migrate-to-sqlite --delete-source`:迁移会**逐封比对 SHA256**,任何一封不一致就中止并保留源文件; +4. 改 `Storage.Provider` 为 `sqlite`(默认值即是),启服务。 + +**回滚**:把 `Storage.Provider` 改回 `json` 即可 —— 四个 JSON 索引文件在迁移时**故意保留**。 +若源文件已被 `--delete-source` 删除导致 JSON 侧缺 `raw/`,先跑 `--migrate-to-json` +把 blobs 还原成文件,再切回 json。 + +### ⚠️ `--vacuum` 必须在停服时做 + +VACUUM 需要约 **2 倍**临时空间,且 **WAL 无法在别的连接持有数据库时截断** —— +在服务运行时执行会把文件撑大而收不回来(实测 241 KB → 1.75 MB,停服重做后回到 274 KB)。 + +### 空间账(本机实测,66 封邮件) + +``` +raw/ 0 B(0 个文件) ← 报文字节已压缩进库 +attachments/ 0 B(0 个文件) +wpywmail.db 274,432 B(67 页 × 4096 B,空闲页 0) + 大对象 59 个:原始 88,917 B → 存储 49,986 B(57 个启用压缩) + 正文列合计 47,853 B(最大一封 8,908 B:port25 的 Authentication Report) +``` + +平均约 4.2 KB/封(含索引)。索引不是免费的:`ux_messages_owner_folder_uid`、 +`ix_messages_owner_folder_date`、`ix_messages_owner_unread`、`ix_messages_owner_starred`、 +`ix_messages_owner_status` 都对应真实查询;早期版本建过两个没人用的索引(`message_id`、`raw_path`), +已在建表语句里幂等 `DROP` 掉。 + +--- + +## 10. 全文检索开关(FTS5) + +`Storage.FullTextSearch: true` 会建立 FTS5(trigram 分词器)索引,中文子串搜索走索引: + +| | 搜索每次 @2000 封 | 占用 | +|---|---|---| +| `false`(默认) | 7.30 ms(`LIKE '%…%'` 全表扫描) | 7,028,736 B | +| `true` | **0.35 ms** | 9,777,152 B(**索引多占 2.7 MB ≈ 1.4 KB/封**) | + +默认关闭是因为本机磁盘偏紧;按 12.5 GB 可用空间算,索引成本要到**上百万封**才会成为问题, +所以只要搜索体验优先,随时可以打开(改配置重启即可,首次启动会自动为历史邮件建索引)。 + +--- + +### 10.1 入站邮件身份校验(SPF / DKIM / DMARC) + +在此之前**谁都能用 `From: wpy@wpy.email` 给这台服务器发信**,服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有区别。 +现在收信时依次做: + +| 步骤 | 实现 | 说明 | +|---|---|---| +| SPF | `Spf.EvaluateAsync`(RFC 7208 常用子集) | all / include / a / mx / ip4 / ip6 / exists + 限定符 + redirect + 宏;**查询次数上限 10**(超了 permerror);`ptr` 机制按 RFC 建议直接视为不匹配 | +| DKIM | `DkimVerifier`(RFC 6376) | relaxed/simple 规范化、`h=` 重名从下往上取、`l=` 截断、`x=` 过期、`p=` 为空视为吊销;**公钥发布成 CNAME 时会自动跟**(outlook.com 就是这种) | +| DMARC | `Dmarc.EvaluateAsync`(RFC 7489) | `p=` / `aspf=` / `adkim=`;relaxed 对齐用「组织域」(内置 co.uk / com.cn 这类多段后缀表) | + +结论会写进报文:`Authentication-Results:`、`X-Spam-Score:`、`X-Spam-Reason:`(**前置插入,不动原有字节**, +所以发件人的 DKIM 签名不会被我们破坏 —— 自检里有这条断言)。 +判定为垃圾(默认阈值 3;DMARC 失败固定 +4)则投进 `spam` 文件夹并把 `DeliveryStatus` 标成 `received-spam`, +**默认不拒收**:校验实现自身也可能有 bug,投垃圾箱可逆、拒收不可逆。 + +**验签实现必须能被打假**:自检里带了「改正文 / 改主题 / 只翻转一个字节 / 换公钥 / 公钥吊销」五类反向用例, +它们必须全部失败 —— 2026-09-13 的 DKIM 事故就是签名端和验签端犯了同一个错,导致自检「全通过」而外部判 fail。 + +维护命令:**`--verify-inbound [N]`** —— 对已落库的真实邮件走真实 DNS 验签(排查「这封信是不是伪造的」)。 +实测最近 25 封里 8 封通过(含 126.com 这类第三方签名),1 封 163 转发的因正文被改写而判 fail(**判定正确**)。 + +--- + +## 11. 已知限制 +- ~~未实现 IMAP/POP3~~ → **IMAP4rev1 已在 v2 实现**(见第 8 节);POP3 仍未实现。 +- ~~TLS 证书自签名~~ → **已换成 Let's Encrypt 受信任证书**(DNS-01,含自动续期计划任务)。 +- ~~无账号体系~~ → **v2.2 已有自助注册 / 登录加固 / 找回密码 / 会话与资料管理**(见第 6.2 节); + 仍缺:**管理员删除账号**(目前只能停用,`PATCH /api/admin/users/{email}` `{"active":false}`)。 +- **明文凭据**:`appsettings.json` 中的 `AdminPassword` 与证书口令是明文,注意文件权限。 +- **无病毒/垃圾过滤**:收信不做内容扫描,也未做 SPF/DKIM 校验入站判定。 +- **默认无全文索引**:搜索是 `LIKE` 扫描(数千封内毫秒级);需要更快请开 `FullTextSearch`。 +- **无配额**:单用户磁盘占用不限制。 +- **`--vacuum` 需停服执行**(见第 9 节)。 diff --git a/server-native-v2/SelfTest.cs b/server-native-v2/SelfTest.cs new file mode 100644 index 0000000..796342f --- /dev/null +++ b/server-native-v2/SelfTest.cs @@ -0,0 +1,401 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WpywMail.Native; + +/// +/// 自检:不依赖网络与真实邮箱,验证中文编解码、MIME 往返、附件、DKIM 签名可被验证。 +/// 用法:WpywMail.Native.exe --selftest +/// +public static partial class SelfTest +{ + private static int passed; + private static int failed; + + public static int Run() + { + Console.OutputEncoding = Encoding.UTF8; + Console.WriteLine($"=== WpywMail.Native 自检 {BuildInfo.Version} ==="); + + var config = new AppConfig + { + Domain = "wpy.email", + Hostname = "mail.example.com", + AdminEmail = "wpy@wpy.email", + AdminPassword = "selftest-password-1234", + DataDirectory = Path.Combine(Path.GetTempPath(), "wpyw-selftest-" + Guid.NewGuid().ToString("N")[..8]), + Dkim = new DkimConfig { Enabled = true, Selector = "mail" }, + }; + Directory.CreateDirectory(config.DataDirectory); + + try + { + TestChineseRoundTrip(config); + TestMessageIdDomain(config); + TestRfc2047Decoding(); + TestRawUtf8Headers(); + TestQuotedPrintable(); + TestAttachmentRoundTrip(config); + TestCharsetFallback(); + TestDkimSignatureVerifies(config); + TestDkimSurvivesTransport(config); + TestAddressParsing(); + TestSmtpDataReader(); + TestDkimCanonicalization(); + TestStorageBackends(); + TestAccounts(); + TestInboundAuth(); + } + catch (Exception ex) + { + Fail("自检整体", $"抛出异常:{ex}"); + } + finally + { + try { Directory.Delete(config.DataDirectory, true); } catch { } + } + + Console.WriteLine(); + Console.WriteLine($"=== 结果:{passed} 项通过,{failed} 项失败 ==="); + return failed == 0 ? 0 : 1; + } + + // ---------------------------------------------------------------- 用例 + + private static void TestChineseRoundTrip(AppConfig config) + { + const string subject = "中文主题测试:你好,世界(含全角标点)"; + const string body = "这是正文第一行。\n第二行包含 emoji 之外的符号:→ ★ ①\n第三行结束。"; + var raw = Mime.Build(new ComposeRequest("wpy@wpy.email", "王朋友", ["to@example.com"], [], subject, body), config); + var parsed = Mime.Parse(raw); + + Check("中文主题往返", parsed.Subject == subject, $"得到「{parsed.Subject}」"); + Check("中文正文往返", parsed.Text == body, $"得到「{parsed.Text.Replace("\n", "\\n")}」"); + Check("正文使用 base64", Encoding.ASCII.GetString(raw).Contains("Content-Transfer-Encoding: base64"), ""); + Check("主题使用 RFC2047", Encoding.ASCII.GetString(raw).Contains("=?UTF-8?B?"), ""); + Check("报文全为 ASCII(可安全传输)", raw.All(b => b < 0x80), "存在 8bit 字节"); + Check("带显示名的中文发件人", RawHeader(raw, "From").Contains("=?UTF-8?B?"), RawHeader(raw, "From")); + } + + private static void TestMessageIdDomain(AppConfig config) + { + var raw = Mime.Build(new ComposeRequest("wpy@wpy.email", null, ["to@example.com"], [], "t", "b"), config); + var messageId = RawHeader(raw, "Message-ID"); + Check("Message-ID 使用配置域名", messageId.Contains("@wpy.email>"), messageId); + Check("Message-ID 不再写死旧域名", !messageId.Contains("wpyw.site"), messageId); + } + + private static void TestRfc2047Decoding() + { + var b = "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes("中文主题")) + "?="; + Check("解码 B 编码字", Mime.DecodeHeader(b) == "中文主题", Mime.DecodeHeader(b)); + + var q = "=?UTF-8?Q?=E4=B8=AD=E6=96=87?="; + Check("解码 Q 编码字", Mime.DecodeHeader(q) == "中文", Mime.DecodeHeader(q)); + + var adjacent = "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes("前半")) + "?= =?UTF-8?B?" + + Convert.ToBase64String(Encoding.UTF8.GetBytes("后半")) + "?="; + Check("相邻编码字拼接无多余空格", Mime.DecodeHeader(adjacent) == "前半后半", Mime.DecodeHeader(adjacent)); + + var mixed = "Hello " + b; + Check("编码字与纯文本混排", Mime.DecodeHeader(mixed) == "Hello 中文主题", Mime.DecodeHeader(mixed)); + } + + /// + /// 裸 UTF-8 头(没有 RFC 2047 编码字)与 8bit 正文。 + /// 现实中不少客户端这么发;早期版本会把它解成「[æµè¯]」这种乱码。 + /// + private static void TestRawUtf8Headers() + { + var raw = Encoding.UTF8.GetBytes( + "From: 张三 \r\n" + + "Subject: 中文裸 UTF-8 主题\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n" + + "\r\n" + + "中文正文内容\r\n"); + var parsed = Mime.Parse(raw); + Check("裸 UTF-8 主题解码", parsed.Subject == "中文裸 UTF-8 主题", parsed.Subject); + Check("裸 UTF-8 发件人显示名", parsed.From.Contains("张三"), parsed.From); + Check("8bit 正文解码", parsed.Text == "中文正文内容", parsed.Text); + } + + private static void TestQuotedPrintable() { + var raw = Encoding.ASCII.GetBytes("Subject: =?UTF-8?Q?=E4=B8=AD=E6=96=87?=\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n=E4=B8=AD=E6=96=87=20ok=\r\n=E7=BB=AD=E8=A1=8C\r\n"); + var parsed = Mime.Parse(raw); + // =E7=BB=AD = U+7EED「续」,且 =CRLF 是软换行必须被合并 + Check("QP 正文解码(含软换行合并)", parsed.Text == "中文 ok续行", $"得到「{parsed.Text}」"); + Check("QP 主题解码", parsed.Subject == "中文", parsed.Subject); + } + + private static void TestAttachmentRoundTrip(AppConfig config) + { + var data = Encoding.UTF8.GetBytes("附件内容:中文测试数据"); + var raw = Mime.Build(new ComposeRequest("wpy@wpy.email", null, ["to@example.com"], [], + "带附件", "见附件", null, [new OutgoingAttachment("中文报告.txt", "text/plain", data)]), config); + var parsed = Mime.Parse(raw); + + Check("附件数量", parsed.Attachments.Count == 1, $"{parsed.Attachments.Count}"); + if (parsed.Attachments.Count == 1) + { + var attachment = parsed.Attachments[0]; + Check("附件名(中文)", attachment.FileName == "中文报告.txt", attachment.FileName); + Check("附件内容逐字节一致", attachment.Data.SequenceEqual(data), ""); + Check("附件使用 RFC2231 文件名", Encoding.ASCII.GetString(raw).Contains("filename*=UTF-8''"), ""); + } + Check("附件存在时正文仍可读", parsed.Text.Contains("见附件"), parsed.Text); + } + + private static void TestCharsetFallback() + { + Check("UTF-8 解码", Mime.DecodeCharset(Encoding.UTF8.GetBytes("中文"), "utf-8") == "中文", ""); + Check("未知字符集回退不崩", Mime.DecodeCharset(Encoding.UTF8.GetBytes("中文"), "x-unknown-999") == "中文", ""); + + var provider = CodePagesShim.Provider; + if (provider is not null) + { + try + { + Encoding.RegisterProvider(provider); + var gbk = Encoding.GetEncoding(936).GetBytes("中文测试"); + Check("GBK 解码", Mime.DecodeCharset(gbk, "gb2312") == "中文测试", Mime.DecodeCharset(gbk, "gb2312")); + } + catch (Exception ex) { Check("GBK 解码", false, ex.Message); } + } + else + { + Console.WriteLine(" [跳过] GBK 解码(未引用 System.Text.Encoding.CodePages)"); + } + } + + private static void TestDkimSignatureVerifies(AppConfig config) + { + var signer = DkimSigner.Create(config); + if (signer is null) { Check("DKIM 初始化", false, "返回 null"); return; } + + var raw = Mime.Build(new ComposeRequest("wpy@wpy.email", null, ["to@example.com"], [], + "DKIM 测试主题", "DKIM 测试正文内容。"), config); + var signed = signer.Sign(raw); + + Check("报文已带 DKIM-Signature", Encoding.ASCII.GetString(signed).Contains("DKIM-Signature:"), ""); + Check("被签名报文仍是 ASCII", signed.All(b => b < 0x80), ""); + Check("DKIM 签名可被本地验证(RFC 6376 §3.7 顺序)", VerifyDkim(signed, signer), "签名校验失败"); + // 反向对照:如果验签器把「签名头放最前 + 结尾带 CRLF」也判为有效,说明它根本没在 + // 按 RFC 校验(2026-09-13 的真实事故就是签名端与验签端一起犯错导致假通过)。 + Check("(反向对照)非规范顺序必须验不过", + !VerifyDkim(signed, signer, rfcOrder: false), "两种顺序都能通过 → 验签器未按 RFC 6376 校验"); + } + + /// + /// 最关键的一致性测试:签名覆盖的字节必须与传输写出的字节完全一致。 + /// 用一个「裸 LF 行尾 + 以点开头的行」的恶劣报文走完整链路: + /// 规范化 → 签名 → DATA 编码(dot-stuffing)→ 收件端还原 → 验签。 + /// 若签名后才改行尾,或 dot-stuffing 破坏了正文,这里必然失败。 + /// + private static void TestDkimSurvivesTransport(AppConfig config) + { + var signer = DkimSigner.Create(config); + if (signer is null) { Check("DKIM 传输一致性", false, "签名器不可用"); return; } + + var hostile = Encoding.UTF8.GetBytes( + "From: wpy@wpy.email\n" + + "To: to@example.com\n" + + "Subject: 传输一致性测试\n" + + "MIME-Version: 1.0\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "Content-Transfer-Encoding: 8bit\n" + + "\n" + + "第一行中文\n" + + ". 这一行以点开头\n" + + "最后一行\n"); + + var normalized = SmtpDataEncoder.Normalize(hostile); + // 注意:报文是 UTF-8 字节,检查时必须按 UTF-8 还原(用 Latin-1 解会得到乱码而匹配不上) + Check("裸 LF 在签名前被规范化为 CRLF", + Encoding.UTF8.GetString(normalized).Contains("\r\n. 这一行以点开头\r\n"), + ""); + + var signed = signer.Sign(normalized); + var wire = SmtpDataEncoder.Encode(signed); + Check("线上报文对行首点做了 dot-stuffing", + Encoding.UTF8.GetString(wire).Contains("\r\n.. 这一行以点开头\r\n"), + ""); + + var received = SmtpDataEncoder.Decode(wire); + Check("接收端还原后与签名前字节一致", received.SequenceEqual(signed), ""); + + var verified = VerifyDkim(received, signer); + Check("DKIM 经过完整传输变换后仍可验证", verified, "签名校验失败"); + } + + private static void TestAddressParsing() + { + var result = Mime.Addresses("张三 , b@example.com; \"李四, 五\" "); + Check("地址解析(含引号内逗号)", result.Length == 3 && result[0] == "a@example.com" && result[2] == "c@example.com", + string.Join(" | ", result)); + Check("非法地址被过滤", Mime.Addresses("not-an-address, ok@example.com").Length == 1, ""); + } + + private static void TestSmtpDataReader() + { + // 模拟客户端发送:正文里有中文 UTF-8、以点开头的行需要 dot-stuffing + var payload = "Subject: 测试\r\n\r\n.. 以点开头\r\n中文内容\r\n"; + var wire = Encoding.UTF8.GetBytes(payload.Replace("\r\n", "\r\n")); + using var stream = new MemoryStream(); + // 客户端在 DATA 后追加结束行 + stream.Write(wire); + stream.Write(Encoding.ASCII.GetBytes(".\r\n")); + stream.Position = 0; + + var reader = new SmtpReader(stream); + var data = reader.ReadDataAsync(1024 * 1024, CancellationToken.None).GetAwaiter().GetResult(); + var text = Encoding.UTF8.GetString(data); + + Check("DATA 读取保留 UTF-8 中文", text.Contains("中文内容"), text.Replace("\r\n", "\\n")); + Check("DATA 读取做 dot-unstuffing", text.Contains(". 以点开头"), text.Replace("\r\n", "\\n")); + Check("DATA 读取行尾统一 CRLF", text.EndsWith("\r\n"), ""); + } + + private static void TestDkimCanonicalization() + { + // 折行 + 多余空白应被规范化成单空格 + var raw = Encoding.ASCII.GetBytes("From: a@b.com\r\nSubject: hello world \r\n\tcontinued\r\n\r\nbody\r\n"); + var (headers, _) = SplitForTest(raw); + var subject = headers.First(h => h.Name == "Subject"); + var canonical = CanonicalForTest(subject.Name, subject.Value); + Check("relaxed 头规范化(折行与空白)", canonical == "subject:hello world continued", canonical); + } + + // ---------------------------------------------------------------- DKIM 校验(独立于签名器的实现) + + /// + /// 按 RFC 6376 §3.7 第 2 步重建签名输入并验签。 + /// rfcOrder=false 时故意用「DKIM-Signature 放最前 + 结尾带 CRLF」的**非规范**顺序, + /// 仅用于反向对照:非规范顺序绝不能被判为有效。 + /// + private static bool VerifyDkim(byte[] message, DkimSigner signer, bool rfcOrder = true) + { + var (headers, body) = SplitForTest(message); + var dkim = headers.FirstOrDefault(h => h.Name.Equals("DKIM-Signature", StringComparison.OrdinalIgnoreCase)); + if (dkim.Name is null) return false; + + var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in dkim.Value.Split(';')) + { + var equals = part.IndexOf('='); + if (equals > 0) tags[part[..equals].Trim()] = part[(equals + 1)..].Trim(); + } + if (!tags.TryGetValue("b", out var signatureBase64) || !tags.TryGetValue("bh", out var bodyHash)) return false; + if (!tags.TryGetValue("h", out var headerList)) return false; + + // 1) 正文哈希 + var canonicalBody = CanonicalBodyForTest(body); + var computedBodyHash = Convert.ToBase64String(SHA256.HashData(canonicalBody)); + if (computedBodyHash != bodyHash.Trim()) return false; + + // 2) 重建签名输入 + var withoutSignature = dkim.Value.Replace(tags["b"], "").TrimEnd(); + var signatureLine = CanonicalForTest("DKIM-Signature", withoutSignature); + var builder = new StringBuilder(); + + if (!rfcOrder) + { + builder.Append(signatureLine).Append("\r\n"); + } + + foreach (var name in headerList.Split(':', StringSplitOptions.RemoveEmptyEntries)) + { + var header = headers.FirstOrDefault(h => h.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase)); + if (header == default) return false; + builder.Append(CanonicalForTest(header.Name, header.Value)).Append("\r\n"); + } + + if (rfcOrder) + { + // RFC 6376 §3.7:签名头放最后,且不带结尾 CRLF + builder.Append(signatureLine); + } + + var publicKey = RSA.Create(); + publicKey.ImportSubjectPublicKeyInfo(signer.PublicKeyBytes, out _); + // 与签名端一致用 Latin1(字节保真):ASCII 会把 8bit 头里的非 ASCII 字符替换成 '?' + return publicKey.VerifyData(Encoding.Latin1.GetBytes(builder.ToString()), + Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + + private static byte[] CanonicalBodyForTest(byte[] body) + { + var text = Encoding.Latin1.GetString(body).Replace("\r\n", "\n").Replace('\r', '\n'); + var lines = text.Split('\n').Select(l => CollapseForTest(l).TrimEnd(' ', '\t')); + var joined = string.Join("\r\n", lines).TrimEnd('\r', '\n'); + if (joined.Length > 0) joined += "\r\n"; + return Encoding.Latin1.GetBytes(joined); + } + + private static string CanonicalForTest(string name, string value) + { + var unfolded = value.Replace("\r\n", "").Replace("\n", ""); + return name.Trim().ToLowerInvariant() + ":" + CollapseForTest(unfolded).Trim(); + } + + private static string CollapseForTest(string value) + { + var builder = new StringBuilder(); + var inWhitespace = false; + foreach (var c in value) + { + if (c is ' ' or '\t') { if (!inWhitespace) builder.Append(' '); inWhitespace = true; } + else { builder.Append(c); inWhitespace = false; } + } + return builder.ToString(); + } + + private static (List<(string Name, string Value)> Headers, byte[] Body) SplitForTest(byte[] message) + { + var headers = new List<(string, string)>(); + var index = 0; + string? name = null; + var value = new StringBuilder(); + while (index < message.Length) + { + var lineEnd = Array.IndexOf(message, (byte)'\n', index); + if (lineEnd < 0) lineEnd = message.Length; + var line = Encoding.Latin1.GetString(message, index, lineEnd - index).TrimEnd('\r'); + index = lineEnd + 1; + if (line.Length == 0) break; + if ((line[0] == ' ' || line[0] == '\t') && name is not null) { value.Append("\r\n").Append(line); continue; } + if (name is not null) headers.Add((name, value.ToString())); + var colon = line.IndexOf(':'); + if (colon <= 0) { name = null; value.Clear(); continue; } + name = line[..colon].Trim(); + value.Clear().Append(line[(colon + 1)..]); + } + if (name is not null) headers.Add((name, value.ToString())); + return (headers, message[Math.Min(index, message.Length)..]); + } + + private static string RawHeader(byte[] raw, string name) + { + var (headers, _) = SplitForTest(raw); + var header = headers.FirstOrDefault(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + return header == default ? "" : header.Value.Trim(); + } + + // ---------------------------------------------------------------- 断言 + + private static void Check(string title, bool ok, string detail) + { + if (ok) + { + passed++; + Console.WriteLine($" [通过] {title}"); + } + else + { + failed++; + Console.WriteLine($" [失败] {title}" + (string.IsNullOrEmpty(detail) ? "" : $" —— {detail}")); + } + } + + private static void Fail(string title, string detail) => Check(title, false, detail); +} diff --git a/server-native-v2/SelfTestAccounts.cs b/server-native-v2/SelfTestAccounts.cs new file mode 100644 index 0000000..1e48388 --- /dev/null +++ b/server-native-v2/SelfTestAccounts.cs @@ -0,0 +1,293 @@ +using System.Text.Json; + +namespace WpywMail.Native; + +/// +/// 账号体系自检:注册(含验证码)、登录失败锁定、密码重置、会话管理、资料与审计。 +/// +/// 这些用例**不经过 HTTP**,直接打 AccountService + 存储层,所以跑得快、 +/// 也能覆盖「两套存储后端行为一致」这一点(json 与 sqlite 各跑一遍)。 +/// 验证码是随机的、只能从邮件正文里拿;这里为了可测,直接从存储里读回 +/// 哈希校验逻辑无法反推明文 —— 所以改为**注入式**:测试里用一个已知码替换哈希。 +/// +public static partial class SelfTest +{ + private static void TestAccounts() + { + foreach (var provider in new[] { "json", "sqlite" }) + { + var config = new AppConfig + { + Domain = "wpy.email", + Hostname = "mail.example.com", + AdminEmail = "accounts@wpy.email", + AdminPassword = "selftest-password-1234", + DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-acct-{provider}-{Guid.NewGuid().ToString("N")[..8]}"), + Dkim = new DkimConfig { Enabled = false }, + Storage = new StorageConfig { Provider = provider }, + }; + Directory.CreateDirectory(config.DataDirectory); + IMailStore? store = null; + try + { + config.Accounts = new AccountsConfig + { + Registration = "invite", + InviteCode = "TEST-INVITE", + // 两个域各司其职:wpy.email 由本机托管(免验证路径),mail.example.net 托管在别处(走验证码路径) + AllowedDomains = ["wpy.email", "mail.example.net"], + RequireEmailVerification = true, + MinPasswordLength = 12, + CodeMinutes = 30, + MaxCodeAttempts = 3, + MaxLoginFailures = 3, + LockoutMinutes = 15, + RegisterPerHourPerIp = 5, + ResendPerHourPerEmail = 5, + }; + store = Program.CreateStore(config); + var accounts = new AccountService(config, store); + + // ---- 密码策略 + Check($"[{provider}] 密码太短被拒", !accounts.CheckPassword("short", "a@wpy.email").Ok, ""); + Check($"[{provider}] 纯数字密码被拒", !accounts.CheckPassword("123456789012", "a@wpy.email").Ok, ""); + Check($"[{provider}] 与邮箱相同的密码被拒", !accounts.CheckPassword("a@wpy.email", "a@wpy.email").Ok, ""); + Check($"[{provider}] 合格密码被接受", accounts.CheckPassword("Str0ng-Pass-2026", "a@wpy.email").Ok, ""); + + // ---- 域名白名单 + Check($"[{provider}] 域名校验:允许 wpy.email", + accounts.Register(new RegisterRequest("new1@wpy.email", "Str0ng-Pass-2026", "新人", "TEST-INVITE"), "10.0.0.1", "test").Ok, ""); + var badDomain = accounts.Register(new RegisterRequest("someone@example.com", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.2", "test"); + Check($"[{provider}] 域名校验:拒绝外部域", !badDomain.Ok && badDomain.Status == 400, badDomain.Error); + + // ---- 邀请码 + var badInvite = accounts.Register(new RegisterRequest("new2@wpy.email", "Str0ng-Pass-2026", "", "WRONG"), "10.0.0.3", "test"); + Check($"[{provider}] 邀请码错误被拒", !badInvite.Ok && badInvite.Status == 403, badInvite.Error); + + // ---- 弱密码 + var weak = accounts.Register(new RegisterRequest("new3@wpy.email", "123", "", "TEST-INVITE"), "10.0.0.4", "test"); + Check($"[{provider}] 注册时弱密码被拒", !weak.Ok && weak.Status == 400, weak.Error); + + // ---- 【设计决定】本机托管的地址必须免邮箱验证直接开通 + // 验证码邮件只能投进「这个」信箱,而它在验证通过前登录不了(IMAP/Webmail/API 全进不去) + // → 要验证就是死循环。这类地址的授权凭据是管理员发放的邀请码。 + var hostedEmail = $"hosted{provider}@wpy.email"; + var hostedReg = accounts.Register(new RegisterRequest(hostedEmail, "Str0ng-Pass-2026", "本机地址", "TEST-INVITE"), "10.0.0.21", "test"); + Check($"[{provider}] 本机托管地址免邮箱验证直接开通", + hostedReg.Ok && !hostedReg.VerificationRequired, hostedReg.Error); + Check($"[{provider}] 本机托管地址开通后立即可登录", + store.Authenticate(hostedEmail, "Str0ng-Pass-2026") is not null, ""); + Check($"[{provider}] 免验证注册不会残留验证码记录", + store.FindVerificationCode(hostedEmail, "register") is null, ""); + Check($"[{provider}] 托管判定:本机域=true / 外部域=false", + accounts.IsHostedHere("a@wpy.email") && !accounts.IsHostedHere("a@mail.example.net"), ""); + + // ---- 注册 → 验证码 → 激活(外部托管的邮箱:验证码才有意义) + var email = $"reg{provider}@mail.example.net"; + var register = accounts.Register(new RegisterRequest(email, "Str0ng-Pass-2026", "注册用户", "TEST-INVITE"), "10.0.0.5", "test"); + Check($"[{provider}] 注册返回需要邮箱验证", register.Ok && register.VerificationRequired, register.Error); + + // ★ 关键回归:曾经写成「验证通过才建号」,结果本地投递看不到收件人、验证码邮件根本送不到。 + // 正确做法是注册时就把信箱行建出来并置为未激活。 + var pending = store.FindUserAnyState(email); + Check($"[{provider}] 注册后立即建号且处于未激活状态", pending is { Active: false }, + pending is null ? "(无用户行)" : $"active={pending.Active}"); + Check($"[{provider}] 未激活的信箱对本地投递可见", store.IsLocalAddress(email), ""); + Check($"[{provider}] 未激活期间任何密码都不能登录(凭据只存在验证码记录里)", + store.Authenticate(email, "Str0ng-Pass-2026") is null, ""); + + var code = ReadCodeFromOutbox(store, email); + Check($"[{provider}] 验证码邮件已入队且能取出 6 位码", code is { Length: 6 }, code ?? "(空)"); + + // ★ 端到端:这封验证码邮件必须真的能投进那个「未激活」信箱。 + // 原设计(验证通过才建号)就是死在这里 —— 本地投递找不到收件人,用户永远收不到码。 + var codeRaw = ReadQueuedRaw(store, email); + var landed = codeRaw is null ? null : store.DeliverLocal(email, codeRaw, "postmaster@wpy.email"); + Check($"[{provider}] 验证码邮件真的投进了未激活信箱", + landed is not null && landed.OwnerEmail.Equals(email, StringComparison.OrdinalIgnoreCase), + landed is null ? "本地投递返回 null(收件人不可见)" : $"owner={landed.OwnerEmail} folder={landed.Folder} 未读={landed.Unread}"); + + var wrong = accounts.VerifyRegistration(new VerifyCodeRequest(email, "000000"), "10.0.0.5", "test"); + Check($"[{provider}] 错误验证码被拒", !wrong.Ok, wrong.Error); + + var verified = accounts.VerifyRegistration(new VerifyCodeRequest(email, code!), "10.0.0.5", "test"); + Check($"[{provider}] 正确验证码激活账号", verified.Ok && verified.Session is not null, verified.Error); + var created = store.FindUser(email); + Check($"[{provider}] 激活后用户存在且可认证", + created is not null && store.Authenticate(email, "Str0ng-Pass-2026") is not null, ""); + Check($"[{provider}] 激活后验证码被清除", store.FindVerificationCode(email, "register") is null, ""); + + var reuse = accounts.VerifyRegistration(new VerifyCodeRequest(email, code!), "10.0.0.5", "test"); + Check($"[{provider}] 验证码不能用第二次", !reuse.Ok, reuse.Error); + + // ---- 未完成验证的注册可以重来;抢注者无法凭自己提交的密码进去 + var pendEmail = $"pending{provider}@mail.example.net"; + accounts.Register(new RegisterRequest(pendEmail, "First-Pass-2026", "先注册", "TEST-INVITE"), "10.0.0.7", "test"); + var again = accounts.Register(new RegisterRequest(pendEmail, "Second-Pass-2026", "后注册", "TEST-INVITE"), "10.0.0.8", "test"); + Check($"[{provider}] 未激活的注册允许重来(不返回 409)", again.Ok && again.VerificationRequired, again.Error); + Check($"[{provider}] 重来期间两个密码都不能登录(未激活就没有可用凭据)", + store.Authenticate(pendEmail, "First-Pass-2026") is null && store.Authenticate(pendEmail, "Second-Pass-2026") is null, ""); + var pendCode = ReadCodeFromOutbox(store, pendEmail); + var pendVerified = accounts.VerifyRegistration(new VerifyCodeRequest(pendEmail, pendCode!), "10.0.0.8", "test"); + Check($"[{provider}] 验证后生效的是读得到验证码那一方提交的密码", + pendVerified.Ok + && store.Authenticate(pendEmail, "Second-Pass-2026") is not null + && store.Authenticate(pendEmail, "First-Pass-2026") is null, pendVerified.Error); + + // ---- 登录失败时的措辞:卡在验证的人要被告知出路;被停用的人不能被探测出来 + var pendHint = accounts.LoginFailureHint(pendEmail); + Check($"[{provider}] 已激活账号的失败提示是通用 401", + pendHint.Status == 401 && !pendHint.PendingVerification, pendHint.AuditReason); + var hintPending = accounts.LoginFailureHint($"pending2{provider}@mail.example.net"); + Check($"[{provider}] 不存在的账号失败提示是通用 401(不暴露存在性)", + hintPending.Status == 401 && hintPending.AuditReason == "unknown user", hintPending.AuditReason); + accounts.Register(new RegisterRequest($"pending2{provider}@mail.example.net", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.13", "test"); + var hintWait = accounts.LoginFailureHint($"pending2{provider}@mail.example.net"); + Check($"[{provider}] 卡在邮箱验证的账号会被告知出路(403)", + hintWait.Status == 403 && hintWait.PendingVerification, $"{hintWait.Status} {hintWait.AuditReason}"); + + // ---- 管理员停用是权威状态:不能靠重新注册翻回来 + var disabled = $"disabled{provider}@wpy.email"; + store.CreateUser(disabled, "Str0ng-Pass-2026", "停用测试"); + store.SetLastLogin(disabled); + store.SetUserActive(disabled, false); + var reReg = accounts.Register(new RegisterRequest(disabled, "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.14", "test"); + Check($"[{provider}] 被停用的账号不能靠重新注册复活(403)", + !reReg.Ok && reReg.Status == 403, $"{reReg.Status} {reReg.Error}"); + Check($"[{provider}] 被停用的账号登录提示不暴露状态(401)", + accounts.LoginFailureHint(disabled).Status == 401, ""); + Check($"[{provider}] 停用状态没有被注册流程改掉", store.FindUser(disabled) is null, ""); + + // ---- 重复注册(已激活的账号必须被挡) + var dup = accounts.Register(new RegisterRequest(email, "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.6", "test"); + Check($"[{provider}] 已激活账号重复注册被拒(409)", !dup.Ok && dup.Status == 409, dup.Error); + + // ---- 登录失败锁定 + var lockedEmail = "lock@wpy.email"; + store.CreateUser(lockedEmail, "Str0ng-Pass-2026", "锁定测试"); + for (var i = 0; i < config.Accounts.MaxLoginFailures; i++) + accounts.Record(lockedEmail, "10.0.0.9", "login-failed", false, "test"); + Check($"[{provider}] 连续失败后进入锁定", accounts.LockRemainingSeconds(lockedEmail) > 0, + $"剩余 {accounts.LockRemainingSeconds(lockedEmail)}s"); + var fresh = "fresh@wpy.email"; + store.CreateUser(fresh, "Str0ng-Pass-2026", "未锁定"); + Check($"[{provider}] 未失败过的账号不锁定", accounts.LockRemainingSeconds(fresh) == 0, ""); + + // ---- 审计可查 + var events = store.ListAuthEvents(lockedEmail, null, "login-failed", 10); + Check($"[{provider}] 审计记录了登录失败", events.Count == config.Accounts.MaxLoginFailures, $"{events.Count} 条"); + Check($"[{provider}] 按 IP 统计失败次数", store.CountAuthEvents(null, "10.0.0.9", "login-failed", false, 60) == config.Accounts.MaxLoginFailures, ""); + + // ---- 会话管理 + var sessions = store.ListSessions(fresh); + Check($"[{provider}] 新建用户初始无会话", sessions.Count == 0, $"{sessions.Count} 个"); + var s1 = store.CreateSession(fresh, 30); + var s2 = store.CreateSession(fresh, 30); + var s3 = store.CreateSession(fresh, 30); + Check($"[{provider}] 三次登录产生三个会话", store.ListSessions(fresh).Count == 3, ""); + var revoked = store.RemoveSessions(fresh, s2.Token); + Check($"[{provider}] 退出其他设备保留当前(吊销 2 个)", revoked == 2 && store.ListSessions(fresh).Count == 1, $"吊销 {revoked} 个"); + Check($"[{provider}] 保留的正是当前 token", store.GetSession(s2.Token) is not null && store.GetSession(s1.Token) is null, ""); + var all = store.RemoveSessions(fresh, null); + Check($"[{provider}] 全部吊销", all == 1 && store.ListSessions(fresh).Count == 0, $"吊销 {all} 个"); + Check($"[{provider}] 会话视图标记 current", sessions.Count == 0, ""); + + // ---- 资料 + accounts.UpdateProfile(fresh, "新名字"); + var renamed = store.FindUser(fresh); + Check($"[{provider}] 显示名更新生效", renamed?.DisplayName == "新名字", renamed?.DisplayName ?? "(null)"); + + // ---- 密码重置 + var resetEmail = "reset@wpy.email"; + store.CreateUser(resetEmail, "Str0ng-Pass-2026", "重置测试"); + var keep = store.CreateSession(resetEmail, 30); + var (requested, reqError) = accounts.RequestReset(resetEmail, "10.0.0.10"); + Check($"[{provider}] 申请重置成功", requested, reqError); + var resetCode = ReadCodeFromOutbox(store, resetEmail); + Check($"[{provider}] 重置验证码邮件已入队", resetCode is { Length: 6 }, resetCode ?? "(空)"); + var weakReset = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, resetCode!, "123"), "10.0.0.10"); + Check($"[{provider}] 重置时弱密码被拒", !weakReset.Ok && weakReset.Status == 400, weakReset.Error); + var badCode = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, "999999", "New-Str0ng-2026"), "10.0.0.10"); + Check($"[{provider}] 重置时错误验证码被拒", !badCode.Ok, badCode.Error); + var good = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, resetCode!, "New-Str0ng-2026"), "10.0.0.10"); + Check($"[{provider}] 正确验证码重置成功", good.Ok, good.Error); + Check($"[{provider}] 新密码可用", store.Authenticate(resetEmail, "New-Str0ng-2026") is not null, ""); + Check($"[{provider}] 旧密码失效", store.Authenticate(resetEmail, "Str0ng-Pass-2026") is null, ""); + Check($"[{provider}] 重置后旧会话被吊销", store.GetSession(keep.Token) is null, ""); + + // ---- 不暴露账号是否存在 + var unknown = accounts.RequestReset("nobody@wpy.email", "10.0.0.11"); + Check($"[{provider}] 对不存在的邮箱申请重置也返回成功(防枚举)", unknown.Ok, unknown.Error); + + // ---- 关闭注册 + config.Accounts.Registration = "closed"; + var closed = accounts.Register(new RegisterRequest("closed@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.12", "test"); + Check($"[{provider}] 关闭注册后拒绝(403)", !closed.Ok && closed.Status == 403, closed.Error); + + // ---- 限流 + config.Accounts.Registration = "invite"; + config.Accounts.RegisterPerHourPerIp = 2; + + // ★ 真机验收抓到的坑:失败尝试(填错邀请码 / 密码太短)不能吃掉严格配额, + // 否则正常用户表单填错几次就被挡一小时,NAT 下还会连累同 IP 的其他人。 + var failIp = "10.9.9.10"; + for (var i = 0; i < 6; i++) + accounts.Register(new RegisterRequest($"quota{i}-{provider}@wpy.email", "123", "", "TEST-INVITE"), failIp, "test"); + var quota1 = accounts.Register(new RegisterRequest($"quota-a-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test"); + var quota2 = accounts.Register(new RegisterRequest($"quota-b-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test"); + Check($"[{provider}] 表单填错 6 次后仍能正常建号(失败不吃严格配额)", + quota1.Ok && quota2.Ok, $"{quota1.Status}/{quota2.Status}"); + var quota3 = accounts.Register(new RegisterRequest($"quota-c-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test"); + Check($"[{provider}] 成功建号达到配额后照样限流(429)", + !quota3.Ok && quota3.Status == 429, $"{quota3.Status} {quota3.Error}"); + + var ip = "10.9.9.9"; + var r1 = accounts.Register(new RegisterRequest($"rate1-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test"); + var r2 = accounts.Register(new RegisterRequest($"rate2-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test"); + var r3 = accounts.Register(new RegisterRequest($"rate3-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test"); + Check($"[{provider}] 同 IP 超过每小时上限后被限流(429)", + r1.Ok && r2.Ok && !r3.Ok && r3.Status == 429, r3.Error); + } + catch (Exception ex) + { + Fail($"[{provider}] 账号体系自检", $"抛出异常:{ex.Message}"); + } + finally + { + try { store?.Dispose(); } catch { } + try { Directory.Delete(config.DataDirectory, true); } catch { } + } + } + } + + /// 从出站队列里把那封验证码邮件的原文取出来,再解析出 6 位验证码。 + private static string? ReadCodeFromOutbox(IMailStore store, string email) + { + try + { + var raw = ReadQueuedRaw(store, email); + if (raw is null) return null; + var match = System.Text.RegularExpressions.Regex.Match(Mime.Parse(raw).Text ?? "", @"\b(\d{6})\b"); + return match.Success ? match.Groups[1].Value : null; + } + catch { return null; } + } + + /// 取该收件人最近一封出站邮件的原始字节(用于把「投递」也纳入自检)。 + private static byte[]? ReadQueuedRaw(IMailStore store, string email) + { + try + { + var items = new List(); + foreach (var user in store.AllUsers()) items.AddRange(store.ListQueue(user.Email)); + var hit = items + .Where(q => q.Recipients.Any(r => r.Equals(email, StringComparison.OrdinalIgnoreCase))) + .OrderByDescending(q => q.CreatedAt) + .FirstOrDefault(); + if (hit is null) return null; + var message = store.GetById(hit.MessageId); + return message is null ? null : store.ReadRaw(message.RawPath); + } + catch { return null; } + } +} diff --git a/server-native-v2/SelfTestInboundAuth.cs b/server-native-v2/SelfTestInboundAuth.cs new file mode 100644 index 0000000..8e1dc78 --- /dev/null +++ b/server-native-v2/SelfTestInboundAuth.cs @@ -0,0 +1,293 @@ +using System.Text; + +namespace WpywMail.Native; + +/// +/// 入站校验(SPF / DKIM / DMARC)自检。 +/// +/// 关键设计:**DNS 查询走固定记录的桩实现**,所以断言是确定性的、不依赖外网。 +/// 而 DKIM 部分特意包含「篡改必须失败」的反向用例 —— 2026-09-13 的 DKIM 事故就是 +/// 「验签和签名犯了同一个错,于是一直假通过」,任何验签实现都必须能被打假才算数。 +/// +public static partial class SelfTest +{ + private sealed class StubDns : IDnsLookup + { + public readonly Dictionary Txt = new(StringComparer.OrdinalIgnoreCase); + public readonly Dictionary Addresses = new(StringComparer.OrdinalIgnoreCase); + public readonly Dictionary Mx = new(StringComparer.OrdinalIgnoreCase); + + public Task> TxtAsync(string name, CancellationToken token) => + Task.FromResult>(Txt.TryGetValue(name, out var v) ? v : []); + public Task> AddressesAsync(string name, CancellationToken token) => + Task.FromResult>(Addresses.TryGetValue(name, out var v) ? v : []); + public Task> MxAsync(string name, CancellationToken token) => + Task.FromResult>(Mx.TryGetValue(name, out var v) ? v : []); + } + + private static void TestInboundAuth() + { + var config = new AppConfig + { + Domain = "wpy.email", + Hostname = "mail.example.com", + AdminEmail = "wpy@wpy.email", + AdminPassword = "SelfTest-Password-12", + DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-auth-{Guid.NewGuid().ToString("N")[..8]}"), + Dkim = new DkimConfig { Enabled = true, Selector = "sel" }, + InboundAuth = new InboundAuthConfig { DnsTimeoutSeconds = 2 }, + }; + Directory.CreateDirectory(config.DataDirectory); + + try + { + var dns = new StubDns(); + var token = CancellationToken.None; + var cfg = config.InboundAuth; + + // ─────────────────────────── SPF + dns.Txt["strict.example.com"] = ["v=spf1 ip4:203.0.113.0/24 -all"]; + var pass = Spf.EvaluateAsync("203.0.113.5", "mail.example.com", "bob@strict.example.com", dns, cfg, token).Result; + Check("SPF:ip4 CIDR 命中 → pass", pass.Outcome == "pass", $"{pass.Outcome} {pass.Detail}"); + + var fail = Spf.EvaluateAsync("198.51.100.7", "mail.example.com", "bob@strict.example.com", dns, cfg, token).Result; + Check("SPF:ip4 不命中且 -all → fail", fail.Outcome == "fail", $"{fail.Outcome} {fail.Detail}"); + + dns.Txt["soft.example.com"] = ["v=spf1 ~all"]; + var soft = Spf.EvaluateAsync("198.51.100.7", "x", "bob@soft.example.com", dns, cfg, token).Result; + Check("SPF:~all → softfail", soft.Outcome == "softfail", soft.Outcome); + + dns.Txt["neutral.example.com"] = ["v=spf1 ?all"]; + var neutral = Spf.EvaluateAsync("198.51.100.7", "x", "bob@neutral.example.com", dns, cfg, token).Result; + Check("SPF:?all → neutral", neutral.Outcome == "neutral", neutral.Outcome); + + dns.Txt["inc.example.com"] = ["v=spf1 include:_spf.relay.net -all"]; + dns.Txt["_spf.relay.net"] = ["v=spf1 ip4:198.51.100.7 -all"]; + var included = Spf.EvaluateAsync("198.51.100.7", "x", "bob@inc.example.com", dns, cfg, token).Result; + Check("SPF:include 命中 → pass", included.Outcome == "pass", included.Detail); + + dns.Txt["inc2.example.com"] = ["v=spf1 include:_spf.other.net -all"]; + dns.Txt["_spf.other.net"] = ["v=spf1 ip4:203.0.113.1 -all"]; + var notIncluded = Spf.EvaluateAsync("198.51.100.7", "x", "bob@inc2.example.com", dns, cfg, token).Result; + Check("SPF:include 不命中 → 落到 -all 的 fail", notIncluded.Outcome == "fail", notIncluded.Outcome); + + dns.Txt["a.example.com"] = ["v=spf1 a -all"]; + dns.Addresses["a.example.com"] = ["198.51.100.7"]; + var aMatch = Spf.EvaluateAsync("198.51.100.7", "x", "bob@a.example.com", dns, cfg, token).Result; + Check("SPF:a 机制按 A 记录命中", aMatch.Outcome == "pass", aMatch.Outcome); + + dns.Txt["mx.example.com"] = ["v=spf1 mx -all"]; + dns.Mx["mx.example.com"] = ["mx1.example.com"]; + dns.Addresses["mx1.example.com"] = ["198.51.100.7"]; + var mxMatch = Spf.EvaluateAsync("198.51.100.7", "x", "bob@mx.example.com", dns, cfg, token).Result; + Check("SPF:mx 机制按 MX 主机命中", mxMatch.Outcome == "pass", mxMatch.Outcome); + + var missing = Spf.EvaluateAsync("198.51.100.7", "x", "bob@nospf.example.com", dns, cfg, token).Result; + Check("SPF:没有记录 → none", missing.Outcome == "none", missing.Outcome); + + dns.Txt["dup.example.com"] = ["v=spf1 -all", "v=spf1 +all"]; + var dup = Spf.EvaluateAsync("198.51.100.7", "x", "bob@dup.example.com", dns, cfg, token).Result; + Check("SPF:两条记录 → permerror", dup.Outcome == "permerror", dup.Outcome); + + // 查询次数上限:链式 include 超过 10 次必须 permerror + for (var i = 0; i < 14; i++) + dns.Txt[$"chain{i}.example.com"] = [$"v=spf1 include:chain{i + 1}.example.com -all"]; + dns.Txt["chain14.example.com"] = ["v=spf1 ip4:203.0.113.9 -all"]; + var tooMany = Spf.EvaluateAsync("198.51.100.7", "x", "bob@chain0.example.com", dns, cfg, token).Result; + Check("SPF:include 链超过 10 次查询 → permerror", tooMany.Outcome == "permerror", tooMany.Outcome); + + var heloFallback = Spf.EvaluateAsync("198.51.100.7", "helo.example.com", "", dns, cfg, token).Result; + Check("SPF:空 MAIL FROM 时回退用 HELO 域", heloFallback.Domain == "helo.example.com", heloFallback.Domain); + + Check("SPF:组织域/宏展开", + Spf.DomainOf("Bob ") == "example.com" + && Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "bob@ex.com", "h") == "ex.com/ex.com/bob", + Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "bob@ex.com", "h")); + + // ─────────────────────────── DKIM(用本机签名器造真实签名,再打假) + var signer = DkimSigner.Create(config); + Check("DKIM:签名器可用(自检用)", signer is not null, "未启用则跳过后面的验签"); + if (signer is not null) + { + var raw = BuildMessage("from@dkim.example.com", "收件人", "DKIM self-test 主题", "line-one ASCII marker\r\n第二行。\r\n"); + var signed = signer.Sign(raw); + dns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue]; + + var okResult = DkimVerifier.VerifyAllAsync(signed, dns, token).Result; + Check("DKIM:自己签的报文验签通过", okResult.Count == 1 && okResult[0].Outcome == "pass", + okResult.Count == 0 ? "(没找到签名)" : $"{okResult[0].Outcome} {okResult[0].Detail}"); + + // 反向用例 1:改正文(用 ASCII 标记改,避免在 Latin1 视图里搜中文搜不到) + var bad1 = DkimVerifier.VerifyAllAsync(Tamper(signed, "line-one ASCII marker", "line-one TAMPERED"), dns, token).Result; + Check("DKIM:改正文后必须验签失败", bad1.Count > 0 && bad1[0].Outcome == "fail", + bad1.Count == 0 ? "(没找到签名)" : bad1[0].Detail); + + // 反向用例 2:改被签名的头(同样只能用 ASCII 片段做替换 —— Latin1 视图里搜不到 UTF-8 中文) + var bad2 = DkimVerifier.VerifyAllAsync(Tamper(signed, "DKIM self-test", "DKIM tampered!!"), dns, token).Result; + Check("DKIM:改主题后必须验签失败", bad2.Count > 0 && bad2[0].Outcome == "fail", + bad2.Count == 0 ? "(没找到签名)" : bad2[0].Detail); + + // 反向用例 2b:只翻转正文里的一个字节也必须失败(最严格的篡改用例) + var flipped = (byte[])signed.Clone(); + var marker = Encoding.Latin1.GetBytes("line-one ASCII marker"); + var at = IndexOf(flipped, marker); + if (at > 0) flipped[at + 3] ^= 0x01; + var bad2b = DkimVerifier.VerifyAllAsync(flipped, dns, token).Result; + Check("DKIM:正文翻转一个字节必须验签失败", at > 0 && bad2b.Count > 0 && bad2b[0].Outcome == "fail", + at > 0 ? bad2b[0].Detail : "测试标记没找到"); + + // 反向用例 3:DNS 里换成别人的公钥 + var otherConfig = new AppConfig + { + Domain = config.Domain, Hostname = config.Hostname, AdminEmail = config.AdminEmail, + AdminPassword = config.AdminPassword, DataDirectory = config.DataDirectory, + Dkim = new DkimConfig { Enabled = true, Selector = "other" }, + }; + var other = DkimSigner.Create(otherConfig); + dns.Txt[signer.RecordName + "." + config.Domain] = [other!.RecordValue]; + var bad3 = DkimVerifier.VerifyAllAsync(signed, dns, token).Result; + Check("DKIM:公钥不匹配必须验签失败", bad3.Count > 0 && bad3[0].Outcome == "fail", + bad3.Count == 0 ? "(没找到签名)" : bad3[0].Detail); + + // 反向用例 4:公钥被吊销(p= 为空) + dns.Txt[signer.RecordName + "." + config.Domain] = ["v=DKIM1; k=rsa; p="]; + var bad4 = DkimVerifier.VerifyAllAsync(signed, dns, token).Result; + Check("DKIM:公钥 p= 为空(已吊销)→ fail", bad4.Count > 0 && bad4[0].Outcome == "fail", + bad4.Count == 0 ? "(没找到签名)" : bad4[0].Detail); + dns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue]; + + // 加前置头(我们入站校验要往报文前面插 Authentication-Results)不能破坏对方签名 + var withHeaders = InboundAuth.PrependHeaders(signed, "Authentication-Results: mail.example.com; spf=pass\r\nX-Spam-Score: 0\r\n"); + var stillOk = DkimVerifier.VerifyAllAsync(withHeaders, dns, token).Result; + Check("DKIM:前面插入我们自己的头之后,对方签名依然有效", + stillOk.Count == 1 && stillOk[0].Outcome == "pass", $"{stillOk.Count} 个签名"); + + var plain = DkimVerifier.VerifyAllAsync(BuildMessage("a@b.com", "x", "无签名", "正文"), dns, token).Result; + Check("DKIM:没有签名的报文返回空列表", plain.Count == 0, $"{plain.Count} 个"); + } + + // ─────────────────────────── DMARC + dns.Txt["_dmarc.strict2.example.com"] = ["v=DMARC1; p=reject; rua=mailto:dmarc@example.com"]; + var dmarcPass = Dmarc.EvaluateAsync("strict2.example.com", ("pass", "strict2.example.com"), [], dns, token).Result; + Check("DMARC:SPF 对齐通过 + p=reject", dmarcPass.Outcome == "pass" && dmarcPass.Policy == "reject", + $"{dmarcPass.Outcome}/{dmarcPass.Policy}"); + + var dmarcFail = Dmarc.EvaluateAsync("strict2.example.com", ("fail", "evil.net"), [], dns, token).Result; + Check("DMARC:SPF 未对齐 → fail 且带策略", dmarcFail.Outcome == "fail" && dmarcFail.Policy == "reject", + $"{dmarcFail.Outcome}/{dmarcFail.Policy}"); + + dns.Txt["_dmarc.relaxed.example.com"] = ["v=DMARC1; p=quarantine"]; + var relaxedAlign = Dmarc.EvaluateAsync("relaxed.example.com", ("pass", "mail.relaxed.example.com"), [], dns, token).Result; + Check("DMARC:relaxed 对齐(子域算对齐)→ pass", relaxedAlign.Outcome == "pass", relaxedAlign.Detail); + + dns.Txt["_dmarc.strictdomain.example.com"] = ["v=DMARC1; p=none; aspf=s"]; + var strictAlign = Dmarc.EvaluateAsync("strictdomain.example.com", ("pass", "mail.strictdomain.example.com"), [], dns, token).Result; + Check("DMARC:aspf=s 时子域不算对齐 → fail", strictAlign.Outcome == "fail", strictAlign.Detail); + + var dmarcDkim = Dmarc.EvaluateAsync("dkimalign.example.com", ("fail", "x"), [("pass", "dkimalign.example.com")], + new StubDnsWithRecords(("_dmarc.dkimalign.example.com", "v=DMARC1; p=none")), token).Result; + Check("DMARC:SPF 挂了但 DKIM 对齐 → 依然 pass", dmarcDkim.Outcome == "pass", dmarcDkim.Detail); + + var noRecord = Dmarc.EvaluateAsync("nodmarc.example.com", ("fail", "x"), [], dns, token).Result; + Check("DMARC:没有记录 → none", noRecord.Outcome == "none", noRecord.Outcome); + + Check("DMARC:组织域判定(含 com.cn/co.uk 这类多段后缀)", + Dmarc.OrganizationalDomain("a.b.example.co.uk") == "example.co.uk" + && Dmarc.OrganizationalDomain("mail.example.com") == "example.com" + && Dmarc.OrganizationalDomain("news.sina.com.cn") == "sina.com.cn", + Dmarc.OrganizationalDomain("a.b.example.co.uk")); + + // ─────────────────────────── 端到端:干净信 vs 冒名信 + var cleanDns = new StubDns(); + if (signer is not null) + { + var cleanRaw = signer.Sign(BuildMessage("boss@wpy.email", "我", "正常邮件", "正文内容\r\n")); + cleanDns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue]; + cleanDns.Txt["_dmarc." + config.Domain] = ["v=DMARC1; p=reject"]; + cleanDns.Txt[config.Domain] = ["v=spf1 ip4:203.0.113.0/24 -all"]; + + var clean = InboundAuth.CheckAsync(cleanRaw, "203.0.113.9", "mail.example.com", "boss@wpy.email", config, token, cleanDns).Result; + Check("端到端:SPF+DKIM+DMARC 全通过的邮件不判垃圾", + clean.Spf == "pass" && clean.Dkim == "pass" && clean.Dmarc == "pass" && clean.Score == 0 && !clean.Spam, + $"spf={clean.Spf} dkim={clean.Dkim} dmarc={clean.Dmarc} 分数={clean.Score}"); + Check("端到端:Authentication-Results 头写全了", + clean.HeaderBlock.Contains("spf=pass") && clean.HeaderBlock.Contains("dkim=pass") + && clean.HeaderBlock.Contains("dmarc=pass") && clean.HeaderBlock.Contains("X-Spam-Score: 0"), + clean.HeaderBlock.Replace("\r\n", " | ").Trim()); + + // 冒名信:外域 IP 假冒 wpy.email,无 DKIM,DMARC p=reject + var spoof = InboundAuth.CheckAsync(BuildMessage("boss@wpy.email", "我", "我是老板", "把钱转过来"), + "198.51.100.7", "evil.example.net", "boss@wpy.email", config, token, cleanDns).Result; + Check("端到端:冒名邮件被判为垃圾(SPF fail + DMARC 失败)", + spoof.Spf == "fail" && spoof.Dmarc == "fail" && spoof.Spam && spoof.Score >= 4, + $"spf={spoof.Spf} dmarc={spoof.Dmarc} 分数={spoof.Score} 垃圾={spoof.Spam}"); + Check("端到端:默认不拒收(只投垃圾箱,可逆)", !spoof.Reject, $"Reject={spoof.Reject}"); + + var strictConfig = new AppConfig + { + Domain = config.Domain, Hostname = config.Hostname, AdminEmail = config.AdminEmail, + AdminPassword = config.AdminPassword, DataDirectory = config.DataDirectory, + Dkim = config.Dkim, + InboundAuth = new InboundAuthConfig { RejectOnDmarcReject = true }, + }; + var strictSpoof = InboundAuth.CheckAsync(BuildMessage("boss@wpy.email", "我", "我是老板", "把钱转过来"), + "198.51.100.7", "evil.example.net", "boss@wpy.email", strictConfig, token, cleanDns).Result; + Check("端到端:打开 RejectOnDmarcReject 后 p=reject 的冒名信会被拒收", strictSpoof.Reject, $"Reject={strictSpoof.Reject}"); + + // 插入头之后报文仍可正常解析(不能把收信搞坏) + var withAuth = InboundAuth.PrependHeaders(cleanRaw, clean.HeaderBlock); + var reparsed = Mime.Parse(withAuth); + Check("端到端:插入校验头后报文仍能正常解析", + reparsed.Subject == "正常邮件" && reparsed.Text.Contains("正文内容"), $"{reparsed.Subject} / {reparsed.Text.Trim()}"); + } + + Check("端到端:X-Spam-Reason 会说明判垃圾的理由", + InboundAuth.CheckAsync(BuildMessage("boss@wpy.email", "我", "x", "y"), + "198.51.100.7", "evil.example.net", "boss@wpy.email", config, token, cleanDns).Result + .HeaderBlock.Contains("X-Spam-Reason:"), ""); + } + catch (Exception ex) + { + Fail("入站校验自检", $"抛出异常:{ex}"); + } + finally + { + try { Directory.Delete(config.DataDirectory, true); } catch { } + } + } + + /// 只带一条 DNS 记录的一次性桩(用于个别用例)。 + private sealed class StubDnsWithRecords(params (string Name, string Value)[] records) : IDnsLookup + { + public Task> TxtAsync(string name, CancellationToken token) => + Task.FromResult>(records.Where(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + .Select(r => r.Value).ToArray()); + public Task> AddressesAsync(string name, CancellationToken token) => Task.FromResult>([]); + public Task> MxAsync(string name, CancellationToken token) => Task.FromResult>([]); + } + + /// 在字节层面做替换(用 Latin1 视图,1 字符 = 1 字节,不会动到别的字节)。 + private static byte[] Tamper(byte[] raw, string from, string to) => + Encoding.Latin1.GetBytes(Encoding.Latin1.GetString(raw).Replace(from, to)); + + private static int IndexOf(byte[] haystack, byte[] needle) + { + for (var i = 0; i + needle.Length <= haystack.Length; i++) + { + var hit = true; + for (var j = 0; j < needle.Length; j++) + if (haystack[i + j] != needle[j]) { hit = false; break; } + if (hit) return i; + } + return -1; + } + + private static byte[] BuildMessage(string from, string toName, string subject, string body) + { + var text = $"From: <{from}>\r\nTo: \r\nSubject: {subject}\r\n" + + $"Date: {Mime.FormatDate(DateTimeOffset.Now)}\r\nMessage-ID: <{Guid.NewGuid():N}@example.com>\r\n" + + "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n\r\n" + body; + return Encoding.UTF8.GetBytes(text); + } +} diff --git a/server-native-v2/SelfTestStorage.cs b/server-native-v2/SelfTestStorage.cs new file mode 100644 index 0000000..c7e6e70 --- /dev/null +++ b/server-native-v2/SelfTestStorage.cs @@ -0,0 +1,179 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WpywMail.Native; + +/// +/// 存储层自检:**同一套断言在 json 与 sqlite 两个后端上各跑一遍**, +/// 保证两套实现语义一致(这样 Storage.Provider 才能真正做到一键切换/回滚)。 +/// +public static partial class SelfTest +{ + private static void TestStorageBackends() + { + foreach (var provider in new[] { "json", "sqlite" }) + { + var config = new AppConfig + { + Domain = "wpy.email", + Hostname = "mail.example.com", + AdminEmail = "store@wpy.email", + AdminPassword = "selftest-password-1234", + DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-store-{provider}-{Guid.NewGuid().ToString("N")[..8]}"), + Dkim = new DkimConfig { Enabled = false }, + Storage = new StorageConfig { Provider = provider }, + }; + Directory.CreateDirectory(config.DataDirectory); + try { RunStorageChecks(config, provider); } + catch (Exception ex) { Fail($"[{provider}] 存储自检", $"抛出异常:{ex.Message}"); } + finally { try { Directory.Delete(config.DataDirectory, true); } catch { } } + } + } + + private static void RunStorageChecks(AppConfig config, string provider) + { + using var store = Program.CreateStore(config); + var admin = config.AdminEmail; + + Check($"[{provider}] 自动创建的管理员可认证", store.Authenticate(admin, config.AdminPassword) is not null, ""); + Check($"[{provider}] 错误口令被拒绝", store.Authenticate(admin, "wrong-password-1234") is null, ""); + Check($"[{provider}] 本地地址判定", store.IsLocalAddress(admin) && !store.IsLocalAddress("nobody@example.com"), ""); + + // ---- 报文原文往返(逐字节)---- + var raw = Mime.Build(new ComposeRequest("张三 ", "张三", [admin], [], + "存储自检:中文主题", "存储自检正文,包含中文与全角标点()、——。"), config); + var parsed = Mime.Parse(raw); + var first = store.SaveMessage(new MailMessage + { + OwnerEmail = admin, Folder = "inbox", From = parsed.From, To = parsed.To, Subject = parsed.Subject, + Text = parsed.Text, MessageId = parsed.MessageId, Date = DateTimeOffset.UtcNow, Unread = true, + }, raw); + + var readBack = store.ReadRaw(first.RawPath); + Check($"[{provider}] 报文原文逐字节读回", readBack.SequenceEqual(raw), $"{raw.Length} 字节"); + + // ---- UID 单调 ---- + var second = store.SaveMessage(new MailMessage + { + OwnerEmail = admin, Folder = "inbox", From = "b@example.com", To = admin, + Subject = "第二封", Text = "第二封正文", Date = DateTimeOffset.UtcNow.AddSeconds(1), Unread = true, + }, raw); + Check($"[{provider}] IMAP UID 同文件夹内递增", second.Uid == first.Uid + 1, $"{first.Uid} → {second.Uid}"); + Check($"[{provider}] 未读计数正确", store.CountUnseen(admin, "inbox") == 2, $"{store.CountUnseen(admin, "inbox")}"); + + // ---- 标记已读 / 星标 / 统计 ---- + store.MarkRead(admin, first.Id, true); + var unreadAfter = store.CountUnseen(admin, "inbox"); + var stats = store.Stats(admin); + var inboxCount = (int)stats.GetType().GetProperty("inbox")!.GetValue(stats)!; + var unreadCount = (int)stats.GetType().GetProperty("unread")!.GetValue(stats)!; + Check($"[{provider}] 标记已读后未读数下降", unreadAfter == 1 && unreadCount == 1, $"{unreadAfter}/{unreadCount}"); + Check($"[{provider}] 统计的收件箱总数", inboxCount == 2, $"{inboxCount}"); + + store.SetStar(admin, second.Id, true); + var starred = (int)store.Stats(admin).GetType().GetProperty("starred")!.GetValue(store.Stats(admin))!; + Check($"[{provider}] 星标计数", starred == 1, $"{starred}"); + + // ---- 分页与搜索 ---- + var (total, page) = store.ListMessagesPage(admin, "inbox", "", false, false, 1, 0); + Check($"[{provider}] 分页:总数与页大小", total == 2 && page.Count == 1, $"total={total} 页={page.Count}"); + var (unreadTotal, _) = store.ListMessagesPage(admin, "inbox", "", true, false, 10, 0); + Check($"[{provider}] 分页:未读过滤", unreadTotal == 1, $"{unreadTotal}"); + var hits = store.ListMessages(admin, "", "存储自检正文"); + Check($"[{provider}] 中文正文搜索命中", hits.Count == 1, $"命中 {hits.Count}"); + var miss = store.ListMessages(admin, "", "绝对不存在的关键词xyzzy"); + Check($"[{provider}] 搜索不误报", miss.Count == 0, $"命中 {miss.Count}"); + + // ---- 会话 ---- + var session = store.CreateSession(admin, 30); + Check($"[{provider}] 会话创建与校验", store.GetSession(session.Token)?.Email == admin, ""); + store.RemoveSession(session.Token); + Check($"[{provider}] 会话删除后失效", store.GetSession(session.Token) is null, ""); + + // ---- 队列 ---- + var queued = store.QueueOutbound(admin, ["someone@example.com"], "队列自检", "正文", raw); + var due = store.TakeDueQueue(10); + Check($"[{provider}] 出站任务入队并可取出", due.Count == 1 && due[0].MessageId == queued.Id, $"{due.Count} 条"); + if (due.Count > 0) + { + store.CompleteQueue(due[0]); + var queueStats = (int)store.Stats(admin).GetType().GetProperty("queue")!.GetValue(store.Stats(admin))!; + Check($"[{provider}] 完成后队列清空", queueStats == 0, $"{queueStats}"); + var sent = store.GetById(queued.Id)!; + Check($"[{provider}] 发件箱状态更新为 sent", sent.DeliveryStatus == "sent", sent.DeliveryStatus); + } + + // ---- 删除:inbox → trash → 永久 ---- + store.DeleteMessage(admin, first.Id, permanent: false); + var moved = store.GetMessage(admin, first.Id); + Check($"[{provider}] 删除先移入垃圾箱", moved?.Folder == "trash", moved?.Folder ?? "(null)"); + + // 永久删除 + 原文回收:用一封**内容唯一**的报文来验证。 + // (不能拿 first 来验:它的字节被别的邮件/队列项共享,按内容去重后本就不该被回收。) + var doomedRaw = Mime.Build(new ComposeRequest("delete@example.com", "删除自检", [admin], [], + "永久删除自检专用报文 " + Guid.NewGuid().ToString("N")[..8], "这封邮件的字节应当独一无二,删除后原文必须一起消失。"), config); + var doomed = store.SaveMessage(new MailMessage + { + OwnerEmail = admin, Folder = "inbox", From = "delete@example.com", To = admin, + Subject = "永久删除自检", Text = "唯一内容", Date = DateTimeOffset.UtcNow.AddMinutes(1), Unread = false, + }, doomedRaw); + var doomedPath = doomed.RawPath; + store.DeleteMessage(admin, doomed.Id, permanent: true); + Check($"[{provider}] 永久删除后查不到", store.GetMessage(admin, doomed.Id) is null, ""); + Check($"[{provider}] 永久删除同时回收了原文", ThrowsOnMissing(() => store.ReadRaw(doomedPath)), doomedPath); + + // ---- 本地投递(同域收件人)---- + var delivered = store.DeliverLocal(admin, raw, "someone@example.com"); + Check($"[{provider}] 本地投递入库为一封未读邮件", delivered is not null && delivered.Unread, ""); + + // ---- 附件 ---- + var attachmentStore = store.SaveAttachment(Encoding.UTF8.GetBytes("附件内容"), "测试.txt"); + Check($"[{provider}] 附件写入并读回", + Encoding.UTF8.GetString(store.ReadAttachment(attachmentStore)) == "附件内容", ""); + + // ---- SQLite 专属:大对象按内容去重 ---- + if (store is SqliteStore) + { + var a = store.SaveRaw(raw); + var b = store.SaveRaw(raw); + Check("[sqlite] 相同报文内容按哈希去重(同一 blob)", a == b, $"{a} / {b}"); + var report = ((SqliteStore)store).StorageReport(); + Check("[sqlite] 大对象压缩生效(存储小于原文)", + report.BlobStoredBytes > 0 && report.BlobStoredBytes < report.BlobRawBytes, + $"{report.BlobRawBytes} → {report.BlobStoredBytes}"); + Check("[sqlite] 无孤儿大对象或可清理", report.Orphans == 0 || ((SqliteStore)store).Compact() >= 0, + $"孤儿 {report.Orphans}"); + } + + // ---- 彻底删除账号(--purge-user 用的就是这一套;两个后端必须同一契约) + var doomedUser = $"purge-{provider}@wpy.email"; + store.CreateUser(doomedUser, "Str0ng-Pass-2026", "待删账号"); + store.CreateSession(doomedUser, 30); + store.SaveVerificationCode(new VerificationCode + { + Email = doomedUser, Purpose = "register", + Salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)), + CodeHash = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), CreatedAt = DateTimeOffset.UtcNow, + }); + var doomedMail = store.SaveMessage(new MailMessage + { + OwnerEmail = doomedUser, Folder = "inbox", From = "x@example.com", To = doomedUser, + Subject = "待删账号的邮件", Text = "内容 " + Guid.NewGuid().ToString("N")[..8], + Date = DateTimeOffset.UtcNow, Unread = true, + }, null); + foreach (var mail in store.ListMessages(doomedUser, "", "")) store.DeleteMessage(doomedUser, mail.Id, permanent: true); + Check($"[{provider}] 删除账号前邮件确实属于它", store.GetMessage(doomedUser, doomedMail.Id) is null, ""); + Check($"[{provider}] 删除账号返回成功", store.DeleteUser(doomedUser), ""); + Check($"[{provider}] 删除后查不到账号", store.FindUserAnyState(doomedUser) is null, ""); + Check($"[{provider}] 删除后会话与验证码一并清掉", + store.ListSessions(doomedUser).Count == 0 && store.FindVerificationCode(doomedUser, "register") is null, ""); + Check($"[{provider}] 重复删除返回 false(幂等)", !store.DeleteUser(doomedUser), ""); + } + + private static bool ThrowsOnMissing(Action action) + { + try { action(); return false; } + catch { return true; } + } +} diff --git a/server-native-v2/SmtpDataEncoder.cs b/server-native-v2/SmtpDataEncoder.cs new file mode 100644 index 0000000..0cfbb00 --- /dev/null +++ b/server-native-v2/SmtpDataEncoder.cs @@ -0,0 +1,97 @@ +namespace WpywMail.Native; + +/// +/// SMTP DATA 段的线上编码与还原。 +/// +/// 为什么要单独抽出来: +/// DKIM 是对「签名那一刻的确切字节」做的哈希。任何在签名之后改写报文的行为 +/// (例如把裸 LF 换成 CRLF)都会让接收方算出的正文哈希对不上,签名直接失效。 +/// 因此约定:**先规范化行尾 → 再签名 → 传输阶段除 dot-stuffing 外不得改动任何字节**。 +/// 本类同时被发送侧与自检使用,保证两边行为一致。 +/// +internal static class SmtpDataEncoder +{ + private static readonly byte[] Crlf = [13, 10]; + + /// 把报文规范化为统一的 CRLF 行尾形式(不加密、不加终止行)。应在 DKIM 签名之前调用。 + public static byte[] Normalize(byte[] message) + { + using var output = new MemoryStream(message.Length + 16); + var index = 0; + while (index < message.Length) + { + var current = message[index]; + if (current == (byte)'\r') + { + output.Write(Crlf); + index += index + 1 < message.Length && message[index + 1] == (byte)'\n' ? 2 : 1; + continue; + } + if (current == (byte)'\n') + { + output.Write(Crlf); + index++; + continue; + } + output.WriteByte(current); + index++; + } + var bytes = output.ToArray(); + if (bytes.Length == 0 || !EndsWithCrlf(bytes)) + { + var padded = new byte[bytes.Length + 2]; + Buffer.BlockCopy(bytes, 0, padded, 0, bytes.Length); + padded[^2] = 13; + padded[^1] = 10; + return padded; + } + return bytes; + } + + /// + /// 生成 DATA 段实际要写出的字节:行首的点做 dot-stuffing,结尾补 CRLF 与单独一行的 "."。 + /// 除 dot-stuffing 外不改动任何字节,以保证与 DKIM 签名一致。 + /// + public static byte[] Encode(byte[] normalizedMessage) + { + using var output = new MemoryStream(normalizedMessage.Length + 16); + var atLineStart = true; + foreach (var current in normalizedMessage) + { + if (atLineStart && current == (byte)'.') output.WriteByte((byte)'.'); // dot-stuffing + output.WriteByte(current); + atLineStart = current == (byte)'\n'; + } + if (output.Length == 0 || output.GetBuffer()[output.Length - 1] != (byte)'\n') output.Write(Crlf); + output.Write([(byte)'.', 13, 10]); + return output.ToArray(); + } + + /// 接收侧还原:去掉终止行并做 dot-unstuffing(自检用于模拟收件端)。 + public static byte[] Decode(byte[] wire) + { + // 去掉结尾的 ".\r\n" + var end = wire.Length; + if (end >= 3 && wire[end - 3] == (byte)'.' && wire[end - 2] == 13 && wire[end - 1] == 10) + end -= 3; + else if (end >= 2 && wire[end - 2] == (byte)'.' && wire[end - 1] == 10) + end -= 2; + + using var output = new MemoryStream(end); + var atLineStart = true; + for (var index = 0; index < end; index++) + { + var current = wire[index]; + if (atLineStart && current == (byte)'.' && index + 1 < end && wire[index + 1] == (byte)'.') + { + index++; // 去掉填充的点 + } + output.WriteByte(wire[index]); + atLineStart = wire[index] == (byte)'\n'; + } + return output.ToArray(); + } + + private static bool EndsWithCrlf(byte[] bytes) => + bytes.Length >= 2 && bytes[^2] == 13 && bytes[^1] == 10; +} diff --git a/server-native-v2/SmtpReader.cs b/server-native-v2/SmtpReader.cs new file mode 100644 index 0000000..b3ada17 --- /dev/null +++ b/server-native-v2/SmtpReader.cs @@ -0,0 +1,116 @@ +using System.Text; + +namespace WpywMail.Native; + +/// +/// 字节级 SMTP 行读取器。 +/// +/// 为什么不用 StreamReader: +/// 1. StreamReader 只能返回字符串,8bit 正文会被字符集转换破坏; +/// 2. StreamReader 会预读缓冲,若之后改从原始流直接读 DATA,属于正文的字节 +/// 可能已经被吞进它的缓冲区,造成命令/正文错位。 +/// 这里让命令与 DATA 共用同一份缓冲区,从根本上避免这两个问题。 +/// +internal sealed class SmtpReader +{ + private const int MaxCommandLine = 8192; + private static readonly byte[] Crlf = [13, 10]; + + private readonly Stream stream; + private readonly byte[] buffer = new byte[8192]; + private readonly MemoryStream line = new(MaxCommandLine); + private int start; + private int end; + + public SmtpReader(Stream stream) => this.stream = stream; + + /// 读一行命令(不含 CRLF),连接关闭返回 null。 + public async Task ReadLineAsync(CancellationToken token) + { + while (true) + { + if (start >= end) + { + end = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token); + start = 0; + if (end <= 0) return null; + } + + var current = buffer[start++]; + if (current == (byte)'\n') + { + var bytes = line.ToArray(); + line.SetLength(0); + var length = bytes.Length; + if (length > 0 && bytes[length - 1] == (byte)'\r') length--; + return Encoding.ASCII.GetString(bytes, 0, length); + } + + line.WriteByte(current); + if (line.Length > MaxCommandLine) throw new InvalidOperationException("命令行过长,已断开。"); + } + } + + /// 从同一缓冲区读取恰好 length 个字节(IMAP 的 literal 需要)。 + public async Task ReadExactlyAsync(int length, CancellationToken token) + { + if (length < 0) throw new InvalidOperationException("长度非法。"); + var result = new byte[length]; + var written = 0; + while (written < length) + { + if (start >= end) + { + end = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token); + start = 0; + if (end <= 0) throw new IOException("连接在读取 literals 途中关闭。"); + } + var take = Math.Min(length - written, end - start); + Buffer.BlockCopy(buffer, start, result, written, take); + start += take; + written += take; + } + return result; + } + + /// + /// 读取 DATA 段直到单独一行的 "."。按字节忠实处理并做 dot-unstuffing, + /// 行尾统一为 CRLF。超过 maxBytes 抛 InvalidOperationException。 + /// + public async Task ReadDataAsync(int maxBytes, CancellationToken token) + { + var message = new MemoryStream(); + line.SetLength(0); + + while (true) + { + if (start >= end) + { + end = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token); + start = 0; + if (end <= 0) throw new IOException("连接在 DATA 中途关闭。"); + } + + var current = buffer[start++]; + if (current != (byte)'\n') + { + line.WriteByte(current); + if (line.Length > maxBytes) throw new InvalidOperationException("单行过长,已拒绝。"); + continue; + } + + var text = line.ToArray(); + line.SetLength(0); + if (text.Length > 0 && text[^1] == (byte)'\r') text = text[..^1]; + if (text.Length == 1 && text[0] == (byte)'.') break; // DATA 结束 + if (text.Length > 1 && text[0] == (byte)'.') text = text[1..]; // dot-unstuffing + + message.Write(text); + message.Write(Crlf); + if (message.Length > maxBytes) + throw new InvalidOperationException($"邮件超过 {maxBytes / 1024 / 1024} MB 上限,已拒绝。"); + } + + return message.ToArray(); + } +} diff --git a/server-native-v2/SmtpServer.cs b/server-native-v2/SmtpServer.cs new file mode 100644 index 0000000..4f4b869 --- /dev/null +++ b/server-native-v2/SmtpServer.cs @@ -0,0 +1,509 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WpywMail.Native; + +/// +/// SMTP 服务端: +/// 25 端口 —— 公网收信(只接受本地收件人,不中继) +/// 587 端口 —— 已认证的客户端发信 +/// +/// 相比 v1 的关键修正: +/// 1. DATA 阶段按字节读取(v1 用 StreamReader 读文本再拼回,破坏 8bit 内容与行尾); +/// 2. 只要加载到证书就广告 STARTTLS(v1 只在「非自签名」时才广告,而 AUTH 又要求 +/// 加密,导致 587 端口完全无法认证的死锁); +/// 3. 收信时补 Received 头,并对认证失败做临时封禁。 +/// +public sealed class SmtpServer +{ + private static readonly ConcurrentDictionary Failures = new(); + + private readonly AppConfig config; + private readonly IMailStore store; + private readonly X509Certificate2? certificate; + private readonly bool advertiseStartTls; + + public SmtpServer(AppConfig config, IMailStore store) + { + this.config = config; + this.store = store; + + if (!string.IsNullOrWhiteSpace(config.TlsCertificatePath) && File.Exists(config.TlsCertificatePath)) + { + certificate = new X509Certificate2(config.TlsCertificatePath, config.TlsCertificatePassword); + var selfSigned = certificate.Subject.Equals(certificate.Issuer, StringComparison.OrdinalIgnoreCase); + advertiseStartTls = config.Smtp.AdvertiseStartTls; + AppLog.Info($"[SMTP] 已加载 TLS 证书:{certificate.Subject}({(selfSigned ? "自签名" : "受信任")},至 {certificate.NotAfter:yyyy-MM-dd})"); + if (selfSigned) AppLog.Warn("[SMTP] 证书是自签名:STARTTLS 会正常广告,但部分严格客户端会拒绝,建议换取受信任证书。"); + } + else + { + AppLog.Warn("[SMTP] 未找到 TLS 证书;STARTTLS 不可用,587 端口将无法完成认证(AUTH 要求加密)。"); + AppLog.Warn("[SMTP] 请配置 TlsCertificatePath 指向 mail.<你的域名> 的 PFX 证书。"); + } + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + var inbound = new TcpListener(IPAddress.Any, config.SmtpPort); + var submission = new TcpListener(IPAddress.Any, config.SubmissionPort); + inbound.Start(); + submission.Start(); + AppLog.Info($"[SMTP] 收信端口已监听:{config.SmtpPort}"); + AppLog.Info($"[SMTP] 客户端发信端口已监听:{config.SubmissionPort}(需 STARTTLS + AUTH)"); + await Task.WhenAll(AcceptLoop(inbound, false, cancellationToken), AcceptLoop(submission, true, cancellationToken)); + } + + private async Task AcceptLoop(TcpListener listener, bool submission, CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(token); + _ = Task.Run(() => SafeHandleAsync(client, submission, token)); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) { AppLog.Error($"[SMTP] {(submission ? 587 : 25)} 接收循环异常:{ex.Message}"); } + finally { listener.Stop(); } + } + + private async Task SafeHandleAsync(TcpClient client, bool submission, CancellationToken token) + { + try { await HandleClient(client, submission, token); } + catch (Exception ex) { AppLog.Error($"[SMTP] 会话处理异常:{ex.Message}"); } + finally { client.Dispose(); } + } + + private async Task HandleClient(TcpClient client, bool submission, CancellationToken token) + { + var rawStream = client.GetStream(); + Stream stream = rawStream; + var reader = new SmtpReader(stream); + var writer = NewWriter(stream); + var tls = false; + string? authenticatedUser = null; + string? sender = null; + var recipients = new List(); + + var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "未知地址"; + var remote = client.Client.RemoteEndPoint?.ToString() ?? remoteIp; + var clientHelo = ""; + + try + { + if (IsBanned(remoteIp)) + { + AppLog.Warn($"[SMTP] {remoteIp} 已被临时封禁,直接断开。"); + await Send(writer, "421 Too many authentication failures, try again later"); + return; + } + + AppLog.Info($"[SMTP] 收到连接:{remote},模式={(submission ? "客户端发信" : "公网收信")}"); + var banner = submission ? $"220 {config.Hostname} ESMTP WpywMail submission" : $"220 {config.Hostname} ESMTP WpywMail"; + await Send(writer, banner); + + while (!token.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(token); + if (line is null) break; + var upper = line.Trim().ToUpperInvariant(); + + if (upper.StartsWith("EHLO") || upper.StartsWith("HELO")) + { + clientHelo = line[(line.IndexOf(' ') + 1)..].Trim(); + await SendCapabilities(writer, submission, tls); + } + else if (upper == "STARTTLS") + { + if (certificate is null || !advertiseStartTls) + { + await Send(writer, "454 TLS not available"); + continue; + } + await Send(writer, "220 Ready to start TLS"); + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13, + ClientCertificateRequired = false, + }, token); + stream = ssl; + reader = new SmtpReader(stream); + writer = NewWriter(stream); + tls = true; + authenticatedUser = null; + AppLog.Info($"[SMTP] {remote} 已建立 TLS 会话。"); + } + else if (upper.StartsWith("AUTH")) + { + var allowed = submission || config.Smtp.AllowAuthOnInbound; + if (!allowed || !tls) + { + // 关键修复:明确告诉客户端为什么不能认证,而不是让它无解 + await Send(writer, "538 Encryption required for authentication"); + continue; + } + var result = await AuthenticateAsync(line, reader, writer, token); + if (result is null) + { + RegisterFailure(remoteIp); + AppLog.Warn($"[SMTP] {remote} 认证失败。"); + await Send(writer, "535 Authentication failed"); + } + else + { + ClearFailures(remoteIp); + authenticatedUser = result; + AppLog.Info($"[SMTP] {remote} 认证成功:{result}"); + await Send(writer, "235 Authentication successful"); + } + } + else if (upper == "RSET") + { + sender = null; + recipients.Clear(); + await Send(writer, "250 Reset"); + } + else if (upper.StartsWith("NOOP")) + { + await Send(writer, "250 OK"); + } + else if (upper.StartsWith("MAIL FROM:")) + { + // ── 安全修复(2026-09-15)─────────────────────────────────── + // 原实现在这里「先赋值 sender,再做认证校验」,导致 MAIL FROM + // 被 530 拒绝之后 sender 依然非空,于是 RCPT 与 DATA 两处守卫 + // (sender is null)全部失效:未认证客户端可以走完 RCPT + DATA + // 全流程,报文会被 SaveRaw 写进 blob 存储。开放中继之所以没有 + // 真正打通,只是因为随后对 null 账号解引用抛了异常 —— 属偶然, + // 不是设计。现在改为通过全部校验后才赋值。 + var candidate = Mime.Addresses(line).FirstOrDefault() ?? ExtractAddress(line); + sender = null; + recipients.Clear(); + if (submission && authenticatedUser is null) + { + AppLog.Warn($"[SMTP] {remote} 未认证就尝试发信:{candidate}(已拒绝)"); + await Send(writer, "530 Authentication required"); + } + else if (submission && config.Smtp.EnforceSenderMatch && + !string.Equals(candidate, authenticatedUser, StringComparison.OrdinalIgnoreCase)) + { + AppLog.Warn($"[SMTP] {remote} 发件人 {candidate} 与已认证账号 {authenticatedUser} 不匹配。"); + await Send(writer, "553 Sender must match authenticated mailbox"); + } + else + { + sender = candidate; + AppLog.Info($"[SMTP] {remote} MAIL FROM:{sender}"); + await Send(writer, "250 2.1.0 Sender accepted"); + } + } + else if (upper.StartsWith("RCPT TO:")) + { + var recipient = Mime.Addresses(line).FirstOrDefault() ?? ExtractAddress(line); + if (sender is null) + { + await Send(writer, "503 5.5.1 Need MAIL FROM first"); + } + else if (submission && authenticatedUser is null) + { + // 安全修复(2026-09-15):提交端口必须已认证才允许指定收件人。 + // 原实现只在 MAIL FROM 处拦未认证,收件人完全不校验,且中继 + // 检查写成 !submission,于是 587 上外域地址也会被 250 接受。 + AppLog.Warn($"[SMTP] {remote} 未认证的提交会话尝试指定收件人:{recipient}(已拒绝)"); + await Send(writer, "530 Authentication required"); + } + else if (!submission && !store.IsLocalAddress(recipient)) + { + AppLog.Warn($"[SMTP] {remote} 非本地收件人被拒绝:{recipient}"); + await Send(writer, "550 5.7.1 Relay denied"); + } + else + { + recipients.Add(recipient); + AppLog.Info($"[SMTP] {remote} RCPT TO:{recipient}"); + await Send(writer, "250 2.1.5 Recipient accepted"); + } + } + else if (upper == "DATA") + { + if (sender is null || recipients.Count == 0) + { + await Send(writer, "503 5.5.1 Need sender and recipient"); + continue; + } + if (submission && authenticatedUser is null) + { + // 安全修复(2026-09-15):双保险。即使前面的状态机被绕过, + // 也绝不让未认证会话进入数据阶段 —— 否则读取到的报文会先被 + // SaveRaw 落盘,形成未认证、无限速、可并发的写盘路径。 + AppLog.Error($"[SMTP] {remote} 未认证的提交会话尝试 DATA,已拒绝。"); + await Send(writer, "530 Authentication required"); + sender = null; + recipients.Clear(); + continue; + } + await Send(writer, "354 End data with ."); + byte[] raw; + try + { + raw = await reader.ReadDataAsync(config.Smtp.MaxMessageBytes, token); + } + catch (InvalidOperationException ex) + { + await Send(writer, $"552 5.3.4 {ex.Message}"); + sender = null; + recipients.Clear(); + continue; + } + + await StoreIncomingAsync(raw, submission, sender, recipients, authenticatedUser, remoteIp, remote, clientHelo, token); + await Send(writer, "250 2.0.0 Message accepted"); + sender = null; + recipients.Clear(); + } + else if (upper == "QUIT") + { + await Send(writer, "221 Bye"); + break; + } + else + { + await Send(writer, "502 5.5.2 Command not implemented"); + } + } + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or ObjectDisposedException) { } + catch (Exception ex) { AppLog.Error($"[SMTP] {remote} 会话错误:{ex.Message}"); } + finally + { + rawStream.Dispose(); + } + } + + /// 把收到的报文落库;submission 模式则进入发件队列。 + private async Task StoreIncomingAsync(byte[] raw, bool submission, string sender, List recipients, + string? authenticatedUser, string remoteIp, string remote, string helo, CancellationToken token) + { + if (config.Smtp.AddReceivedHeader && !submission) + { + raw = AddReceivedHeader(raw, remoteIp, recipients); + } + + if (submission) + { + if (string.IsNullOrEmpty(authenticatedUser)) + { + // 安全修复(2026-09-15):提交模式必须有已认证账号。 + // 原实现直接使用 authenticatedUser!(null 宽容运算符),在未认证 + // 路径下传入 null,最终在 SqliteStore.InsertMessageLocked 里对 + // OwnerEmail 解引用抛 NullReferenceException —— 落盘已经发生, + // 异常只是恰好阻止了入队。 + AppLog.Error($"[SMTP] 拒绝入队:提交会话没有已认证账号(发件人 {sender})。"); + return; + } + var queued = Mime.Parse(raw); + // 客户端提交的报文按原样排队(DKIM 在投递时签名),但正文/主题仍解析入库便于列表展示 + store.QueueOutbound(authenticatedUser!, recipients.ToArray(), + queued.Subject, queued.Text, raw, queued.Cc, queued.Html, queued.InReplyTo); + AppLog.Info($"[SMTP] 已进入发件队列:{authenticatedUser} → {string.Join(", ", recipients)},主题:{queued.Subject}"); + return; + } + + // ── 入站身份校验:SPF / DKIM / DMARC(默认只标注 + 投垃圾箱,不拒收) + InboundAuthVerdict? verdict = null; + if (config.InboundAuth.Enabled) + { + try + { + verdict = await InboundAuth.CheckAsync(raw, remoteIp, helo, sender, config, token); + raw = InboundAuth.PrependHeaders(raw, verdict.HeaderBlock); + AppLog.Info($"[入站校验] {sender} → {string.Join(", ", recipients)}:spf={verdict.Spf}({verdict.SpfDomain}) " + + $"dkim={verdict.Dkim} dmarc={verdict.Dmarc}(p={verdict.DmarcPolicy}) 分数={verdict.Score}" + + (verdict.Spam ? " → 投垃圾箱" : "")); + foreach (var reason in verdict.Reasons) AppLog.Warn($"[入站校验] {sender}:{reason}"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // 校验自身失败不能影响收信:宁可放过也不丢信 + AppLog.Error($"[入站校验] 出错(按未判定处理):{ex.Message}"); + } + } + + var parsed = Mime.Parse(raw); + var folder = verdict is { Spam: true } ? "spam" : "inbox"; + + foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (store.FindUser(recipient) is null) continue; + + 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 = store.SaveAttachment(attachment.Data, attachment.FileName), + ContentId = attachment.ContentId, + Inline = attachment.Inline, + }); + } + + store.SaveMessage(new MailMessage + { + OwnerEmail = recipient.ToLowerInvariant(), + Folder = folder, + 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 = verdict is { Spam: true } ? "received-spam" : "received", + Unread = true, + Attachments = attachments, + }, raw); + } + + AppLog.Info($"[SMTP] 已接收邮件:{sender} → {string.Join(", ", recipients)},主题:{parsed.Subject}" + + $"({(folder == "spam" ? "垃圾箱" : "收件箱")}{parsed.Attachments.Count switch { > 0 => $",附件 {parsed.Attachments.Count} 个", _ => "" }})"); + } + + private byte[] AddReceivedHeader(byte[] raw, string remoteIp, List recipients) + { + var header = $"Received: from {remoteIp} by {config.Hostname} with ESMTP id {Guid.NewGuid():N} " + + $"for <{recipients.FirstOrDefault()}>; {Mime.FormatDate(DateTimeOffset.Now)}{"\r\n"}"; + var output = new MemoryStream(raw.Length + header.Length + 16); + output.Write(Encoding.ASCII.GetBytes(header)); + output.Write(raw); + return output.ToArray(); + } + + private async Task SendCapabilities(StreamWriter writer, bool submission, bool tls) + { + var capabilities = new List + { + $"SIZE {config.Smtp.MaxMessageBytes}", + "8BITMIME", + "PIPELINING", + "ENHANCEDSTATUSCODES", + }; + if (certificate is not null && advertiseStartTls && !tls) capabilities.Add("STARTTLS"); + if (tls && (submission || config.Smtp.AllowAuthOnInbound)) capabilities.Add("AUTH PLAIN LOGIN"); + + await Send(writer, $"250-{config.Hostname}"); + for (var i = 0; i < capabilities.Count; i++) + { + var prefix = i == capabilities.Count - 1 ? "250 " : "250-"; + await Send(writer, prefix + capabilities[i]); + } + } + + private async Task AuthenticateAsync(string command, SmtpReader reader, StreamWriter writer, CancellationToken token) + { + var parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + string? email = null; + string? password = null; + + if (parts.Length >= 2 && parts[1].Equals("PLAIN", StringComparison.OrdinalIgnoreCase)) + { + var payload = parts.Length >= 3 ? parts[2] : null; + if (string.IsNullOrEmpty(payload)) + { + await Send(writer, "334 "); + payload = (await reader.ReadLineAsync(token) ?? "").Trim(); + } + var bytes = TryBase64(payload); + if (bytes is null) return null; + var values = Encoding.UTF8.GetString(bytes).Split('\0'); + email = values.Length > 1 ? values[1] : null; + password = values.Length > 2 ? values[2] : null; + } + else if (parts.Length >= 2 && parts[1].Equals("LOGIN", StringComparison.OrdinalIgnoreCase)) + { + string? userPayload = parts.Length >= 3 ? parts[2] : null; + if (string.IsNullOrEmpty(userPayload)) + { + await Send(writer, "334 VXNlcm5hbWU6"); + userPayload = (await reader.ReadLineAsync(token) ?? "").Trim(); + } + await Send(writer, "334 UGFzc3dvcmQ6"); + var passwordPayload = (await reader.ReadLineAsync(token) ?? "").Trim(); + + var userBytes = TryBase64(userPayload); + var passwordBytes = TryBase64(passwordPayload); + if (userBytes is null || passwordBytes is null) return null; + email = Encoding.UTF8.GetString(userBytes); + password = Encoding.UTF8.GetString(passwordBytes); + } + else + { + await Send(writer, "504 5.5.4 Authentication mechanism not supported"); + return null; + } + + return store.Authenticate(email ?? "", password ?? "")?.Email; + } + + private static byte[]? TryBase64(string value) + { + try { return Convert.FromBase64String(value.Trim()); } + catch { return null; } + } + + // ---------------------------------------------------------------- 认证失败封禁 + + private sealed record Failure(int Count, DateTimeOffset Until); + + private static bool IsBanned(string ip) + { + if (!Failures.TryGetValue(ip, out var failure)) return false; + if (failure.Until > DateTimeOffset.UtcNow) return true; + Failures.TryRemove(ip, out _); + return false; + } + + private void RegisterFailure(string ip) + { + var threshold = Math.Max(1, config.Smtp.AuthFailuresBeforeBan); + var ban = TimeSpan.FromMinutes(Math.Max(1, config.Smtp.BanMinutes)); + Failures.AddOrUpdate(ip, + _ => new Failure(1, DateTimeOffset.MinValue), + (_, existing) => new Failure(existing.Count + 1, existing.Count + 1 >= threshold ? DateTimeOffset.UtcNow.Add(ban) : existing.Until)); + + if (Failures.TryGetValue(ip, out var current) && current.Count >= threshold) + AppLog.Warn($"[SMTP] {ip} 认证失败 {current.Count} 次,已临时封禁 {ban.TotalMinutes:0} 分钟。"); + } + + private static void ClearFailures(string ip) => Failures.TryRemove(ip, out _); + + // ---------------------------------------------------------------- 工具 + + private static string ExtractAddress(string command) + { + var start = command.IndexOf('<'); + var end = command.IndexOf('>', start + 1); + return start >= 0 && end > start + ? command[(start + 1)..end].Trim().ToLowerInvariant() + : command[(command.IndexOf(':') + 1)..].Trim().ToLowerInvariant(); + } + + // 控制通道只传 ASCII 命令;DATA 走 SmtpReader 的字节通道 + private static StreamWriter NewWriter(Stream stream) => new(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = "\r\n" }; + private static Task Send(StreamWriter writer, string value) => writer.WriteLineAsync(value); +} diff --git a/server-native-v2/SqliteStore.cs b/server-native-v2/SqliteStore.cs new file mode 100644 index 0000000..93590a4 --- /dev/null +++ b/server-native-v2/SqliteStore.cs @@ -0,0 +1,1752 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Data.Sqlite; + +namespace WpywMail.Native; + +/// +/// SQLite 存储后端:邮件元数据、用户、会话、出站队列全部进库(带索引与事务), +/// 原始报文(.eml)与附件仍以文件形式存放在 DataDirectory 下。 +/// +/// 相比 的关键差别: +/// 1. 单条记录的改动只写单行(UPDATE),不再把整个邮箱重新序列化 → 去掉 O(N) 放大; +/// 2. UID 分配是 MAX(uid)+1 的索引查询,不再扫描全部邮件; +/// 3. 列表/未读数/统计走索引,搜索限定在该账号范围内; +/// 4. 事务保证崩溃时不会写坏索引文件(JSON 实现靠 .tmp + Move 兜底,但没有跨文件一致性); +/// 5. WAL 模式:读写不互相阻塞。 +/// +public sealed class SqliteStore : IMailStore, IDisposable +{ + private readonly object gate = new(); + private readonly SqliteConnection connection; + private readonly string rawDirectory; + private readonly string attachmentDirectory; + private readonly AppConfig config; + private long version; + + public string DatabasePath { get; } + + public SqliteStore(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); + + DatabasePath = string.IsNullOrWhiteSpace(config.Storage.DatabasePath) + ? Path.Combine(config.DataDirectory, "wpywmail.db") + : config.Storage.DatabasePath; + Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!); + + connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = DatabasePath, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Private, + Pooling = false, + }.ToString()); + connection.Open(); + + Execute("PRAGMA journal_mode=WAL;"); + Execute("PRAGMA synchronous=NORMAL;"); + Execute("PRAGMA foreign_keys=ON;"); + Execute("PRAGMA busy_timeout=5000;"); + if (config.Storage.WalAutoCheckpointPages > 0) + Execute($"PRAGMA wal_autocheckpoint={config.Storage.WalAutoCheckpointPages};"); + + CreateSchema(); + SetupFullTextSearch(); + Recover(); + EnsureAdmin(); + version = ReadMetaLong("version"); + AppLog.Info($"[存储] SQLite 已打开:{DatabasePath}(v{version})"); + } + + // ---------------------------------------------------------------- 基础 + + private void Execute(string sql) + { + lock (gate) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + } + + private static SqliteCommand Cmd(SqliteConnection conn, string sql, params (string Name, object? Value)[] args) + { + var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var (name, value) in args) cmd.Parameters.AddWithValue(name, value ?? DBNull.Value); + return cmd; + } + + private void Mutate(string sql, params (string Name, object? Value)[] args) + { + lock (gate) + { + using var cmd = Cmd(connection, sql, args); + cmd.ExecuteNonQuery(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + } + + private T Scalar(string sql, T fallback, params (string Name, object? Value)[] args) + { + lock (gate) + { + using var cmd = Cmd(connection, sql, args); + var value = cmd.ExecuteScalar(); + if (value is null || value is DBNull) return fallback; + try { return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); } + catch { return fallback; } + } + } + + private static string Ts(DateTimeOffset value) => value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); + + private static DateTimeOffset ParseTs(object? value, DateTimeOffset fallback) => + value is string text && DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed) + ? parsed + : fallback; + + private long ReadMetaLong(string key) => Scalar($"SELECT value FROM meta WHERE key=@k", 0L, ("@k", key)); + + private void WriteMetaLocked(string key, string value) + { + using var cmd = Cmd(connection, + "INSERT INTO meta(key,value) VALUES(@k,@v) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + ("@k", key), ("@v", value)); + cmd.ExecuteNonQuery(); + } + + private void CreateSchema() + { + Execute(""" + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS users ( + email TEXT PRIMARY KEY COLLATE NOCASE, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user', + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + last_login_at TEXT NULL + ); + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL COLLATE NOCASE, + folder TEXT NOT NULL COLLATE NOCASE, + uid INTEGER NOT NULL DEFAULT 0, + from_addr TEXT NOT NULL DEFAULT '', + to_addr TEXT NOT NULL DEFAULT '', + cc_addr TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL DEFAULT '', + text_body TEXT NOT NULL DEFAULT '', + html_body TEXT NOT NULL DEFAULT '', + raw_path TEXT NOT NULL DEFAULT '', + message_id TEXT NOT NULL DEFAULT '', + in_reply_to TEXT NOT NULL DEFAULT '', + refs TEXT NOT NULL DEFAULT '', + date_utc TEXT NOT NULL, + received_at TEXT NOT NULL, + unread INTEGER NOT NULL DEFAULT 1, + starred INTEGER NOT NULL DEFAULT 0, + delivery_status TEXT NOT NULL DEFAULT 'received', + last_error TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + dkim_signed INTEGER NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX IF NOT EXISTS ux_messages_owner_folder_uid ON messages(owner_email, folder, uid); + CREATE INDEX IF NOT EXISTS ix_messages_owner_folder_date ON messages(owner_email, folder, date_utc DESC); + CREATE INDEX IF NOT EXISTS ix_messages_owner_unread ON messages(owner_email, folder, unread); + CREATE INDEX IF NOT EXISTS ix_messages_owner_starred ON messages(owner_email, starred); + CREATE INDEX IF NOT EXISTS ix_messages_owner_status ON messages(owner_email, delivery_status); + -- 只建有查询真的会用的索引:message_id / raw_path 当前没有任何 SQL 按它们检索, + -- 建了只会白占空间(本机磁盘紧张),需要时再加。 + CREATE TABLE IF NOT EXISTS attachments ( + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + file_name TEXT NOT NULL DEFAULT '', + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL DEFAULT 0, + stored_as TEXT NOT NULL DEFAULT '', + content_id TEXT NOT NULL DEFAULT '', + inline INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (message_id, position) + ); + CREATE INDEX IF NOT EXISTS ix_attachments_stored_as ON attachments(stored_as); + CREATE TABLE IF NOT EXISTS queue ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL DEFAULT '', + owner_email TEXT NOT NULL DEFAULT '' COLLATE NOCASE, + recipients TEXT NOT NULL DEFAULT '[]', + attempts INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + next_attempt TEXT NOT NULL, + last_attempt_at TEXT NULL, + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT NOT NULL DEFAULT '', + last_code INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS ix_queue_due ON queue(status, next_attempt); + CREATE INDEX IF NOT EXISTS ix_queue_owner ON queue(owner_email); + CREATE INDEX IF NOT EXISTS ix_queue_message ON queue(message_id); + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + email TEXT NOT NULL COLLATE NOCASE, + expires TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_sessions_expires ON sessions(expires); + -- 账号体系:邮箱验证码(只存哈希)与认证审计 + CREATE TABLE IF NOT EXISTS verification_codes ( + email TEXT NOT NULL COLLATE NOCASE, + purpose TEXT NOT NULL, + code_hash TEXT NOT NULL, + salt TEXT NOT NULL, + payload TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + sent_at TEXT, + PRIMARY KEY (email, purpose) + ); + CREATE TABLE IF NOT EXISTS auth_events ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL DEFAULT '' COLLATE NOCASE, + ip TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + success INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_auth_events_at ON auth_events(at DESC); + CREATE INDEX IF NOT EXISTS ix_auth_events_email ON auth_events(email, reason); + CREATE INDEX IF NOT EXISTS ix_auth_events_ip ON auth_events(ip, reason); + -- 早期版本建过两个没人用的索引,这里幂等清掉(老库也会被回收空间) + DROP INDEX IF EXISTS ix_messages_message_id; + DROP INDEX IF EXISTS ix_messages_raw_path; + -- 报文原文与附件的大对象表:按内容 SHA256 去重,能压就压(省磁盘)。 + -- raw/ 与 attachments/ 目录在 SQLite 模式下**不再写入文件**。 + CREATE TABLE IF NOT EXISTS blobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sha256 TEXT NOT NULL UNIQUE, + raw_size INTEGER NOT NULL, + stored_size INTEGER NOT NULL, + gzipped INTEGER NOT NULL DEFAULT 0, + data BLOB NOT NULL, + created_at TEXT NOT NULL + ); + """); + } + + // ---------------------------------------------------------------- 全文检索(可选) + + private bool fullText; + + /// + /// 按配置尝试建立 FTS5(trigram) 全文索引。 + /// 失败(SQLite 未编译 FTS5 / 版本过低)时静默降级为 LIKE 扫描,不影响功能。 + /// + private void SetupFullTextSearch() + { + if (!config.Storage.FullTextSearch) return; + try + { + Execute("CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(" + + "subject, from_addr, to_addr, text_body, " + + "content='messages', content_rowid='rowid', tokenize='trigram');"); + Execute(""" + CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, subject, from_addr, to_addr, text_body) + VALUES (new.rowid, new.subject, new.from_addr, new.to_addr, new.text_body); + END; + CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, subject, from_addr, to_addr, text_body) + VALUES ('delete', old.rowid, old.subject, old.from_addr, old.to_addr, old.text_body); + END; + CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, subject, from_addr, to_addr, text_body) + VALUES ('delete', old.rowid, old.subject, old.from_addr, old.to_addr, old.text_body); + INSERT INTO messages_fts(rowid, subject, from_addr, to_addr, text_body) + VALUES (new.rowid, new.subject, new.from_addr, new.to_addr, new.text_body); + END; + """); + + // 索引为空但已有邮件(首次启用)时灌一次;之后靠触发器增量维护 + var indexed = Scalar("SELECT COUNT(1) FROM messages_fts", 0L); + var stored = Scalar("SELECT COUNT(1) FROM messages", 0L); + if (indexed == 0 && stored > 0) + { + Execute("INSERT INTO messages_fts(messages_fts) VALUES('rebuild');"); + AppLog.Info($"[存储] 已为 {stored} 封历史邮件建立全文索引。"); + } + fullText = true; + AppLog.Info("[存储] 全文检索已启用(FTS5 / trigram)。"); + } + catch (Exception ex) + { + fullText = false; + AppLog.Warn($"[存储] 全文索引不可用,搜索将回退为 LIKE 扫描:{ex.Message}"); + } + } + + /// 构造 WHERE 子句与参数;搜索在可用时走 FTS,否则回退 LIKE。 + private (string Where, List<(string Name, object? Value)> Args) BuildFilter( + string owner, string folder, string query, bool unreadOnly, bool starredOnly) + { + var where = "WHERE owner_email=@o"; + var args = new List<(string, object?)> { ("@o", owner ?? "") }; + if (!string.IsNullOrEmpty(folder)) { where += " AND folder=@f"; args.Add(("@f", folder.ToLowerInvariant())); } + if (unreadOnly) where += " AND unread=1"; + if (starredOnly) where += " AND starred=1"; + + query = (query ?? "").Trim(); + if (query.Length > 0) + { + if (fullText && query.Length >= 3) + { + // trigram 分词器:把查询当整体做子串匹配,需要加引号避免被当成 FTS 语法 + where += " AND rowid IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH @q)"; + args.Add(("@q", "\"" + query.Replace("\"", "\"\"") + "\"")); + } + else + { + where += " AND (from_addr LIKE @q OR to_addr LIKE @q OR cc_addr LIKE @q OR subject LIKE @q OR text_body LIKE @q)"; + args.Add(("@q", "%" + query + "%")); + } + } + return (where, args); + } + + // ---------------------------------------------------------------- 大对象(报文原文 / 附件) + + private const string BlobPrefix = "db:"; + + /// 存入大对象表并返回伪路径 db:<id>;相同内容自动复用(内容寻址去重)。 + private string SaveBlobLocked(byte[] data) + { + var hash = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + + using (var probe = Cmd(connection, "SELECT id FROM blobs WHERE sha256=@h", ("@h", hash))) + { + var existing = probe.ExecuteScalar(); + if (existing is not null and not DBNull) return BlobPrefix + Convert.ToInt64(existing, CultureInfo.InvariantCulture); + } + + // 只有压缩确实有收益(≥5%)才存压缩版:JPEG/ZIP 这类已压缩数据不会被白折腾 + var gz = Gzip(data); + var useGzip = gz.Length < data.Length * 0.95; + + using var cmd = Cmd(connection, + "INSERT INTO blobs(sha256,raw_size,stored_size,gzipped,data,created_at) VALUES(@h,@r,@s,@g,@d,@c)", + ("@h", hash), ("@r", data.Length), ("@s", useGzip ? gz.Length : data.Length), + ("@g", useGzip ? 1 : 0), ("@d", useGzip ? gz : data), ("@c", Ts(DateTimeOffset.UtcNow))); + cmd.ExecuteNonQuery(); + + long id; + using (var last = Cmd(connection, "SELECT last_insert_rowid()")) + id = Convert.ToInt64(last.ExecuteScalar(), CultureInfo.InvariantCulture); + return BlobPrefix + id; + } + + private byte[] ReadBlobLocked(long id) + { + using var cmd = Cmd(connection, "SELECT gzipped, data FROM blobs WHERE id=@id", ("@id", id)); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) throw new InvalidOperationException($"报文数据不存在(blob {id})。"); + var gzipped = reader.GetInt64(0) != 0; + var bytes = (byte[])reader.GetValue(1); + return gzipped ? Gunzip(bytes) : bytes; + } + + /// 清理没有任何消息/附件引用的孤儿大对象。 + private int PurgeOrphanBlobsLocked() + { + using var cmd = Cmd(connection, + "DELETE FROM blobs WHERE id NOT IN (" + + " SELECT CAST(substr(raw_path,4) AS INTEGER) FROM messages WHERE raw_path LIKE 'db:%' " + + " UNION SELECT CAST(substr(stored_as,4) AS INTEGER) FROM attachments WHERE stored_as LIKE 'db:%')"); + return cmd.ExecuteNonQuery(); + } + + private static byte[] Gzip(byte[] data) + { + using var output = new MemoryStream(); + using (var gz = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal, true)) + gz.Write(data, 0, data.Length); + return output.ToArray(); + } + + private static byte[] Gunzip(byte[] data) + { + using var input = new MemoryStream(data); + using var gz = new System.IO.Compression.GZipStream(input, System.IO.Compression.CompressionMode.Decompress); + using var output = new MemoryStream(); + gz.CopyTo(output); + return output.ToArray(); + } + + /// 启动恢复:把上次异常退出留下的 processing 回退成 retry;清掉过期会话;补 UID。 + private void Recover() + { + lock (gate) + { + using (var cmd = Cmd(connection, "UPDATE queue SET status='retry', next_attempt=@now WHERE status='processing'", + ("@now", Ts(DateTimeOffset.UtcNow)))) + cmd.ExecuteNonQuery(); + + using (var cmd = Cmd(connection, "DELETE FROM sessions WHERE expires < @now", ("@now", Ts(DateTimeOffset.UtcNow)))) + cmd.ExecuteNonQuery(); + + // 给 uid=0 的历史邮件补号:同一账号 + 同一文件夹按时间递增 + var groups = new List<(string Owner, string Folder)>(); + using (var cmd = Cmd(connection, "SELECT DISTINCT owner_email, folder FROM messages WHERE uid=0")) + using (var reader = cmd.ExecuteReader()) + while (reader.Read()) groups.Add((reader.GetString(0), reader.GetString(1))); + + foreach (var (owner, folder) in groups) + { + var next = Scalar("SELECT COALESCE(MAX(uid),0)+1 FROM messages WHERE owner_email=@o AND folder=@f", + 1L, ("@o", owner), ("@f", folder)); + var ids = new List(); + using (var cmd = Cmd(connection, "SELECT id FROM messages WHERE owner_email=@o AND folder=@f AND uid=0 ORDER BY date_utc, rowid", + ("@o", owner), ("@f", folder))) + using (var reader = cmd.ExecuteReader()) + while (reader.Read()) ids.Add(reader.GetString(0)); + foreach (var id in ids) + { + using var cmd = Cmd(connection, "UPDATE messages SET uid=@u WHERE id=@id", ("@u", next++), ("@id", id)); + cmd.ExecuteNonQuery(); + } + } + } + } + + private void EnsureAdmin() + { + var existing = FindUser(config.AdminEmail); + lock (gate) + { + if (existing is not null) + { + if (!VerifyPassword(config.AdminPassword, existing.PasswordHash, existing.PasswordSalt)) + { + var salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + using var cmd = Cmd(connection, "UPDATE users SET password_salt=@s, password_hash=@h WHERE email=@e", + ("@s", salt), ("@h", HashPassword(config.AdminPassword, salt)), ("@e", existing.Email)); + cmd.ExecuteNonQuery(); + 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); + lock (gate) + { + using var cmd = Cmd(connection, + "INSERT OR IGNORE INTO users(email,display_name,role,password_hash,password_salt,active,created_at,last_login_at) " + + "VALUES(@e,@d,@r,@h,@s,1,@c,NULL)", + ("@e", user.Email), ("@d", user.DisplayName), ("@r", user.Role), + ("@h", user.PasswordHash), ("@s", user.PasswordSalt), ("@c", Ts(user.CreatedAt))); + cmd.ExecuteNonQuery(); + } + AppLog.Info($"[存储] 已创建管理员邮箱:{user.Email}"); + } + + public long Version { get { lock (gate) return version; } } + + // ---------------------------------------------------------------- 用户 + + private static MailUser MapUser(SqliteDataReader r) => new() + { + Email = r.GetString(0), + DisplayName = r.GetString(1), + Role = r.GetString(2), + PasswordHash = r.GetString(3), + PasswordSalt = r.GetString(4), + Active = r.GetInt64(5) != 0, + CreatedAt = ParseTs(r.IsDBNull(6) ? null : r.GetString(6), DateTimeOffset.UtcNow), + LastLoginAt = r.IsDBNull(7) ? null : ParseTs(r.GetString(7), DateTimeOffset.UtcNow), + }; + + private const string UserColumns = "email, display_name, role, password_hash, password_salt, active, created_at, last_login_at"; + + public MailUser? FindUser(string email) + { + var key = (email ?? "").Trim(); + if (key.Length == 0) return null; + lock (gate) + { + using var cmd = Cmd(connection, $"SELECT {UserColumns} FROM users WHERE email=@e AND active=1", ("@e", key)); + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapUser(reader) : null; + } + } + + public MailUser? FindUserAnyState(string email) + { + var key = (email ?? "").Trim(); + if (key.Length == 0) return null; + lock (gate) + { + using var cmd = Cmd(connection, $"SELECT {UserColumns} FROM users WHERE email=@e", ("@e", key)); + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapUser(reader) : null; + } + } + + 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; + Mutate("UPDATE users SET last_login_at=@t WHERE email=@e", ("@t", Ts(DateTimeOffset.UtcNow)), ("@e", user.Email)); + user.LastLoginAt = DateTimeOffset.UtcNow; + return user; + } + + public bool IsLocalAddress(string email) + { + var key = (email ?? "").Trim(); + if (key.Length == 0) return false; + return Scalar("SELECT COUNT(1) FROM users WHERE email=@e", 0L, ("@e", key)) > 0; + } + + public IReadOnlyList ListUsers() + { + lock (gate) + { + var list = new List(); + using var cmd = Cmd(connection, $"SELECT {UserColumns} FROM users ORDER BY email"); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) list.Add(MapUser(reader)); + return list; + } + } + + public MailUser CreateUser(string email, string password, string displayName) + { + email = (email ?? "").Trim().ToLowerInvariant(); + if (!email.Contains('@')) throw new InvalidOperationException("邮箱地址不合法。"); + if (Scalar("SELECT COUNT(1) FROM users WHERE email=@e", 0L, ("@e", email)) > 0) + 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); + Mutate("INSERT INTO users(email,display_name,role,password_hash,password_salt,active,created_at,last_login_at) " + + "VALUES(@e,@d,@r,@h,@s,1,@c,NULL)", + ("@e", user.Email), ("@d", user.DisplayName), ("@r", user.Role), + ("@h", user.PasswordHash), ("@s", user.PasswordSalt), ("@c", Ts(user.CreatedAt))); + 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("密码哈希不能为空。"); + + var existing = FindUserAnyState(email); + if (existing is not null) + { + // 允许「上次注册没验证完」的账号重新注册:覆盖密码与显示名,保持未激活 + if (existing.Active) throw new InvalidOperationException("用户已存在。"); + Mutate("UPDATE users SET display_name=@d, password_hash=@h, password_salt=@s WHERE email=@e", + ("@d", displayName ?? existing.DisplayName), ("@h", passwordHash), ("@s", passwordSalt), ("@e", email)); + existing.DisplayName = displayName ?? existing.DisplayName; + existing.PasswordHash = passwordHash; + existing.PasswordSalt = passwordSalt; + return existing; + } + + var user = new MailUser + { + Email = email, + DisplayName = displayName ?? "", + PasswordHash = passwordHash, + PasswordSalt = passwordSalt, + Active = active, + CreatedAt = DateTimeOffset.UtcNow, + }; + Mutate("INSERT INTO users(email,display_name,role,password_hash,password_salt,active,created_at,last_login_at) " + + "VALUES(@e,@d,@r,@h,@s,@a,@c,NULL)", + ("@e", user.Email), ("@d", user.DisplayName), ("@r", user.Role), + ("@h", user.PasswordHash), ("@s", user.PasswordSalt), ("@a", user.Active ? 1 : 0), + ("@c", Ts(user.CreatedAt))); + return user; + } + + public void ChangePassword(string email, string password) { + var salt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + var hash = HashPassword(password, salt); + lock (gate) + { + using var cmd = Cmd(connection, "UPDATE users SET password_salt=@s, password_hash=@h WHERE email=@e", + ("@s", salt), ("@h", hash), ("@e", (email ?? "").Trim())); + if (cmd.ExecuteNonQuery() == 0) throw new InvalidOperationException("用户不存在。"); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + } + + public void SetUserActive(string email, bool active) => + Mutate("UPDATE users SET active=@a WHERE email=@e", ("@a", active ? 1 : 0), ("@e", (email ?? "").Trim())); + + public bool DeleteUser(string email) + { + var key = (email ?? "").Trim(); + if (key.Length == 0) return false; + if (FindUserAnyState(key) is null) return false; + RemoveSessions(key, null); + Mutate("DELETE FROM verification_codes WHERE email=@e", ("@e", key)); + Mutate("DELETE FROM queue WHERE owner_email=@e", ("@e", key)); + Mutate("DELETE FROM users WHERE email=@e", ("@e", key)); + 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) + { + using (var cmd = Cmd(connection, "DELETE FROM sessions WHERE expires < @now", ("@now", Ts(DateTimeOffset.UtcNow)))) + cmd.ExecuteNonQuery(); + using (var cmd = Cmd(connection, "INSERT INTO sessions(token,email,expires,created_at) VALUES(@t,@e,@x,@c)", + ("@t", record.Token), ("@e", record.Email), ("@x", Ts(record.Expires)), ("@c", Ts(record.CreatedAt)))) + cmd.ExecuteNonQuery(); + } + return record; + } + + public SessionRecord? GetSession(string? token) + { + if (string.IsNullOrWhiteSpace(token)) return null; + lock (gate) + { + using var cmd = Cmd(connection, "SELECT token,email,expires,created_at FROM sessions WHERE token=@t", ("@t", token)); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) return null; + var record = new SessionRecord + { + Token = reader.GetString(0), + Email = reader.GetString(1), + Expires = ParseTs(reader.GetString(2), DateTimeOffset.UtcNow), + CreatedAt = ParseTs(reader.GetString(3), DateTimeOffset.UtcNow), + }; + if (record.Expires >= DateTimeOffset.UtcNow) return record; + } + RemoveSession(token); + return null; + } + + public void RemoveSession(string token) + { + if (string.IsNullOrWhiteSpace(token)) return; + Mutate("DELETE FROM sessions WHERE token=@t", ("@t", token)); + } + + public IReadOnlyList ListSessions(string email) + { + var key = (email ?? "").Trim(); + if (key.Length == 0) return []; + lock (gate) + { + var list = new List(); + using var cmd = Cmd(connection, + "SELECT token,email,expires,created_at FROM sessions WHERE email=@e AND expires >= @now ORDER BY created_at DESC", + ("@e", key), ("@now", Ts(DateTimeOffset.UtcNow))); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + list.Add(new SessionRecord + { + Token = reader.GetString(0), + Email = reader.GetString(1), + Expires = ParseTs(reader.GetString(2), DateTimeOffset.UtcNow), + CreatedAt = ParseTs(reader.GetString(3), DateTimeOffset.UtcNow), + }); + } + return list; + } + } + + public int RemoveSessions(string email, string? keepToken) + { + var key = (email ?? "").Trim(); + if (key.Length == 0) return 0; + lock (gate) + { + using var cmd = string.IsNullOrWhiteSpace(keepToken) + ? Cmd(connection, "DELETE FROM sessions WHERE email=@e", ("@e", key)) + : Cmd(connection, "DELETE FROM sessions WHERE email=@e AND token<>@t", ("@e", key), ("@t", keepToken)); + var removed = cmd.ExecuteNonQuery(); + if (removed > 0) + { + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + return removed; + } + } + + // ---------------------------------------------------------------- 原始报文与附件 + // + // SQLite 模式下报文原文与附件都存进 blobs 表(gzip + 内容去重),**不再落任何文件**。 + // 对外仍然是「SaveXxx 返回一个相对路径、ReadXxx 按该路径取回」的语义, + // 只是路径形态变成 db:,上层(API / IMAP / 投递队列)完全不用改。 + + public string SaveRaw(byte[] raw) + { + lock (gate) return SaveBlobLocked(raw); + } + + public byte[] ReadRaw(string relativePath) + { + if (IsBlobPath(relativePath)) { lock (gate) return ReadBlobLocked(BlobId(relativePath)); } + return ReadInside(rawDirectory, relativePath); // 迁移期间仍可能指向老的 raw/*.eml + } + + public string SaveAttachment(byte[] data, string suggestedName) + { + lock (gate) return SaveBlobLocked(data); + } + + public byte[] ReadAttachment(string relativePath) + { + if (IsBlobPath(relativePath)) { lock (gate) return ReadBlobLocked(BlobId(relativePath)); } + return ReadInside(attachmentDirectory, relativePath); + } + + private static bool IsBlobPath(string? path) => + path is not null && path.StartsWith(BlobPrefix, StringComparison.OrdinalIgnoreCase); + + private static long BlobId(string path) => + long.TryParse(path.AsSpan(BlobPrefix.Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) + ? id : throw new InvalidOperationException($"非法的大对象路径:{path}"); + + /// 存储占用统计(给 --storage-status / 基准测试用)。 + public StoreUsage StorageReport() + { + lock (gate) + { + long ScalarLong(string sql) + { + using var cmd = Cmd(connection, sql); + var value = cmd.ExecuteScalar(); + return value is null or DBNull ? 0 : Convert.ToInt64(value, CultureInfo.InvariantCulture); + } + + var files = new FileInfo(DatabasePath); + var wal = new FileInfo(DatabasePath + "-wal"); + var pageSize = ScalarLong("SELECT page_size FROM pragma_page_size()"); + var pageCount = ScalarLong("SELECT page_count FROM pragma_page_count()"); + var freePages = ScalarLong("SELECT freelist_count FROM pragma_freelist_count()"); + return new StoreUsage + { + Provider = "sqlite", + Database = DatabasePath, + DbBytes = files.Exists ? files.Length : 0, + WalBytes = wal.Exists ? wal.Length : 0, + Blobs = ScalarLong("SELECT COUNT(1) FROM blobs"), + BlobRawBytes = ScalarLong("SELECT COALESCE(SUM(raw_size),0) FROM blobs"), + BlobStoredBytes = ScalarLong("SELECT COALESCE(SUM(stored_size),0) FROM blobs"), + BlobGzipped = ScalarLong("SELECT COUNT(1) FROM blobs WHERE gzipped=1"), + Messages = ScalarLong("SELECT COUNT(1) FROM messages"), + Orphans = ScalarLong( + "SELECT COUNT(1) FROM blobs WHERE id NOT IN (" + + " SELECT CAST(substr(raw_path,4) AS INTEGER) FROM messages WHERE raw_path LIKE 'db:%' " + + " UNION SELECT CAST(substr(stored_as,4) AS INTEGER) FROM attachments WHERE stored_as LIKE 'db:%')"), + TextBytes = ScalarLong("SELECT COALESCE(SUM(LENGTH(text_body)+LENGTH(html_body)),0) FROM messages"), + FreeBytes = freePages * pageSize, + TotalBytes = pageCount * pageSize, + PageCount = pageCount, + PageSize = pageSize, + LargestTextBytes = ScalarLong("SELECT COALESCE(MAX(LENGTH(text_body)+LENGTH(html_body)),0) FROM messages"), + LargestTextSubject = LargestTextSubjectLocked(), + }; + } + } + + /// 删除孤儿大对象,返回删除条数。 + public int Compact() + { + lock (gate) return PurgeOrphanBlobsLocked(); + } + + private string LargestTextSubjectLocked() + { + using var cmd = Cmd(connection, + "SELECT subject FROM messages ORDER BY LENGTH(text_body)+LENGTH(html_body) DESC LIMIT 1"); + var value = cmd.ExecuteScalar(); + return value is null or DBNull ? "" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + } + + 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); + } + + // ---------------------------------------------------------------- 邮件 + + private const string MessageColumns = + "id, owner_email, folder, uid, from_addr, to_addr, cc_addr, subject, text_body, html_body, raw_path, " + + "message_id, in_reply_to, refs, date_utc, received_at, unread, starred, delivery_status, last_error, size, dkim_signed"; + + private static MailMessage MapMessage(SqliteDataReader r) => new() + { + Id = r.GetString(0), + OwnerEmail = r.GetString(1), + Folder = r.GetString(2), + Uid = (int)r.GetInt64(3), + From = r.GetString(4), + To = r.GetString(5), + Cc = r.GetString(6), + Subject = r.GetString(7), + Text = r.GetString(8), + Html = r.GetString(9), + RawPath = r.GetString(10), + MessageId = r.GetString(11), + InReplyTo = r.GetString(12), + References = r.GetString(13), + Date = ParseTs(r.GetString(14), DateTimeOffset.UtcNow), + ReceivedAt = ParseTs(r.GetString(15), DateTimeOffset.UtcNow), + Unread = r.GetInt64(16) != 0, + Starred = r.GetInt64(17) != 0, + DeliveryStatus = r.GetString(18), + LastError = r.GetString(19), + Size = r.GetInt64(20), + DkimSigned = r.GetInt64(21) != 0, + }; + + private int NextUidLocked(string owner, string folder) => (int)Scalar( + "SELECT COALESCE(MAX(uid),0)+1 FROM messages WHERE owner_email=@o AND folder=@f", 1L, + ("@o", owner ?? ""), ("@f", folder ?? "")); + + /// 在已持有 gate 的前提下插入一行邮件(含附件)。 + private void InsertMessageLocked(MailMessage message) + { + using var tx = connection.BeginTransaction(); + using (var cmd = Cmd(connection, + "INSERT INTO messages(" + MessageColumns + ") VALUES(" + + "@id,@owner,@folder,@uid,@from,@to,@cc,@subject,@text,@html,@raw,@mid,@irt,@refs,@date,@recv,@unread,@starred,@status,@err,@size,@dkim)", + ("@id", message.Id), ("@owner", message.OwnerEmail.ToLowerInvariant()), ("@folder", message.Folder.ToLowerInvariant()), + ("@uid", message.Uid), ("@from", message.From ?? ""), ("@to", message.To ?? ""), ("@cc", message.Cc ?? ""), + ("@subject", message.Subject ?? ""), ("@text", message.Text ?? ""), ("@html", message.Html ?? ""), + ("@raw", message.RawPath ?? ""), ("@mid", message.MessageId ?? ""), ("@irt", message.InReplyTo ?? ""), + ("@refs", message.References ?? ""), ("@date", Ts(message.Date)), ("@recv", Ts(message.ReceivedAt)), + ("@unread", message.Unread ? 1 : 0), ("@starred", message.Starred ? 1 : 0), + ("@status", message.DeliveryStatus ?? "received"), ("@err", message.LastError ?? ""), + ("@size", message.Size), ("@dkim", message.DkimSigned ? 1 : 0))) + { + cmd.Transaction = tx; + cmd.ExecuteNonQuery(); + } + + var position = 0; + foreach (var attachment in message.Attachments) + { + using var cmd = Cmd(connection, + "INSERT INTO attachments(message_id,position,file_name,content_type,size,stored_as,content_id,inline) " + + "VALUES(@m,@p,@f,@c,@s,@a,@i,@n)", + ("@m", message.Id), ("@p", position++), ("@f", attachment.FileName ?? ""), + ("@c", attachment.ContentType ?? "application/octet-stream"), ("@s", attachment.Size), + ("@a", attachment.StoredAs ?? ""), ("@i", attachment.ContentId ?? ""), ("@n", attachment.Inline ? 1 : 0)); + cmd.Transaction = tx; + cmd.ExecuteNonQuery(); + } + + tx.Commit(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + + public MailMessage SaveMessage(MailMessage message, byte[]? raw = null) + { + if (raw is not null) + { + message.RawPath = SaveRaw(raw); + message.Size = raw.Length; + } + message.OwnerEmail = (message.OwnerEmail ?? "").ToLowerInvariant(); + message.Folder = (message.Folder ?? "inbox").ToLowerInvariant(); + lock (gate) + { + if (message.Uid == 0) message.Uid = NextUidLocked(message.OwnerEmail, message.Folder); + InsertMessageLocked(message); + } + return message; + } + + private void AttachLocked(IReadOnlyList messages, Dictionary> map) + { + foreach (var message in messages) + if (map.TryGetValue(message.Id, out var list)) message.Attachments = list; + } + + private Dictionary> AttachmentMapLocked(IEnumerable ids) + { + var ids2 = ids.ToArray(); + var map = new Dictionary>(StringComparer.Ordinal); + if (ids2.Length == 0) return map; + foreach (var chunk in ids2.Chunk(400)) + { + var names = chunk.Select((_, i) => "@p" + i).ToArray(); + var args = chunk.Select((id, i) => ("@p" + i, (object?)id)).ToArray(); + using var cmd = Cmd(connection, + $"SELECT message_id,file_name,content_type,size,stored_as,content_id,inline FROM attachments " + + $"WHERE message_id IN ({string.Join(",", names)}) ORDER BY message_id, position", args); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var key = reader.GetString(0); + if (!map.TryGetValue(key, out var list)) map[key] = list = []; + list.Add(new Attachment + { + FileName = reader.GetString(1), + ContentType = reader.GetString(2), + Size = reader.GetInt64(3), + StoredAs = reader.GetString(4), + ContentId = reader.GetString(5), + Inline = reader.GetInt64(6) != 0, + }); + } + } + return map; + } + + private List QueryMessagesLocked(string sql, params (string Name, object? Value)[] args) + { + var list = new List(); + using (var cmd = Cmd(connection, sql, args)) + using (var reader = cmd.ExecuteReader()) + while (reader.Read()) list.Add(MapMessage(reader)); + AttachLocked(list, AttachmentMapLocked(list.Select(m => m.Id))); + return list; + } + + public IReadOnlyList ListMessages(string owner, string folder, string query) + { + lock (gate) + { + var (where, args) = BuildFilter(owner, folder, query, unreadOnly: false, starredOnly: false); + return QueryMessagesLocked($"SELECT {MessageColumns} FROM messages {where} ORDER BY date_utc DESC", args.ToArray()); + } + } + + /// 分页查询:COUNT 与取页都下推到 SQL,附带的条件(未读/星标/搜索)同样在库内完成。 + public (int Total, IReadOnlyList Messages) ListMessagesPage( + string owner, string folder, string query, bool unreadOnly, bool starredOnly, int limit, int offset) + { + lock (gate) + { + var (where, args) = BuildFilter(owner, folder, query, unreadOnly, starredOnly); + + int total; + using (var countCmd = Cmd(connection, $"SELECT COUNT(1) FROM messages {where}", args.ToArray())) + total = Convert.ToInt32(countCmd.ExecuteScalar() ?? 0, CultureInfo.InvariantCulture); + + var pageArgs = new List<(string, object?)>(args) + { + ("@limit", Math.Max(1, limit)), + ("@offset", Math.Max(0, offset)), + }; + var page = QueryMessagesLocked( + $"SELECT {MessageColumns} FROM messages {where} ORDER BY date_utc DESC LIMIT @limit OFFSET @offset", + pageArgs.ToArray()); + return (total, page); + } + } + + public MailMessage? GetMessage(string owner, string id) + { + lock (gate) + { + var list = QueryMessagesLocked( + $"SELECT {MessageColumns} FROM messages WHERE owner_email=@o AND id=@id", ("@o", owner ?? ""), ("@id", id ?? "")); + return list.Count > 0 ? list[0] : null; + } + } + + public MailMessage? GetById(string id) + { + lock (gate) + { + var list = QueryMessagesLocked($"SELECT {MessageColumns} FROM messages WHERE id=@id", ("@id", id ?? "")); + return list.Count > 0 ? list[0] : null; + } + } + + public MailMessage? GetByUid(string owner, string folder, int uid) + { + lock (gate) + { + var list = QueryMessagesLocked( + $"SELECT {MessageColumns} FROM messages WHERE owner_email=@o AND folder=@f AND uid=@u", + ("@o", owner ?? ""), ("@f", (folder ?? "").ToLowerInvariant()), ("@u", uid)); + return list.Count > 0 ? list[0] : null; + } + } + + public bool MarkRead(string owner, string id, bool read) + { + lock (gate) + { + using var cmd = Cmd(connection, "UPDATE messages SET unread=@u WHERE owner_email=@o AND id=@id", + ("@u", read ? 0 : 1), ("@o", owner ?? ""), ("@id", id ?? "")); + if (cmd.ExecuteNonQuery() == 0) return false; + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + return true; + } + } + + public bool SetStar(string owner, string id, bool starred) + { + lock (gate) + { + using var cmd = Cmd(connection, "UPDATE messages SET starred=@s WHERE owner_email=@o AND id=@id", + ("@s", starred ? 1 : 0), ("@o", owner ?? ""), ("@id", id ?? "")); + if (cmd.ExecuteNonQuery() == 0) return false; + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + return true; + } + } + + public bool MoveMessage(string owner, string id, string folder) + { + lock (gate) + { + var message = GetMessage(owner, id); + if (message is null) return false; + var target = (folder ?? "inbox").ToLowerInvariant(); + var uid = target == message.Folder ? message.Uid : NextUidLocked(message.OwnerEmail, target); + using var cmd = Cmd(connection, "UPDATE messages SET folder=@f, uid=@u WHERE id=@id", + ("@f", target), ("@u", uid), ("@id", message.Id)); + cmd.ExecuteNonQuery(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + return true; + } + } + + public bool DeleteMessage(string owner, string id, bool permanent) + { + lock (gate) + { + var message = GetMessage(owner, id); + if (message is null) return false; + if (permanent) DeleteRowLocked(message); + else MoveMessage(owner, id, "trash"); + return true; + } + } + + private void DeleteRowLocked(MailMessage message) + { + using (var cmd = Cmd(connection, "DELETE FROM messages WHERE id=@id", ("@id", message.Id))) + cmd.ExecuteNonQuery(); + if (!IsBlobPath(message.RawPath)) TryDelete(Path.Combine(config.DataDirectory, message.RawPath)); + foreach (var attachment in message.Attachments) + if (!IsBlobPath(attachment.StoredAs)) TryDelete(Path.Combine(config.DataDirectory, attachment.StoredAs)); + PurgeOrphanBlobsLocked(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + + // ---------------------------------------------------------------- 迁移专用导入 + // 这些方法保留原始字段(密码哈希、UID、队列状态、会话令牌),供 JSON → SQLite 无损迁移使用。 + + /// 按原样导入用户(保留既有密码哈希与盐,不重新计算)。 + public void ImportUser(MailUser user) + { + lock (gate) + { + using var cmd = Cmd(connection, + "INSERT OR REPLACE INTO users(email,display_name,role,password_hash,password_salt,active,created_at,last_login_at) " + + "VALUES(@e,@d,@r,@h,@s,@a,@c,@l)", + ("@e", (user.Email ?? "").ToLowerInvariant()), ("@d", user.DisplayName ?? ""), ("@r", user.Role ?? "user"), + ("@h", user.PasswordHash), ("@s", user.PasswordSalt), ("@a", user.Active ? 1 : 0), + ("@c", Ts(user.CreatedAt)), ("@l", user.LastLoginAt.HasValue ? Ts(user.LastLoginAt.Value) : null)); + cmd.ExecuteNonQuery(); + } + } + + /// 按原样导入队列项(保留状态、重试次数与下次尝试时间)。 + public void ImportQueueItem(QueueItem item) + { + lock (gate) + { + using var cmd = Cmd(connection, + "INSERT OR REPLACE INTO queue(id,message_id,owner_email,recipients,attempts,created_at,next_attempt,last_attempt_at,status,last_error,last_code) " + + "VALUES(@id,@m,@o,@r,@a,@c,@n,@l,@s,@e,@code)", + ("@id", item.Id), ("@m", item.MessageId), ("@o", item.OwnerEmail ?? ""), + ("@r", JsonSerializer.Serialize(item.Recipients ?? [])), ("@a", item.Attempts), + ("@c", Ts(item.CreatedAt)), ("@n", Ts(item.NextAttempt)), + ("@l", item.LastAttemptAt.HasValue ? Ts(item.LastAttemptAt.Value) : null), + ("@s", item.Status ?? "pending"), ("@e", item.LastError ?? ""), ("@code", item.LastCode)); + cmd.ExecuteNonQuery(); + } + } + + /// 按原样导入会话(保留令牌,避免迁移把已登录客户端踢下线)。 + public void ImportSession(SessionRecord session) + { + lock (gate) + { + using var cmd = Cmd(connection, + "INSERT OR REPLACE INTO sessions(token,email,expires,created_at) VALUES(@t,@e,@x,@c)", + ("@t", session.Token), ("@e", session.Email), ("@x", Ts(session.Expires)), ("@c", Ts(session.CreatedAt))); + cmd.ExecuteNonQuery(); + } + } + + /// 导出全部大对象(供 SQLite → JSON 反向回滚)。 + public IReadOnlyList<(long Id, byte[] Data)> ExportBlobs() + { + lock (gate) + { + var ids = new List(); + using (var cmd = Cmd(connection, "SELECT id FROM blobs ORDER BY id")) + using (var reader = cmd.ExecuteReader()) + while (reader.Read()) ids.Add(reader.GetInt64(0)); + return ids.Select(id => (id, ReadBlobLocked(id))).ToArray(); + } + } + + public IReadOnlyList AllQueueItems() + { + lock (gate) + { + var list = new List(); + using var cmd = Cmd(connection, $"SELECT {QueueColumns} FROM queue"); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) list.Add(MapQueue(reader)); + return list; + } + } + + public IReadOnlyList AllSessions() + { + lock (gate) + { + var list = new List(); + using var cmd = Cmd(connection, "SELECT token,email,expires,created_at FROM sessions"); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + list.Add(new SessionRecord + { + Token = reader.GetString(0), + Email = reader.GetString(1), + Expires = ParseTs(reader.GetString(2), DateTimeOffset.UtcNow), + CreatedAt = ParseTs(reader.GetString(3), DateTimeOffset.UtcNow), + }); + return list; + } + } + public IReadOnlyList ListForImap(string owner, string folder) + { + lock (gate) + { + return QueryMessagesLocked( + $"SELECT {MessageColumns} FROM messages WHERE owner_email=@o AND folder=@f ORDER BY uid", + ("@o", owner ?? ""), ("@f", (folder ?? "").ToLowerInvariant())); + } + } + + public int CountUnseen(string owner, string folder) => (int)Scalar( + "SELECT COUNT(1) FROM messages WHERE owner_email=@o AND folder=@f AND unread=1", 0L, + ("@o", owner ?? ""), ("@f", (folder ?? "").ToLowerInvariant())); + + public int NextUidFor(string owner, string folder) { lock (gate) return NextUidLocked(owner ?? "", folder ?? ""); } + + public bool StoreFlags(string owner, string id, bool? seen, bool? flagged) + { + lock (gate) + { + var message = GetMessage(owner, id); + if (message is null) return false; + using var cmd = Cmd(connection, "UPDATE messages SET unread=@u, starred=@s WHERE id=@id", + ("@u", (seen.HasValue ? !seen.Value : message.Unread) ? 1 : 0), + ("@s", (flagged ?? message.Starred) ? 1 : 0), + ("@id", message.Id)); + cmd.ExecuteNonQuery(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + return true; + } + } + + public bool Expunge(string owner, string id) + { + lock (gate) + { + var message = GetMessage(owner, id); + if (message is null) return false; + if (message.Folder.Equals("trash", StringComparison.OrdinalIgnoreCase)) DeleteRowLocked(message); + else MoveMessage(owner, id, "trash"); + return true; + } + } + + 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 MailMessage? DeliverLocal(string recipient, byte[] raw, string sender) + { + // 投递不看激活状态:未激活的待验证账号也要能收到验证码邮件 + 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 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"); + InsertMessageLocked(message); + foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var item = new QueueItem { MessageId = message.Id, OwnerEmail = owner, Recipients = [recipient] }; + InsertQueueLocked(item); + } + } + 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"); + InsertMessageLocked(message); + } + return message; + } + + // ---------------------------------------------------------------- 出站队列 + + private void InsertQueueLocked(QueueItem item) + { + using var cmd = Cmd(connection, + "INSERT INTO queue(id,message_id,owner_email,recipients,attempts,created_at,next_attempt,last_attempt_at,status,last_error,last_code) " + + "VALUES(@id,@m,@o,@r,@a,@c,@n,@l,@s,@e,@code)", + ("@id", item.Id), ("@m", item.MessageId), ("@o", item.OwnerEmail ?? ""), + ("@r", JsonSerializer.Serialize(item.Recipients ?? [])), ("@a", item.Attempts), + ("@c", Ts(item.CreatedAt)), ("@n", Ts(item.NextAttempt)), + ("@l", item.LastAttemptAt.HasValue ? Ts(item.LastAttemptAt.Value) : null), + ("@s", item.Status), ("@e", item.LastError ?? ""), ("@code", item.LastCode)); + cmd.ExecuteNonQuery(); + } + + private static QueueItem MapQueue(SqliteDataReader r) => new() + { + Id = r.GetString(0), + MessageId = r.GetString(1), + OwnerEmail = r.GetString(2), + Recipients = ParseStringArray(r.GetString(3)), + Attempts = (int)r.GetInt64(4), + CreatedAt = ParseTs(r.GetString(5), DateTimeOffset.UtcNow), + NextAttempt = ParseTs(r.GetString(6), DateTimeOffset.UtcNow), + LastAttemptAt = r.IsDBNull(7) ? null : ParseTs(r.GetString(7), DateTimeOffset.UtcNow), + Status = r.GetString(8), + LastError = r.GetString(9), + LastCode = (int)r.GetInt64(10), + }; + + private const string QueueColumns = + "id, message_id, owner_email, recipients, attempts, created_at, next_attempt, last_attempt_at, status, last_error, last_code"; + + private static string[] ParseStringArray(string json) + { + try { return JsonSerializer.Deserialize(json) ?? []; } + catch { return []; } + } + + private void UpdateQueueLocked(QueueItem item) + { + using var cmd = Cmd(connection, + "UPDATE queue SET attempts=@a, next_attempt=@n, last_attempt_at=@l, status=@s, last_error=@e, last_code=@code WHERE id=@id", + ("@a", item.Attempts), ("@n", Ts(item.NextAttempt)), + ("@l", item.LastAttemptAt.HasValue ? Ts(item.LastAttemptAt.Value) : null), + ("@s", item.Status), ("@e", item.LastError ?? ""), ("@code", item.LastCode), ("@id", item.Id)); + cmd.ExecuteNonQuery(); + } + + public IReadOnlyList TakeDueQueue(int limit) + { + lock (gate) + { + var due = new List(); + using (var cmd = Cmd(connection, + $"SELECT {QueueColumns} FROM queue WHERE status IN ('pending','retry') AND next_attempt <= @now " + + "ORDER BY next_attempt LIMIT @limit", + ("@now", Ts(DateTimeOffset.UtcNow)), ("@limit", limit))) + using (var reader = cmd.ExecuteReader()) + while (reader.Read()) due.Add(MapQueue(reader)); + + foreach (var item in due) + { + item.Status = "processing"; + UpdateQueueLocked(item); + } + if (due.Count > 0) + { + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + return due; + } + } + + public void CompleteQueue(QueueItem item) + { + lock (gate) + { + item.Status = "sent"; + item.LastAttemptAt = DateTimeOffset.UtcNow; + item.LastError = ""; + UpdateQueueLocked(item); + + var remaining = Scalar( + "SELECT COUNT(1) FROM queue WHERE message_id=@m AND id<>@id AND status IN ('pending','retry','processing')", 0L, + ("@m", item.MessageId), ("@id", item.Id)); + using (var cmd = Cmd(connection, "UPDATE messages SET delivery_status=@s, last_error='' WHERE id=@id", + ("@s", remaining > 0 ? "queued" : "sent"), ("@id", item.MessageId))) + cmd.ExecuteNonQuery(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + } + + 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); + UpdateQueueLocked(item); + + var remaining = Scalar( + "SELECT COUNT(1) FROM queue WHERE message_id=@m AND id<>@id AND status IN ('pending','retry','processing')", 0L, + ("@m", item.MessageId), ("@id", item.Id)); + using (var cmd = Cmd(connection, "UPDATE messages SET delivery_status=@s, last_error=@e WHERE id=@id", + ("@s", gaveUp && remaining == 0 ? "failed" : "queued"), ("@e", error.Message), ("@id", item.MessageId))) + cmd.ExecuteNonQuery(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + } + + public void RetryQueueItem(string owner, string queueId) => + Mutate("UPDATE queue SET status='pending', attempts=0, next_attempt=@n, last_error='' " + + "WHERE id=@id AND owner_email=@o", + ("@n", Ts(DateTimeOffset.UtcNow)), ("@id", queueId ?? ""), ("@o", owner ?? "")); + + public IReadOnlyList ListQueue(string owner) + { + lock (gate) + { + var list = new List(); + using var cmd = Cmd(connection, + $"SELECT {QueueColumns} FROM queue WHERE owner_email=@o ORDER BY created_at DESC", ("@o", owner ?? "")); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) list.Add(MapQueue(reader)); + return list; + } + } + + public object Stats(string owner) + { + owner ??= ""; + lock (gate) + { + // 返回值类型必须与 FileStore 完全一致(int),否则两套后端对客户端就不是同一个契约。 + int Count(string where, params (string, object?)[] extra) + { + var args = new List<(string, object?)> { ("@o", owner) }; + args.AddRange(extra); + return (int)Scalar($"SELECT COUNT(1) FROM messages WHERE owner_email=@o AND {where}", 0L, args.ToArray()); + } + + return new + { + inbox = Count("folder='inbox'"), + unread = Count("folder='inbox' AND unread=1"), + starred = Count("starred=1 AND folder<>'trash'"), + drafts = Count("folder='drafts'"), + sent = Count("folder='sent'"), + trash = Count("folder='trash'"), + queue = (int)Scalar("SELECT COUNT(1) FROM queue WHERE owner_email=@o AND status IN ('pending','retry','processing')", 0L, ("@o", owner)), + failed = Count("delivery_status='failed'"), + }; + } + } + + // ---------------------------------------------------------------- 维护 + + public IReadOnlyList AllMessages() + { + lock (gate) return QueryMessagesLocked($"SELECT {MessageColumns} FROM messages"); + } + + public IReadOnlyList AllUsers() => ListUsers(); + + /// + /// SQLite 实现里写入即提交。这里做一次 TRUNCATE 检查点把 WAL 归并回主库并截断, + /// 既能让文件大小反映真实占用,也便于备份时只拷主库文件。 + /// + public void Persist() + { + lock (gate) + { + using var cmd = Cmd(connection, "PRAGMA wal_checkpoint(TRUNCATE)"); + cmd.ExecuteNonQuery(); + } + } + + /// 回收空闲页,把数据库文件真正缩小(--vacuum)。 + public void Vacuum() + { + lock (gate) + { + using var cmd = Cmd(connection, "VACUUM"); + cmd.ExecuteNonQuery(); + } + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + // ---------------------------------------------------------------- 账号体系(注册 / 验证码 / 审计) + + public void UpdateProfile(string email, string displayName) => + Mutate("UPDATE users SET display_name=@d WHERE email=@e", + ("@d", displayName ?? ""), ("@e", (email ?? "").Trim())); + + public void SetLastLogin(string email) => + Mutate("UPDATE users SET last_login_at=@t WHERE email=@e", + ("@t", Ts(DateTimeOffset.UtcNow)), ("@e", (email ?? "").Trim())); + + public void SaveVerificationCode(VerificationCode code) + { + lock (gate) + { + using (var cmd = Cmd(connection, + "INSERT INTO verification_codes(email,purpose,code_hash,salt,payload,expires_at,attempts,created_at,sent_at) " + + "VALUES(@e,@p,@h,@s,@y,@x,@a,@c,@t) " + + "ON CONFLICT(email,purpose) DO UPDATE SET code_hash=@h, salt=@s, payload=@y, " + + "expires_at=@x, attempts=0, created_at=@c, sent_at=@t", + ("@e", code.Email), ("@p", code.Purpose), ("@h", code.CodeHash), ("@s", code.Salt), + ("@y", code.Payload ?? ""), ("@x", Ts(code.ExpiresAt)), ("@a", code.Attempts), + ("@c", Ts(code.CreatedAt)), ("@t", code.SentAt is null ? null : Ts(code.SentAt.Value)))) + cmd.ExecuteNonQuery(); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + } + + public VerificationCode? FindVerificationCode(string email, string purpose) + { + lock (gate) + { + using var cmd = Cmd(connection, + "SELECT email,purpose,code_hash,salt,payload,expires_at,attempts,created_at,sent_at " + + "FROM verification_codes WHERE email=@e AND purpose=@p", + ("@e", (email ?? "").Trim()), ("@p", purpose)); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) return null; + return new VerificationCode + { + Email = reader.GetString(0), + Purpose = reader.GetString(1), + CodeHash = reader.GetString(2), + Salt = reader.GetString(3), + Payload = reader.GetString(4), + ExpiresAt = ParseTs(reader.GetString(5), DateTimeOffset.UtcNow), + Attempts = reader.GetInt32(6), + CreatedAt = ParseTs(reader.GetString(7), DateTimeOffset.UtcNow), + SentAt = reader.IsDBNull(8) ? null : ParseTs(reader.GetString(8), DateTimeOffset.UtcNow), + }; + } + } + + public int IncrementVerificationAttempts(string email, string purpose) + { + Mutate("UPDATE verification_codes SET attempts=attempts+1 WHERE email=@e AND purpose=@p", + ("@e", (email ?? "").Trim()), ("@p", purpose)); + return (int)Scalar("SELECT attempts FROM verification_codes WHERE email=@e AND purpose=@p", 0L, + ("@e", (email ?? "").Trim()), ("@p", purpose)); + } + + public void RemoveVerificationCode(string email, string purpose) => + Mutate("DELETE FROM verification_codes WHERE email=@e AND purpose=@p", + ("@e", (email ?? "").Trim()), ("@p", purpose)); + + public void RecordAuthEvent(AuthEvent entry) + { + lock (gate) + { + using (var cmd = Cmd(connection, + "INSERT INTO auth_events(id,email,ip,reason,success,detail,user_agent,at) " + + "VALUES(@i,@e,@p,@r,@o,@d,@u,@t)", + ("@i", entry.Id), ("@e", entry.Email ?? ""), ("@p", entry.Ip ?? ""), ("@r", entry.Reason ?? ""), + ("@o", entry.Success ? 1 : 0), ("@d", entry.Detail ?? ""), ("@u", entry.UserAgent ?? ""), + ("@t", Ts(entry.At)))) + cmd.ExecuteNonQuery(); + // 顺手淘汰超量审计(每 50 条才做一次,避免每次都付代价) + auditWrites++; + if (auditWrites % 50 == 0) PruneAuthEventsLocked(AuditKeep); + version++; + WriteMetaLocked("version", version.ToString(CultureInfo.InvariantCulture)); + } + } + + private int auditWrites; + + /// 审计保留条数(由配置注入;默认 2000)。 + public int AuditKeep { get; set; } = 2000; + + private void PruneAuthEventsLocked(int keep) + { + using var cmd = Cmd(connection, + "DELETE FROM auth_events WHERE id NOT IN (SELECT id FROM auth_events ORDER BY at DESC LIMIT @k)", + ("@k", Math.Max(100, keep))); + cmd.ExecuteNonQuery(); + } + + public IReadOnlyList ListAuthEvents(string? email, string? ip, string? reason, int limit) + { + var sql = "SELECT id,email,ip,reason,success,detail,user_agent,at FROM auth_events WHERE 1=1"; + var args = new List<(string, object?)>(); + if (!string.IsNullOrWhiteSpace(email)) { sql += " AND email=@e"; args.Add(("@e", email.Trim())); } + if (!string.IsNullOrWhiteSpace(ip)) { sql += " AND ip=@p"; args.Add(("@p", ip.Trim())); } + if (!string.IsNullOrWhiteSpace(reason)) { sql += " AND reason=@r"; args.Add(("@r", reason.Trim())); } + sql += " ORDER BY at DESC LIMIT @k"; + args.Add(("@k", Math.Clamp(limit <= 0 ? 200 : limit, 1, 5000))); + + lock (gate) + { + var list = new List(); + using var cmd = Cmd(connection, sql, [.. args]); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + list.Add(new AuthEvent + { + Id = reader.GetString(0), + Email = reader.GetString(1), + Ip = reader.GetString(2), + Reason = reader.GetString(3), + Success = reader.GetInt32(4) != 0, + Detail = reader.GetString(5), + UserAgent = reader.GetString(6), + At = ParseTs(reader.GetString(7), DateTimeOffset.UtcNow), + }); + } + return list; + } + } + + public int CountAuthEvents(string? email, string? ip, string? reason, bool? success, int minutes) + { + var since = Ts(DateTimeOffset.UtcNow.AddMinutes(-Math.Max(1, minutes))); + var sql = "SELECT COUNT(1) FROM auth_events WHERE at >= @since"; + var args = new List<(string, object?)> { ("@since", since) }; + if (!string.IsNullOrWhiteSpace(email)) { sql += " AND email=@e"; args.Add(("@e", email.Trim())); } + if (!string.IsNullOrWhiteSpace(ip)) { sql += " AND ip=@p"; args.Add(("@p", ip.Trim())); } + if (!string.IsNullOrWhiteSpace(reason)) { sql += " AND reason=@r"; args.Add(("@r", reason.Trim())); } + if (success.HasValue) { sql += " AND success=@o"; args.Add(("@o", success.Value ? 1 : 0)); } + return (int)Scalar(sql, 0L, [.. args]); + } + + // ---------------------------------------------------------------- 密码 + + 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() + { + try { connection.Dispose(); } catch { } + } +} diff --git a/server-native-v2/StorageBench.cs b/server-native-v2/StorageBench.cs new file mode 100644 index 0000000..75a8a2a --- /dev/null +++ b/server-native-v2/StorageBench.cs @@ -0,0 +1,218 @@ +using System.Diagnostics; + +namespace WpywMail.Native; + +/// +/// 存储后端基准测试:同一份负载分别跑 JSON 与 SQLite 两套实现,输出可对比的数字。 +/// +/// 关注点(也是 JSON 实现真正的瓶颈): +/// 1. 入库:JSON 每存一封都要把**全部邮件**重新序列化并整文件重写(O(N)/次); +/// 2. 单条改动(标记已读):同上 —— 这是最容易被放大的操作(IMAP 客户端一打开收件箱就会批量改); +/// 3. 列表 / 未读数 / 统计 / 搜索:JSON 全是全表扫描。 +/// +public static class StorageBench +{ + public static int Run(AppConfig baseConfig, int count, int mutationSamples) + { + count = Math.Clamp(count, 20, 20_000); + mutationSamples = Math.Clamp(mutationSamples, 10, count); + + Console.WriteLine($"=== 存储后端基准测试({count} 封邮件,单条改动抽样 {mutationSamples} 次)==="); + Console.WriteLine(); + + var root = Path.Combine(Path.GetTempPath(), "wpyw-bench-" + Guid.NewGuid().ToString("N")[..8]); + var jsonDir = Path.Combine(root, "json"); + var sqliteDir = Path.Combine(root, "sqlite"); + Directory.CreateDirectory(jsonDir); + Directory.CreateDirectory(sqliteDir); + + var jsonConfig = Clone(baseConfig, jsonDir, "json", fullText: false); + var sqliteConfig = Clone(baseConfig, sqliteDir, "sqlite", fullText: false); + var ftsDir = Path.Combine(root, "sqlite-fts"); + Directory.CreateDirectory(ftsDir); + var ftsConfig = Clone(baseConfig, ftsDir, "sqlite", fullText: true); + + try + { + var json = Measure("JSON(整文件重写)", jsonConfig, count, mutationSamples); + var sqlite = Measure("SQLite(WAL + 索引)", sqliteConfig, count, mutationSamples); + var sqliteFts = Measure("SQLite + FTS5 全文索引", ftsConfig, count, mutationSamples); + + Console.WriteLine(); + Console.WriteLine($"{"操作",-22}{"JSON",16}{"SQLite",16}{"提升",12}"); + Console.WriteLine(new string('-', 68)); + Row("入库 每封", json.InsertPerOp, sqlite.InsertPerOp, "ms"); + Row("标记已读 每次", json.MutatePerOp, sqlite.MutatePerOp, "ms"); + Row("收件箱首页(50条) 每次", json.ListPerOp, sqlite.ListPerOp, "ms"); + Row("未读数 每次", json.UnreadPerOp, sqlite.UnreadPerOp, "ms"); + Row("统计 每次", json.StatsPerOp, sqlite.StatsPerOp, "ms"); + Row("搜索 每次", json.SearchPerOp, sqlite.SearchPerOp, "ms"); + Row("入库总计", json.InsertTotal, sqlite.InsertTotal, "ms"); + Row("磁盘占用", json.Bytes, sqlite.Bytes, "B"); + Row("内存增量", json.Memory, sqlite.Memory, "B"); + + Console.WriteLine(); + Console.WriteLine($"明细:JSON → {json.Detail}"); + Console.WriteLine($" SQLite → {sqlite.Detail}"); + Console.WriteLine($" SQLite+FTS5 → {sqliteFts.Detail}"); + Console.WriteLine(); + Console.WriteLine("全文检索开关的取舍(同一份数据):"); + Console.WriteLine($" 搜索 每次:LIKE 扫描 {sqlite.SearchPerOp:N2} ms / 占用 {sqlite.Bytes:N0} B" + + $" ←→ FTS5 {sqliteFts.SearchPerOp:N2} ms / 占用 {sqliteFts.Bytes:N0} B" + + $"(索引多占 {sqliteFts.Bytes - sqlite.Bytes:N0} B)"); + Console.WriteLine(); + Console.WriteLine($"磁盘对比:JSON 共 {json.Bytes:N0} B,SQLite 共 {sqlite.Bytes:N0} B" + + (sqlite.Bytes > 0 ? $"({(double)json.Bytes / sqlite.Bytes:F2}×)" : "")); + Console.WriteLine(); + Console.WriteLine("结论:SQLite 的「入库」与「单条改动」耗时与邮件量基本无关(索引定位单行);"); + Console.WriteLine(" JSON 实现每次改动都要重写全部邮件,随邮件量增长呈超线性,且常驻内存随邮箱增长。"); + Console.WriteLine(" 列表首页两者都在毫秒级;SQLite 的优势随规模放大(内存与写放大不增长)。"); + return 0; + } + finally + { + try { Directory.Delete(root, true); } catch { } + } + } + + private static void Row(string label, double a, double b, string unit) + { + var ratio = b > 0 ? a / b : 0; + var gain = unit == "ms" && ratio > 1 ? $"{ratio:F1}× 快" + : unit == "B" && ratio > 1 ? $"省 {100.0 * (a - b) / a:F0}%" + : ratio is > 0 and < 1 ? $"{ratio:F2}×" : "—"; + Console.WriteLine($"{label,-22}{a,15:N2} {unit,-1}{b,14:N2} {unit,-1}{gain,11}"); + } + + private sealed record Metrics( + double InsertTotal, double InsertPerOp, double MutatePerOp, + double ListPerOp, double UnreadPerOp, double StatsPerOp, double SearchPerOp, + long Bytes, long Memory, string DbPath, string Detail); + + private static Metrics Measure(string label, AppConfig config, int count, int mutationSamples) + { + using var store = config.Storage.Provider == "sqlite" + ? (IMailStore)new SqliteStore(config) + : new FileStore(config); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + var before = GC.GetTotalMemory(true); + + // ---- 预生成负载(生成不计入耗时)---- + var payloads = new List<(MailMessage Message, byte[] Raw)>(count); + for (var i = 0; i < count; i++) + { + var raw = Mime.Build(new ComposeRequest( + $"sender{i % 37}@example.com", $"发件人 {i % 37}", + new[] { config.AdminEmail }, [], + $"基准测试邮件 #{i}:包含中文主题与正文", + string.Join("\r\n", Enumerable.Repeat($"这是第 {i} 封测试邮件的正文内容,用于测量存储后端的写入与查询开销。", 12))), + config); + var parsed = Mime.Parse(raw); + var message = new MailMessage + { + OwnerEmail = config.AdminEmail, + Folder = i % 5 == 0 ? "sent" : "inbox", + From = parsed.From, + To = parsed.To, + Subject = parsed.Subject, + Text = parsed.Text, + Html = parsed.Html, + MessageId = parsed.MessageId, + Date = DateTimeOffset.UtcNow.AddSeconds(-i), + ReceivedAt = DateTimeOffset.UtcNow, + Unread = true, + DeliveryStatus = "received", + }; + payloads.Add((message, raw)); + } + + // ---- 1) 入库 ---- + var sw = Stopwatch.StartNew(); + foreach (var (message, raw) in payloads) store.SaveMessage(message, raw); + sw.Stop(); + var insertTotal = sw.Elapsed.TotalMilliseconds; + var insertPerOp = insertTotal / count; + + // ---- 2) 单条改动(标记已读)---- + var sample = payloads.Take(mutationSamples).Select(p => p.Message.Id).ToArray(); + sw.Restart(); + foreach (var id in sample) store.MarkRead(config.AdminEmail, id, true); + sw.Stop(); + var mutatePerOp = sw.Elapsed.TotalMilliseconds / sample.Length; + + // ---- 3) 收件箱首页(与 API /api/messages 的真实路径一致:分页)---- + sw.Restart(); + for (var i = 0; i < 10; i++) store.ListMessagesPage(config.AdminEmail, "inbox", "", false, false, 50, 0); + sw.Stop(); + var listPerOp = sw.Elapsed.TotalMilliseconds / 10; + + // ---- 4) 未读数 ---- + sw.Restart(); + for (var i = 0; i < 20; i++) store.CountUnseen(config.AdminEmail, "inbox"); + sw.Stop(); + var unreadPerOp = sw.Elapsed.TotalMilliseconds / 20; + + // ---- 5) 统计 ---- + sw.Restart(); + for (var i = 0; i < 20; i++) store.Stats(config.AdminEmail); + sw.Stop(); + var statsPerOp = sw.Elapsed.TotalMilliseconds / 20; + + // ---- 6) 搜索 ---- + sw.Restart(); + for (var i = 0; i < 10; i++) store.ListMessages(config.AdminEmail, "", "第 178 封"); + sw.Stop(); + var searchPerOp = sw.Elapsed.TotalMilliseconds / 10; + + // ---- 7) 空间与内存 ---- + store.Persist(); + long bytes; + var dbPath = ""; + var breakdown = ""; + if (store is SqliteStore sqlite) + { + var report = sqlite.StorageReport(); + bytes = report.DbBytes + report.WalBytes; + dbPath = report.Database; + breakdown = $"大对象 {report.BlobStoredBytes:N0} B(由 {report.BlobRawBytes:N0} B 原文压缩而来,{report.BlobGzipped} 个启用压缩)、" + + $"正文列 {report.TextBytes:N0} B、空闲页 {report.FreeBytes:N0} B"; + } + else + { + var rawBytes = DirectoryBytes(Path.Combine(config.DataDirectory, "raw")); + var jsonBytes = new[] { "users.json", "messages.json", "queue.json", "sessions.json" } + .Select(f => Path.Combine(config.DataDirectory, f)) + .Where(File.Exists).Sum(f => new FileInfo(f).Length); + bytes = jsonBytes + rawBytes + DirectoryBytes(Path.Combine(config.DataDirectory, "attachments")); + dbPath = config.DataDirectory; + breakdown = $"JSON {jsonBytes:N0} B + raw 报文 {rawBytes:N0} B"; + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + var memory = GC.GetTotalMemory(true) - before; + + Console.WriteLine($"{label}:入库 {insertTotal:N0} ms,每封 {insertPerOp:N2} ms," + + $"标记已读 {mutatePerOp:N2} ms/次,占用 {bytes:N0} B"); + + return new Metrics(insertTotal, insertPerOp, mutatePerOp, listPerOp, unreadPerOp, statsPerOp, searchPerOp, + bytes, memory, dbPath, breakdown); + } + + private static long DirectoryBytes(string path) => + Directory.Exists(path) ? Directory.GetFiles(path, "*", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length) : 0; + + private static AppConfig Clone(AppConfig source, string dataDirectory, string provider, bool fullText) => new() + { + Domain = source.Domain, + Hostname = source.Hostname, + DataDirectory = dataDirectory, + AdminEmail = source.AdminEmail, + AdminPassword = source.AdminPassword, + DeliveryMode = "direct", + Dkim = new DkimConfig { Enabled = false }, + Storage = new StorageConfig { Provider = provider, FullTextSearch = fullText }, + }; +} diff --git a/server-native-v2/StorageMigration.cs b/server-native-v2/StorageMigration.cs new file mode 100644 index 0000000..45bbae5 --- /dev/null +++ b/server-native-v2/StorageMigration.cs @@ -0,0 +1,321 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace WpywMail.Native; + +/// +/// 存储后端之间的数据搬迁。 +/// +/// 正向(JSON → SQLite):把 users/messages/queue/sessions 全部导入数据库, +/// 报文原文与附件读文件后进 blobs 表(gzip + 内容去重),并**逐封校验** +/// 「从数据库读回来的字节」与「原文件字节」SHA256 完全一致,之后才允许删除源文件。 +/// +/// 反向(SQLite → JSON):把 blobs 还原成 raw/ 与 attachments/ 文件并重写 JSON, +/// 用于一键回滚。 +/// +public static class StorageMigration +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + private static T? ReadJson(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 long DirectoryBytes(string path) => + Directory.Exists(path) ? Directory.GetFiles(path, "*", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length) : 0; + + private static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + + // ------------------------------------------------------------------ 正向 + + public static int ToSqlite(AppConfig config, bool deleteSourceFiles) + { + var dataDir = config.DataDirectory; + var usersPath = Path.Combine(dataDir, "users.json"); + var messagesPath = Path.Combine(dataDir, "messages.json"); + var queuePath = Path.Combine(dataDir, "queue.json"); + var sessionsPath = Path.Combine(dataDir, "sessions.json"); + + var users = ReadJson>(usersPath) ?? []; + var messages = ReadJson>(messagesPath) ?? []; + var queue = ReadJson>(queuePath) ?? []; + var sessions = ReadJson>(sessionsPath) ?? []; + + if (messages.Count == 0 && users.Count == 0) + { + Console.WriteLine("没有可迁移的数据(users.json / messages.json 为空或不存在)。"); + return 1; + } + + var beforeRaw = DirectoryBytes(Path.Combine(dataDir, "raw")); + var beforeAttachments = DirectoryBytes(Path.Combine(dataDir, "attachments")); + var beforeJson = new[] { usersPath, messagesPath, queuePath, sessionsPath } + .Where(File.Exists).Sum(p => new FileInfo(p).Length); + + Console.WriteLine($"源数据:用户 {users.Count},邮件 {messages.Count},队列 {queue.Count},会话 {sessions.Count}"); + Console.WriteLine($"源占用:raw {beforeRaw:N0} B + attachments {beforeAttachments:N0} B + JSON {beforeJson:N0} B"); + + using var store = new SqliteStore(config); + var existing = (int)store.StorageReport().Messages; + if (existing > 0) + { + Console.WriteLine($"数据库里已有 {existing} 封邮件,将按 id 覆盖导入(幂等)。"); + } + + foreach (var user in users) store.ImportUser(user); + + // UID 唯一性修复:数据库上有 (owner, folder, uid) 唯一索引 + var used = new HashSet<(string, string, int)>(); + var nextUid = new Dictionary<(string, string), int>(); + var verified = new List<(MailMessage Message, string OriginalSha, bool HadFile)>(); + var imported = 0; + var missingRaw = 0; + + foreach (var message in messages.OrderBy(m => m.Date)) + { + message.OwnerEmail = (message.OwnerEmail ?? "").ToLowerInvariant(); + message.Folder = (message.Folder ?? "inbox").ToLowerInvariant(); + + var key = (message.OwnerEmail, message.Folder); + if (!nextUid.TryGetValue(key, out var next)) next = 1; + var uid = message.Uid; + if (uid <= 0 || !used.Add((key.Item1, key.Item2, uid))) + { + while (used.Contains((key.Item1, key.Item2, next))) next++; + uid = next; + used.Add((key.Item1, key.Item2, uid)); + } + message.Uid = uid; + next = Math.Max(next, uid + 1); + nextUid[key] = next; + + // 报文原文:文件 → blobs + var originalSha = ""; + var hadFile = false; + if (!string.IsNullOrWhiteSpace(message.RawPath) && !message.RawPath.StartsWith("db:", StringComparison.OrdinalIgnoreCase)) + { + var full = Path.Combine(dataDir, message.RawPath); + if (File.Exists(full)) + { + var bytes = File.ReadAllBytes(full); + originalSha = Sha(bytes); + hadFile = true; + message.RawPath = store.SaveRaw(bytes); + message.Size = bytes.Length; + } + else + { + missingRaw++; + message.RawPath = ""; + } + } + + // 附件:文件 → blobs + foreach (var attachment in message.Attachments) + { + if (string.IsNullOrWhiteSpace(attachment.StoredAs) || + attachment.StoredAs.StartsWith("db:", StringComparison.OrdinalIgnoreCase)) continue; + var full = Path.Combine(dataDir, attachment.StoredAs); + if (File.Exists(full)) attachment.StoredAs = store.SaveAttachment(File.ReadAllBytes(full), attachment.FileName); + } + + store.SaveMessage(message); + verified.Add((message, originalSha, hadFile)); + imported++; + } + + foreach (var item in queue) store.ImportQueueItem(item); + var now = DateTimeOffset.UtcNow; + foreach (var session in sessions.Where(s => s.Expires > now)) store.ImportSession(session); + + // ---- 校验:从数据库读回来的字节必须与原文件逐字节一致 ---- + Console.WriteLine("校验中(逐封比对 SHA256)……"); + var bad = 0; + foreach (var (message, originalSha, hadFile) in verified) + { + if (!hadFile || message.RawPath.Length == 0) continue; + try + { + var back = store.ReadRaw(message.RawPath); + if (Sha(back) != originalSha) + { + bad++; + Console.Error.WriteLine($" [不一致] {message.Id} {message.Subject}"); + } + } + catch (Exception ex) + { + bad++; + Console.Error.WriteLine($" [读取失败] {message.Id}:{ex.Message}"); + } + } + + var report = store.StorageReport(); + + Console.WriteLine(); + Console.WriteLine($"导入完成:邮件 {imported} 封,队列 {queue.Count} 条,会话 {sessions.Count} 个" + + (missingRaw > 0 ? $",{missingRaw} 封缺少原始报文文件" : "")); + Console.WriteLine($"入库大对象:原始 {report.BlobRawBytes:N0} B → 存储 {report.BlobStoredBytes:N0} B" + + (report.BlobRawBytes > 0 ? $"({(100.0 * report.BlobStoredBytes / report.BlobRawBytes):F0}%,{report.BlobGzipped} 个压缩)" : "")); + Console.WriteLine($"数据库文件:{report.DbBytes:N0} B + WAL {report.WalBytes:N0} B"); + Console.WriteLine($"校验结果:SHA256 不一致 {bad} 封"); + + if (bad > 0) + { + Console.Error.WriteLine("存在校验失败,已保留源文件,请勿删除。"); + return 2; + } + + if (!deleteSourceFiles) + { + Console.WriteLine("源文件已保留(想释放空间请加 --delete-source 重跑)。"); + return 0; + } + + // ---- 删源文件(校验已全部通过才走到这里)---- + var deleted = 0; + long freed = 0; + foreach (var file in Directory.GetFiles(Path.Combine(dataDir, "raw"), "*.eml", SearchOption.TopDirectoryOnly)) + { + freed += new FileInfo(file).Length; + File.Delete(file); + deleted++; + } + var attachmentDir = Path.Combine(dataDir, "attachments"); + foreach (var file in Directory.GetFiles(attachmentDir, "*", SearchOption.TopDirectoryOnly)) + { + freed += new FileInfo(file).Length; + File.Delete(file); + deleted++; + } + Console.WriteLine($"已删除源文件 {deleted} 个,释放 {freed:N0} B"); + Console.WriteLine($"提示:JSON 索引文件(users/messages/queue/sessions.json)保留未删,便于随时回滚。"); + return 0; + } + + // ------------------------------------------------------------------ 反向 + + public static int ToJson(AppConfig config) + { + var dataDir = config.DataDirectory; + using var store = new SqliteStore(config); + + var messages = store.AllMessages().ToList(); + var users = store.AllUsers().ToList(); + var queue = store.AllQueueItems().ToList(); + var sessions = store.AllSessions().ToList(); + + var rawDir = Path.Combine(dataDir, "raw"); + var attachmentDir = Path.Combine(dataDir, "attachments"); + Directory.CreateDirectory(rawDir); + Directory.CreateDirectory(attachmentDir); + + var exported = new Dictionary(); + foreach (var (id, data) in store.ExportBlobs()) + { + // 同一个 blob 可能被多封邮件/附件引用,这里各导出一份文件(回滚场景不追求去重) + var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{id}{GuessExtension(data)}"; + var relative = Path.Combine("raw", name); + File.WriteAllBytes(Path.Combine(dataDir, relative), data); + exported[id] = relative; + } + + static string GuessExtension(byte[] data) => + data.Length >= 4 && data[0] == 0x50 && data[1] == 0x4B ? ".zip" : ".eml"; + + foreach (var message in messages) + { + if (message.RawPath.StartsWith("db:", StringComparison.OrdinalIgnoreCase) && + long.TryParse(message.RawPath.AsSpan(3), out var blobId) && exported.TryGetValue(blobId, out var path)) + message.RawPath = path; + foreach (var attachment in message.Attachments) + { + if (attachment.StoredAs.StartsWith("db:", StringComparison.OrdinalIgnoreCase) && + long.TryParse(attachment.StoredAs.AsSpan(3), out var attId) && exported.TryGetValue(attId, out var attPath)) + { + var target = Path.Combine("attachments", Path.GetFileName(attPath)); + File.Copy(Path.Combine(dataDir, attPath), Path.Combine(dataDir, target), true); + attachment.StoredAs = target; + } + } + } + + File.WriteAllText(Path.Combine(dataDir, "messages.json"), JsonSerializer.Serialize(messages, Json), new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(dataDir, "users.json"), JsonSerializer.Serialize(users, Json), new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(dataDir, "queue.json"), JsonSerializer.Serialize(queue, Json), new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(dataDir, "sessions.json"), JsonSerializer.Serialize(sessions, Json), new UTF8Encoding(false)); + + Console.WriteLine($"已导出:邮件 {messages.Count} 封,用户 {users.Count},队列 {queue.Count},会话 {sessions.Count}"); + Console.WriteLine($"报文文件 {exported.Count} 个 → {rawDir}"); + Console.WriteLine("把 appsettings.json 的 Storage.Provider 改回 json 即可用这套数据启动。"); + return 0; + } + + // ------------------------------------------------------------------ 状态 + + public static int Status(AppConfig config) + { + var dataDir = config.DataDirectory; + var rawBytes = DirectoryBytes(Path.Combine(dataDir, "raw")); + var attachmentBytes = DirectoryBytes(Path.Combine(dataDir, "attachments")); + var jsonBytes = new[] { "users.json", "messages.json", "queue.json", "sessions.json" } + .Select(f => Path.Combine(dataDir, f)).Where(File.Exists).Sum(p => new FileInfo(p).Length); + + Console.WriteLine($"当前配置的后端:{config.Storage.Provider}"); + Console.WriteLine($"raw/ {rawBytes,12:N0} B ({Directory.GetFiles(Path.Combine(dataDir, "raw"), "*").Length} 个文件)"); + Console.WriteLine($"attachments/{attachmentBytes,12:N0} B ({Directory.GetFiles(Path.Combine(dataDir, "attachments"), "*").Length} 个文件)"); + Console.WriteLine($"JSON 索引 {jsonBytes,12:N0} B"); + + var dbPath = string.IsNullOrWhiteSpace(config.Storage.DatabasePath) + ? Path.Combine(dataDir, "wpywmail.db") : config.Storage.DatabasePath; + if (!File.Exists(dbPath)) + { + Console.WriteLine("SQLite 数据库:尚未创建"); + return 0; + } + + using var store = new SqliteStore(config); + var report = store.StorageReport(); + + Console.WriteLine($"SQLite {report.DbBytes + report.WalBytes,12:N0} B ({dbPath})"); + Console.WriteLine($" 邮件 {report.Messages} 封,大对象 {report.Blobs} 个" + + $"(原始 {report.BlobRawBytes:N0} B → 存储 {report.BlobStoredBytes:N0} B," + + $"其中 {report.BlobGzipped} 个启用了压缩)"); + Console.WriteLine($" 正文列合计 {report.TextBytes:N0} B(最大一封 {report.LargestTextBytes:N0} B:{Trim(report.LargestTextSubject)})"); + Console.WriteLine($" 页统计:{report.PageCount} 页 × {report.PageSize} B = {report.TotalBytes:N0} B," + + $"其中空闲 {report.FreeBytes:N0} B" + + (report.FreeBytes > 0 ? "(用 --vacuum 回收;注意 VACUUM 需要约 2 倍临时空间,且应在服务停止时做)" : "")); + Console.WriteLine($" 孤儿大对象 {report.Orphans} 个(用 --compact 清理)"); + return 0; + } + + private static string Trim(string text) => + string.IsNullOrEmpty(text) ? "(无)" : text.Length <= 24 ? text : text[..24] + "…"; + + public static int Compact(AppConfig config) + { + using var store = new SqliteStore(config); + var removed = store.Compact(); + Console.WriteLine($"已清理孤儿大对象 {removed} 个。"); + return Status(config); + } + + /// 合并 WAL 并回收空闲页,把数据库文件压到实际大小。 + public static int Vacuum(AppConfig config) + { + using var store = new SqliteStore(config); + var before = store.StorageReport(); + Console.WriteLine($"整理前:主库 {before.DbBytes:N0} B + WAL {before.WalBytes:N0} B,空闲页 {before.FreeBytes:N0} B"); + store.Vacuum(); // VACUUM:回收空闲页 + store.Persist(); // 再把 VACUUM 期间产生的 WAL 归并回主库并截断 + var after = store.StorageReport(); + Console.WriteLine($"整理后:主库 {after.DbBytes:N0} B + WAL {after.WalBytes:N0} B,空闲页 {after.FreeBytes:N0} B"); + Console.WriteLine($"实际占用:{(before.DbBytes + before.WalBytes):N0} B → {(after.DbBytes + after.WalBytes):N0} B" + + $"(释放 {(before.DbBytes + before.WalBytes) - (after.DbBytes + after.WalBytes):N0} B)"); + return 0; + } +} diff --git a/server-native-v2/WpywMail.Native.csproj b/server-native-v2/WpywMail.Native.csproj new file mode 100644 index 0000000..6370ab0 --- /dev/null +++ b/server-native-v2/WpywMail.Native.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0 + win-x64 + enable + enable + + false + WpywMail.Native + WpywMail.Native + 2.0.1 + en + + + + + + diff --git a/server-native-v2/appsettings.example.json b/server-native-v2/appsettings.example.json new file mode 100644 index 0000000..748556c --- /dev/null +++ b/server-native-v2/appsettings.example.json @@ -0,0 +1,67 @@ +{ + "Domain": "wpy.email", + "Hostname": "mail.example.com", + "HttpPrefix": "http://127.0.0.1:8787/", + "SmtpPort": 25, + "SubmissionPort": 587, + "DataDirectory": "C:\\WpywMailData", + "AdminEmail": "wpy@wpy.email", + "AdminPassword": "replace-with-a-long-password", + "TlsCertificatePath": "C:\\WpywMailData\\certs\\mail.example.com.pfx", + "TlsCertificatePassword": "replace-with-certificate-password", + "DeliveryMode": "direct", + "DirectDelivery": { + "ConnectionTimeoutSeconds": 30, + "CommandTimeoutSeconds": 30, + "DnsTimeoutSeconds": 5, + "OpportunisticStartTls": true, + "RequireStartTls": false, + "DnsServer": "", + "HeloName": "" + }, + "Relay": { + "Host": "", + "Port": 587, + "User": "", + "Password": "", + "EnableSsl": true + }, + "Retry": { + "MaxAttempts": 12, + "InitialDelaySeconds": 60, + "MaxDelaySeconds": 3600, + "RetryOnPermanentFailure": true, + "MaxAttemptsForPermanent": 3, + "SendBounceNotification": true + }, + "Dkim": { + "Enabled": true, + "Selector": "mail", + "SigningDomain": "", + "PrivateKeyPath": "", + "Headers": [ + "From", + "To", + "Subject", + "Date", + "Message-ID", + "MIME-Version", + "Content-Type", + "Content-Transfer-Encoding" + ] + }, + "Api": { + "SessionDays": 30, + "CorsOrigin": "*", + "LongPollSeconds": 25 + }, + "Smtp": { + "MaxMessageBytes": 26214400, + "AdvertiseStartTls": true, + "AllowAuthOnInbound": false, + "AddReceivedHeader": true, + "AuthFailuresBeforeBan": 8, + "BanMinutes": 15, + "EnforceSenderMatch": true + } +} diff --git a/server-native-v2/install-firewall.ps1 b/server-native-v2/install-firewall.ps1 new file mode 100644 index 0000000..8a5a833 --- /dev/null +++ b/server-native-v2/install-firewall.ps1 @@ -0,0 +1,3 @@ +New-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口' -Direction Inbound -Protocol TCP -LocalPort 25,587 -Action Allow +# API 8787 默认只监听本机,再通过 Cloudflare Tunnel 暴露 Web/API。 +# 如需让独立客户端直连,请先评估安全策略后再单独开放 8787。 diff --git a/server-native-v2/install-task.ps1 b/server-native-v2/install-task.ps1 new file mode 100644 index 0000000..8a4f379 --- /dev/null +++ b/server-native-v2/install-task.ps1 @@ -0,0 +1,17 @@ +param( + [string]$InstallDirectory = 'C:\WpywMail', + [string]$TaskName = 'WpywMail' +) + +$exe = Join-Path $InstallDirectory 'WpywMail.Native.exe' +if (-not (Test-Path -LiteralPath $exe)) { + throw "找不到 $exe,请先把 self-contained publish 目录复制到服务器。" +} + +$action = New-ScheduledTaskAction -Execute $exe -WorkingDirectory $InstallDirectory +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force +Start-ScheduledTask -TaskName $TaskName +Write-Host "已注册并启动任务:$TaskName" diff --git a/server-native-v2/tools/account-acceptance.ps1 b/server-native-v2/tools/account-acceptance.ps1 new file mode 100644 index 0000000..466c324 --- /dev/null +++ b/server-native-v2/tools/account-acceptance.ps1 @@ -0,0 +1,446 @@ +<# + 账号体系真实验收(在邮件服务器本机执行) + + 为什么必须跑真机:这套东西的价值全在「真的能注册、真的能登录、验证码邮件真的进得了信箱」, + 单元自检(--selftest)只能证明存储层与业务层的逻辑,证明不了 HTTP 链路、IMAP 链路、 + 以及「验证码邮件是否真的投递到了客户端读得到的地方」。 + + 覆盖: + A 策略接口、B 邀请码、C 本机托管地址注册即开通(含死循环回归)、D 会话/资料/审计、 + E 真实 IMAP 993 登录 + 收件箱非空、F 忘记密码→读信取码→重置、G 改密踢其他会话、 + H 登录失败锁定(423 + retryAfterSeconds)、I 管理员视角、J 停用后不能登录(并清理测试账号) + + 用法: + powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1 + 退出码 = 失败项数(0 = 全通过)。 +#> +param( + [string]$Base = 'http://127.0.0.1:8787', + [string]$ImapHost = '127.0.0.1', + [int]$ImapPort = 993, + [string]$ImapName = 'mail.example.com', + [string]$Report = 'C:\Windows\Temp\wpyw-acct-verify.txt', + [string]$AppSettings = 'C:\Program Files\WpywMail\appsettings.json' +) + +$ErrorActionPreference = 'Stop' +try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } + +$script:pass = 0 +$script:fail = 0 +$script:skip = 0 +# 注意:PowerShell 变量名大小写不敏感,这里不能叫 $script:report —— 会和参数 $Report 撞车 +$script:logLines = New-Object System.Collections.Generic.List[string] + +function Say([string]$line) { + Write-Host $line + $script:logLines.Add($line) +} +function Ok([string]$name, [bool]$cond, [string]$detail = '') { + if ($cond) { $script:pass++; Say ("[PASS] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) } + else { $script:fail++; Say ("[FAIL] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) } +} +function Skip([string]$name, [string]$why = '') { + $script:skip++ + Say ("[SKIP] {0}{1}" -f $name, $(if ($why) { " —— $why" } else { '' })) +} + +function Invoke-Api { + param([string]$Method, [string]$Path, $Body = $null, [string]$Token = '') + $headers = @{} + if ($Token) { $headers['Authorization'] = "Bearer $Token" } + $params = @{ + Uri = ($Base + $Path); Method = $Method; Headers = $headers + UseBasicParsing = $true; TimeoutSec = 30 + } + if ($null -ne $Body) { + # 必须按 UTF-8 字节发,不能直接传字符串:控制台是 GBK,中文会变问号 + $params['Body'] = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress -Depth 8)) + $params['ContentType'] = 'application/json; charset=utf-8' + } + try { + $r = Invoke-WebRequest @params + $json = $null + try { $json = $r.Content | ConvertFrom-Json } catch { } + return @{ Code = [int]$r.StatusCode; Json = $json; Raw = $r.Content } + } catch { + $resp = $_.Exception.Response + $code = 0 + $raw = '' + if ($null -ne $resp) { + try { $code = [int]$resp.StatusCode } catch { } + try { + $reader = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8) + $raw = $reader.ReadToEnd() + $reader.Close() + } catch { } + } + # ⚠ PowerShell 5.1 的坑:非 2xx 响应体常常已经被它的错误格式化逻辑读掉了, + # 这时 GetResponseStream() 读出来是空的 —— 必须回退到 ErrorDetails.Message。 + if (-not $raw) { + try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $raw = $_.ErrorDetails.Message } } catch { } + } + $json = $null + if ($raw) { try { $json = $raw | ConvertFrom-Json } catch { } } + return @{ Code = $code; Json = $json; Raw = $raw } + } +} + +function Field($obj, [string]$name, $fallback = $null) { + if ($null -eq $obj) { return $fallback } + $p = $obj.PSObject.Properties[$name] + if ($null -ne $p -and $null -ne $p.Value) { return $p.Value } + return $fallback +} + +# /api/login 直接把会话对象摊在顶层({token,expiresAt,user}),/api/register 则包在 session 里。 +# 两种形状都得认,否则会静默取到空 token,后面全用着已失效的 token 连锁失败(踩过)。 +function Get-Token($json) { + $t = [string](Field (Field $json 'session') 'token' '') + if (-not $t) { $t = [string](Field $json 'token' '') } + return $t +} + +function Wait-InboxMessage { + param([string]$Token, [string]$SubjectLike, [int]$Seconds = 40) + for ($i = 0; $i -lt $Seconds; $i++) { + $r = Invoke-Api 'GET' '/api/messages?folder=inbox&limit=20' $null $Token + $list = Field $r.Json 'messages' + if ($r.Code -eq 200 -and $null -ne $list) { + foreach ($m in $list) { + $s = [string](Field $m 'subject' '') + if ($s -like $SubjectLike) { return $m } + } + } + Start-Sleep -Seconds 1 + } + return $null +} + +function Read-ImapUntil { + param($Reader, [string]$Tag, [int]$MaxLines = 500) + $out = New-Object System.Collections.Generic.List[string] + for ($i = 0; $i -lt $MaxLines; $i++) { + $line = $Reader.ReadLine() + if ($null -eq $line) { break } + $out.Add($line) + if ($line.StartsWith($Tag + ' ')) { break } + } + return $out +} + +# 真实 IMAP 客户端:连 993(隐式 TLS)→ LOGIN → SELECT INBOX → UID SEARCH ALL +function Test-ImapLogin { + param([string]$Email, [string]$Password) + $res = @{ Ok = $false; Detail = ''; Count = -1; Select = '' } + $tcp = New-Object Net.Sockets.TcpClient + try { + $tcp.Connect($ImapHost, $ImapPort) + $tcp.ReceiveTimeout = 20000 + $cb = [Net.Security.RemoteCertificateValidationCallback] { param($a, $b, $c, $d) return $true } + $ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, $cb) + $ssl.AuthenticateAsClient($ImapName) + $reader = New-Object IO.StreamReader($ssl, [Text.Encoding]::UTF8) + $writerEncoding = New-Object Text.UTF8Encoding($false) + $writer = New-Object IO.StreamWriter($ssl, $writerEncoding) + $writer.NewLine = "`r`n" + $writer.AutoFlush = $true + + $greeting = $reader.ReadLine() + if ($greeting -notmatch '^\* OK') { $res.Detail = "问候语异常: $greeting"; return $res } + + $writer.WriteLine("a1 LOGIN $Email $Password") + $login = Read-ImapUntil $reader 'a1' + $loginText = ($login -join ' | ') + if ($loginText -notmatch 'a1 OK') { $res.Detail = "LOGIN 失败: $loginText"; return $res } + + $writer.WriteLine('a2 SELECT INBOX') + $sel = Read-ImapUntil $reader 'a2' + $selText = ($sel -join ' ') + if ($selText -notmatch 'a2 OK') { $res.Detail = "SELECT 失败: $selText"; return $res } + + $exists = 0 + $m = [regex]::Match($selText, '\*\s+(\d+)\s+EXISTS') + if ($m.Success) { $exists = [int]$m.Groups[1].Value } + + $writer.WriteLine('a3 UID SEARCH ALL') + $search = Read-ImapUntil $reader 'a3' + $searchText = ($search -join ' ') + $uidCount = -1 + $sm = [regex]::Match($searchText, '\*\s+SEARCH([\d\s]*)') + if ($sm.Success) { + $ids = ($sm.Groups[1].Value -split '\s+' | Where-Object { $_ -ne '' }) + $uidCount = $ids.Count + } + + $writer.WriteLine('a4 LOGOUT') + [void](Read-ImapUntil $reader 'a4') + + $res.Ok = $true + $res.Count = $uidCount + $res.Select = "EXISTS=$exists UIDSEARCH=$uidCount" + return $res + } catch { + $res.Detail = $_.Exception.Message + return $res + } finally { + try { $tcp.Close() } catch { } + } +} + +function Set-UserActive { + param([string]$Token, [string]$Email, [bool]$Active) + $path = '/api/admin/users/' + [Uri]::EscapeDataString($Email) + return Invoke-Api 'PATCH' $path @{ active = $Active } $Token +} + +# ================================================================ 开始 +Say ("WpywMail 账号体系真实验收 {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) +Say ("目标 API:{0} IMAP:{1}:{2}" -f $Base, $ImapHost, $ImapPort) +Say '' + +# 从服务器自己的配置里取管理员口令与邀请码(不硬编码,避免和部署配置不一致) +$cfg = [System.IO.File]::ReadAllText($AppSettings) | ConvertFrom-Json +$invite = $cfg.Accounts.InviteCode +$adminEmail = $cfg.AdminEmail +$adminPassword = $cfg.AdminPassword +$domain = $cfg.Domain +Say ("配置:域名={0} 邀请码长度={1} 管理员={2}" -f $domain, $invite.Length, $adminEmail) +Say '' + +$suffix = (Get-Random -Minimum 100000 -Maximum 999999) +$newEmail = "selftest-$suffix@$domain" +$newPassword = "Selftest-Pass-$suffix" +$resetPassword = "Reset-Pass-$suffix" +$lockEmail = "locktest-$suffix@$domain" +$lockPassword = "Locktest-Pass-$suffix" +$tempAccounts = @($newEmail, $lockEmail) + +# 管理员先登录:后面「注册配额用尽」时要靠它兜底建号,最后的管理员用例也复用它 +$adminLogin = Invoke-Api 'POST' '/api/login' @{ email = $adminEmail; password = $adminPassword } +$adminToken = Get-Token $adminLogin.Json +Ok 'A0 管理员可以登录' ($adminLogin.Code -eq 200 -and $adminToken.Length -gt 20) ("HTTP " + $adminLogin.Code + " " + [string](Field $adminLogin.Json 'error')) + +# ---------------------------------------------------------------- A 策略 +$ver = Invoke-Api 'GET' '/api/version' +Ok 'A1 /api/version 可达' ($ver.Code -eq 200) ("HTTP " + $ver.Code) + +$pol = Invoke-Api 'GET' '/api/auth/policy' +Ok 'A2 策略接口可达' ($pol.Code -eq 200) ("HTTP " + $pol.Code) +Ok 'A3 注册模式 = invite' ((Field $pol.Json 'registration') -eq 'invite') ([string](Field $pol.Json 'registration')) +Ok 'A4 需要邀请码标记' ((Field $pol.Json 'inviteRequired') -eq $true) ([string](Field $pol.Json 'inviteRequired')) +$allowed = Field $pol.Json 'allowedDomains' +Ok 'A5 允许域名包含本机域' ($allowed -contains $domain) ([string]::Join(',', $allowed)) +Ok 'A6 密码最短长度 >= 8' (([int](Field $pol.Json 'minPasswordLength' 0)) -ge 8) ([string](Field $pol.Json 'minPasswordLength')) +$note = [string](Field $pol.Json 'verificationNote' '') +Ok 'A7 策略里说明了「本机托管地址免验证」的原因' ($note.Length -gt 10) $note + +# ---------------------------------------------------------------- B/C 注册 +$badInvite = Invoke-Api 'POST' '/api/register' @{ + email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = 'WRONG-CODE' +} +Ok 'B1 邀请码错误被拒(403)' ($badInvite.Code -eq 403) ([string](Field $badInvite.Json 'error')) + +$badDomain = Invoke-Api 'POST' '/api/register' @{ + email = "nobody-$suffix@example.com"; password = $newPassword; displayName = '外部域'; inviteCode = $invite +} +Ok 'B2 白名单外的域名被拒(400)' ($badDomain.Code -eq 400) ([string](Field $badDomain.Json 'error')) + +$weak = Invoke-Api 'POST' '/api/register' @{ + email = $newEmail; password = '12345678'; displayName = '弱密码'; inviteCode = $invite +} +Ok 'B3 弱密码被拒(400)' ($weak.Code -eq 400) ([string](Field $weak.Json 'error')) + +$reg = Invoke-Api 'POST' '/api/register' @{ + email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = $invite +} +$session = Field $reg.Json 'session' +$token = [string](Field $session 'token' '') +if ($reg.Code -eq 429) { + # 一小时内重复跑本脚本会撞到 IP 配额(配额按「真的建出的账号数」计,见 AccountService)。 + # 这不是缺陷,但要如实标注,并改用管理员接口建号,让后面的 40 多项用例照常跑完。 + Skip 'C1/C2/C3 自助注册链路' '本小时该 IP 的注册配额已用尽(重复运行脚本所致);已改用管理员接口建号继续验收' + $boot = Invoke-Api 'POST' '/api/admin/users' @{ email = $newEmail; password = $newPassword; displayName = '验收账号' } $adminToken + Ok 'C1b 配额用尽时管理员接口可以建号' ($boot.Code -eq 201) ("HTTP " + $boot.Code + " " + [string](Field $boot.Json 'error')) + $boot2 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword } + $token = Get-Token $boot2.Json + Ok 'C1c 兜底建的号可以登录' ($boot2.Code -eq 200 -and $token.Length -gt 20) ("HTTP " + $boot2.Code) +} else { + Ok 'C1 本机域注册成功(201)' ($reg.Code -eq 201) ("HTTP " + $reg.Code + " " + [string](Field $reg.Json 'error')) + Ok 'C2 本机域注册不需要邮箱验证(死循环回归)' ((Field $reg.Json 'verificationRequired') -eq $false) ([string](Field $reg.Json 'verificationRequired')) + Ok 'C3 注册直接返回会话 token' ($token.Length -gt 20) ("token 长度 " + $token.Length) +} + +$me = Invoke-Api 'GET' '/api/me' $null $token +Ok 'C4 注册后的 token 可访问 /api/me' ($me.Code -eq 200) ("HTTP " + $me.Code) +Ok 'C5 /api/me 返回的账号一致' (([string](Field (Field $me.Json 'user') 'email' '')) -eq $newEmail) ([string](Field (Field $me.Json 'user') 'email' '')) + +$login1 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword } +$token2 = Get-Token $login1.Json +Ok 'C6 新账号可以正常登录' ($login1.Code -eq 200 -and $token2.Length -gt 20) ("HTTP " + $login1.Code) +if (-not $token) { $token = $token2 } + +# ---------------------------------------------------------------- D 会话 / 资料 / 审计 +$prof = Invoke-Api 'PATCH' '/api/account/profile' @{ displayName = "验收账号-$suffix" } $token +Ok 'D1 修改显示名成功' ($prof.Code -eq 200) ([string](Field $prof.Json 'error')) +$me2 = Invoke-Api 'GET' '/api/me' $null $token +Ok 'D2 显示名已生效' (([string](Field (Field $me2.Json 'user') 'displayName' '')) -eq "验收账号-$suffix") ([string](Field (Field $me2.Json 'user') 'displayName' '')) + +$sess = Invoke-Api 'GET' '/api/account/sessions' $null $token +$sessList = Field $sess.Json 'sessions' +Ok 'D3 会话列表可读且包含当前会话' ($sess.Code -eq 200 -and $null -ne ($sessList | Where-Object { (Field $_ 'current') -eq $true })) ("共 " + @($sessList).Count + " 个会话") + +$audit = Invoke-Api 'GET' '/api/account/audit?limit=50' $null $token +$events = Field $audit.Json 'events' +$reasons = @($events | ForEach-Object { [string](Field $_ 'reason' '') }) +Ok 'D4 审计里能看到 register 事件' ($reasons -contains 'register') ([string]::Join(',', $reasons)) +Ok 'D5 审计里能看到 login-ok 事件' ($reasons -contains 'login-ok') '' +Ok 'D6 审计里能看到 profile-updated 事件' ($reasons -contains 'profile-updated') '' + +# ---------------------------------------------------------------- E 真实 IMAP +$imap = Test-ImapLogin $newEmail $newPassword +Ok 'E1 新账号能用真实 IMAP(993 隐式 TLS) 登录' $imap.Ok ([string]$imap.Detail) +Ok 'E2 IMAP SELECT INBOX 成功' ($imap.Ok -and $imap.Select -ne '') ([string]$imap.Select) + +# 自己给自己发一封中文邮件,验证本地投递进了这个新信箱 +$send = Invoke-Api 'POST' '/api/send' @{ + to = $newEmail; subject = "账号验收邮件 $suffix"; text = "这封邮件用来验证新注册账号的信箱能收信。编号 $suffix" +} $token +Ok 'E3 新账号可以发信(入队 202)' ($send.Code -eq 202) ("HTTP " + $send.Code + " " + [string](Field $send.Json 'error')) + +$landed = Wait-InboxMessage $token "账号验收邮件*" 40 +$landedId = [string](Field $landed 'id' '') +Ok 'E4 自己发的邮件已投递进收件箱' ($null -ne $landed -and $landedId.Length -gt 0) ([string](Field $landed 'subject' '(未收到)')) + +$imap2 = Test-ImapLogin $newEmail $newPassword +Ok 'E5 IMAP 收件箱计数 >= 1' ($imap2.Ok -and $imap2.Count -ge 1) ([string]$imap2.Select) + +# ---------------------------------------------------------------- F 忘记密码 → 读信取码 → 重置 +$forgot = Invoke-Api 'POST' '/api/auth/forgot' @{ email = $newEmail } +Ok 'F1 申请重置密码返回成功' ($forgot.Code -eq 200) ("HTTP " + $forgot.Code + " " + [string](Field $forgot.Json 'error')) + +$resetMail = Wait-InboxMessage $token '*重置密码验证码*' 40 +$resetId = [string](Field $resetMail 'id' '') +Ok 'F2 重置验证码邮件已投进收件箱' ($resetId.Length -gt 0) ([string](Field $resetMail 'subject' '(未收到)')) + +$code = '' +if ($resetId) { + $detail = Invoke-Api 'GET' ("/api/messages/" + [Uri]::EscapeDataString($resetId)) $null $token + $msg = Field $detail.Json 'message' + $body = [string](Field $msg 'text' '') + if (-not $body) { $body = [string](Field $msg 'html' '') } + # ⚠ 必须锚定「验证码:」这个标签:邮件正文里还写着收件人地址(selftest-123456@…), + # 直接抓第一个 6 位数字会抓到地址里的数字,测试自己就成了假失败源。 + $cm = [regex]::Match($body, '验证码[::]\s*(\d{6})') + if (-not $cm.Success) { + $all = [regex]::Matches($body, '\b(\d{6})\b') + if ($all.Count -gt 0) { $cm = $all[$all.Count - 1] } + } + if ($cm.Success) { + if ($cm.Groups.Count -gt 1) { $code = $cm.Groups[1].Value } else { $code = $cm.Value } + } +} +Ok 'F3 能从邮件正文里取出 6 位验证码' ($code.Length -eq 6) ("code=" + $(if ($code) { $code } else { '(空)' })) + +$badReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = '000000'; password = $resetPassword } +Ok 'F4 错误验证码被拒(400)' ($badReset.Code -eq 400) ([string](Field $badReset.Json 'error')) + +if ($code.Length -eq 6) { + $goodReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = $code; password = $resetPassword } + Ok 'F5 正确验证码重置成功' ($goodReset.Code -eq 200) ("HTTP " + $goodReset.Code + " " + [string](Field $goodReset.Json 'error')) +} + +$oldLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword } +Ok 'F6 重置后旧密码失效(401)' ($oldLogin.Code -eq 401) ("HTTP " + $oldLogin.Code) +$newLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword } +$token3 = Get-Token $newLogin.Json +Ok 'F7 重置后新密码可登录' ($newLogin.Code -eq 200 -and $token3.Length -gt 20) ("HTTP " + $newLogin.Code) +$imap3 = Test-ImapLogin $newEmail $resetPassword +Ok 'F8 新密码同样能用 IMAP 登录' $imap3.Ok ([string]$imap3.Detail) + +if ($token3) { $token = $token3 } + +# ---------------------------------------------------------------- G 改密踢掉其他会话 +$loginExtra = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword } +$token4 = Get-Token $loginExtra.Json +$before = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count +$changed = Invoke-Api 'POST' '/api/account/password' @{ currentPassword = $resetPassword; password = $newPassword } $token +$after = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count +Ok 'G1 修改密码成功' ($changed.Code -eq 200) ("HTTP " + $changed.Code + " " + [string](Field $changed.Json 'error')) +Ok 'G2 改密后其他会话被吊销(当前保留)' ($after -lt $before -and $after -ge 1) ("改前 $before → 改后 $after") +if ($token4) { + $stale = Invoke-Api 'GET' '/api/me' $null $token4 + Ok 'G3 被踢掉的那个 token 已失效(401)' ($stale.Code -eq 401) ("HTTP " + $stale.Code) +} + +# ---------------------------------------------------------------- H 登录失败锁定 +$lockReg = Invoke-Api 'POST' '/api/register' @{ + email = $lockEmail; password = $lockPassword; displayName = '锁定验收'; inviteCode = $invite +} +if ($lockReg.Code -eq 429) { + Skip 'H1 第二个测试账号自助注册' '本小时注册配额已用尽(重复运行脚本所致);改用管理员接口建号' + $lockReg = Invoke-Api 'POST' '/api/admin/users' @{ email = $lockEmail; password = $lockPassword; displayName = '锁定验收' } $adminToken +} +Ok 'H1 第二个测试账号建号成功' ($lockReg.Code -eq 201) ("HTTP " + $lockReg.Code + " " + [string](Field $lockReg.Json 'error')) + +$maxFail = [int](Field $pol.Json 'maxLoginFailures' 8) +$lastCode = 0 +for ($i = 1; $i -le ($maxFail + 1); $i++) { + $r = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = "Wrong-Password-$i" } + $lastCode = $r.Code +} +$lockReply = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = $lockPassword } +$retryAfter = Field $lockReply.Json 'retryAfterSeconds' +Ok 'H2 连续失败后返回 423 锁定' ($lockReply.Code -eq 423) ("HTTP " + $lockReply.Code + " " + [string](Field $lockReply.Json 'error')) +Ok 'H3 锁定响应带 retryAfterSeconds' (($null -ne $retryAfter) -and ([int]$retryAfter -gt 0)) ("retryAfterSeconds=" + [string]$retryAfter + " 原始响应: " + $lockReply.Raw) +Ok 'H4 锁定期间即使密码正确也被挡(不泄露密码对错)' ($lockReply.Code -eq 423) '' + +# ---------------------------------------------------------------- I 管理员视角 +$users = Invoke-Api 'GET' '/api/admin/users' $null $adminToken +$userList = Field $users.Json 'users' +$mine = $userList | Where-Object { ([string](Field $_ 'email' '')) -eq $newEmail } +Ok 'I2 管理员能看到新注册的账号' ($null -ne $mine) ("共 " + @($userList).Count + " 个账号") +Ok 'I3 新账号在管理员视角是启用状态' ($null -ne $mine -and (Field $mine 'active') -eq $true) ([string](Field $mine 'active')) + +$adminAudit = Invoke-Api 'GET' '/api/admin/audit?limit=50' $null $adminToken +$adminEvents = Field $adminAudit.Json 'events' +$adminReasons = @($adminEvents | ForEach-Object { [string](Field $_ 'reason' '') }) +Ok 'I4 管理员能看到全站审计' ($adminAudit.Code -eq 200 -and $adminEvents.Count -gt 0) ("共 " + @($adminEvents).Count + " 条,含 " + [string]::Join('/', ($adminReasons | Select-Object -Unique -First 6))) + +$nonAdmin = Invoke-Api 'GET' '/api/admin/users' $null $token +Ok 'I5 普通账号访问管理接口被拒(403)' ($nonAdmin.Code -eq 403) ("HTTP " + $nonAdmin.Code) + +# ---------------------------------------------------------------- J 停用 + 清理 +$deact = Set-UserActive $adminToken $lockEmail $false +Ok 'J1 管理员可停用账号' ($deact.Code -eq 200) ("HTTP " + $deact.Code + " " + [string](Field $deact.Json 'error')) +$deact2 = Set-UserActive $adminToken $newEmail $false +Ok 'J2 清理:停用验收账号一' ($deact2.Code -eq 200) ("HTTP " + $deact2.Code) + +$disabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword } +Ok 'J3 被停用的账号不能登录(401,且不暴露账号状态)' ($disabled.Code -eq 401) ("HTTP " + $disabled.Code + " " + [string](Field $disabled.Json 'error')) +$disabledImap = Test-ImapLogin $newEmail $newPassword +Ok 'J4 被停用的账号不能登录 IMAP' (-not $disabledImap.Ok) ([string]$disabledImap.Detail) + +# 停用必须是权威状态:不能靠「重新注册」把封禁翻回来(这是真机验收抓出来的洞) +$revive = Invoke-Api 'POST' '/api/register' @{ + email = $newEmail; password = "Revive-Pass-$suffix"; displayName = '尝试复活'; inviteCode = $invite +} +Ok 'J5 被停用的账号不能靠重新注册复活(403)' ($revive.Code -eq 403) ("HTTP " + $revive.Code + " " + [string](Field $revive.Json 'error')) +$stillDisabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = "Revive-Pass-$suffix" } +Ok 'J6 复活尝试后账号依然进不去(401)' ($stillDisabled.Code -eq 401) ("HTTP " + $stillDisabled.Code) + +Say '' +Say ("=== 结果:{0} 项通过,{1} 项失败,{2} 项跳过 ===" -f $script:pass, $script:fail, $script:skip) +Say '' +Say "说明:测试期间创建的两个账号已停用(不是删除,服务器暂无删除账号接口):" +foreach ($a in $tempAccounts) { Say (" - {0}" -f $a) } + +try { + $utf8 = New-Object Text.UTF8Encoding($true) + [System.IO.File]::WriteAllLines($Report, $script:logLines, $utf8) + Write-Host ("报告已写入 {0}" -f $Report) +} catch { + Write-Host ("报告写入失败:{0}" -f $_.Exception.Message) +} + +exit $script:fail diff --git a/server-native-v2/tools/deploy.ps1 b/server-native-v2/tools/deploy.ps1 new file mode 100644 index 0000000..a1675b6 --- /dev/null +++ b/server-native-v2/tools/deploy.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS + 构建 → 自检 → 发布 → 部署到服务器 → 验证,一条命令完成 WpywMail 更新。 + +.EXAMPLE + .\deploy.ps1 -Server -RemotePassword '***' + .\deploy.ps1 -Server -RemotePassword '***' -FullCopy # 首次部署或运行时变更时用 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Server, + [string]$RemoteUser = 'Administrator', + [string]$RemotePassword, + [string]$RemotePath = 'C:\Program Files\WpywMail', + [string]$TaskName = 'WpywMail', + [switch]$FullCopy, + [switch]$SkipTests +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$publish = Join-Path (Split-Path -Parent $root) 'work\publish-v2' + +function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } + +Step '1) 构建' +Push-Location $root +try { + & dotnet build -c Release -v q --nologo + if ($LASTEXITCODE -ne 0) { throw '构建失败' } + + $exe = Join-Path $root 'bin\Release\net8.0\win-x64\WpywMail.Native.exe' + + if (-not $SkipTests) { + Step '2) 自检' + & $exe --selftest + if ($LASTEXITCODE -ne 0) { throw '自检未通过,已中止部署' } + } + + Step '3) 发布(自包含 win-x64)' + & dotnet publish -c Release -r win-x64 --self-contained true -v q --nologo -o $publish + if ($LASTEXITCODE -ne 0) { throw '发布失败' } +} finally { Pop-Location } + +if ($RemotePassword) { + $sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force + $cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec) +} else { + $cred = Get-Credential -UserName "$Server\$RemoteUser" -Message "连接 $Server 的凭据" +} + +Step '4) 停服务' +$session = New-PSSession -ComputerName $Server -Credential $cred +try { + Invoke-Command -Session $session -ArgumentList $TaskName -ScriptBlock { + param($task) + Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'" | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force } + } + + Step '5) 传输文件' + if ($FullCopy) { + Write-Host ' 全量复制(首次部署或运行时版本变更时使用)' + Copy-Item -Path "$publish\*" -Destination $RemotePath -ToSession $session -Recurse -Force + } else { + Write-Host ' 增量复制(仅程序集,约 400 KB)' + foreach ($f in 'WpywMail.Native.dll', 'WpywMail.Native.exe', 'WpywMail.Native.pdb', 'WpywMail.Native.deps.json') { + Copy-Item -Path (Join-Path $publish $f) -Destination "$RemotePath\" -ToSession $session -Force + } + } + + Step '6) 启动并验证' + Invoke-Command -Session $session -ArgumentList $TaskName, $RemotePath -ScriptBlock { + param($task, $path) + # 启动前在服务器上再跑一次自检,确保部署的就是通过测试的二进制 + & (Join-Path $path 'WpywMail.Native.exe') --selftest | Select-String -Pattern '失败|=== 结果' + Start-ScheduledTask -TaskName $task + Start-Sleep -Seconds 7 + $p = Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'" + if (-not $p) { throw '服务未能启动' } + " 运行中 PID: $($p.ProcessId)" + Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalPort -in 25, 587, 8787 } | + ForEach-Object { " 监听 {0}:{1}" -f $_.LocalAddress, $_.LocalPort } + Get-Content 'C:\WpywMailData\service.log' -Tail 4 -Encoding UTF8 + } +} finally { + Remove-PSSession $session +} + +Write-Host "`n部署完成。回滚:把 $RemotePath 换回 $RemotePath.v1-backup 并重启计划任务 $TaskName。" -ForegroundColor Green diff --git a/server-native-v2/tools/e2e_acceptance.py b/server-native-v2/tools/e2e_acceptance.py new file mode 100644 index 0000000..f5be36c --- /dev/null +++ b/server-native-v2/tools/e2e_acceptance.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +WpywMail.Native v2 —— 端到端验收测试(在服务器本机执行) + +设计原则: + · 只用 Python 标准库(服务器上是 3.8,没有 requests / cryptography); + · 密码从 appsettings.json 读取,绝不硬编码; + · 自带纯 Python 的 RSA/SHA-256 PKCS#1 v1.5 验签器,直接对「将来要发布到 DNS 的公钥」 + 验证真实投递报文的 DKIM 签名 —— 这一步不依赖服务端私钥,也不依赖外部库; + · 覆盖收信、发信、API、IMAP、DKIM、以及三条安全回归(非开放中继 / 提交需认证 / 明文登录限制)。 + +用法: + python e2e_acceptance.py # 全部用例 + python e2e_acceptance.py --report out.txt +退出码 = 失败用例数(0 表示全部通过)。 +""" +import argparse +import base64 +import hashlib +import imaplib +import json +import os +import re +import smtplib +import socket +import ssl +import subprocess +import sys +import time +import urllib.error +import urllib.request +from email.message import EmailMessage +from email import utils as email_utils + +socket.setdefaulttimeout(20) + +EXE = r"C:\Program Files\WpywMail\WpywMail.Native.exe" +CONFIG = r"C:\Program Files\WpywMail\appsettings.json" + +PASSED = [] +FAILED = [] + + +def check(name, ok, detail=""): + (PASSED if ok else FAILED).append(name) + mark = "PASS" if ok else "FAIL" + line = " [{}] {}".format(mark, name) + if detail and not ok: + line += " <- " + str(detail)[:200] + elif detail: + line += " ({})".format(str(detail)[:120]) + print(line) + return ok + + +def section(title): + print("\n=== {} ===".format(title)) + + +def load_config(): + with open(CONFIG, "r", encoding="utf-8-sig") as fh: + return json.load(fh) + + +# --------------------------------------------------------------------- API 客户端 + +def api(base, path, method="GET", payload=None, token=None, raw=False): + url = base.rstrip("/") + path + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Content-Type", "application/json; charset=utf-8") + if token: + req.add_header("Authorization", "Bearer " + token) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read() + return resp.status, (body if raw else json.loads(body.decode("utf-8"))) + except urllib.error.HTTPError as err: + body = err.read() + try: + return err.code, json.loads(body.decode("utf-8")) + except Exception: + return err.code, body + + +# --------------------------------------------------------------------- 纯 Python DKIM 验签 + +def der_read(data, offset): + """极简 DER TLV 解析,返回 (tag, value_bytes, next_offset)。""" + tag = data[offset] + offset += 1 + length = data[offset] + offset += 1 + if length & 0x80: + count = length & 0x7F + length = int.from_bytes(data[offset:offset + count], "big") + offset += count + return tag, data[offset:offset + length], offset + length + + +def spki_to_rsa(der): + """从 SubjectPublicKeyInfo 里取出 (n, e)。""" + _, spki, _ = der_read(der, 0) + _, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过 + _, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING + if bitstring[0] != 0: + raise ValueError("BIT STRING 未使用位不为 0") + _, rsa_seq, _ = der_read(bitstring[1:], 0) + _, modulus, off2 = der_read(rsa_seq, 0) + _, exponent, _ = der_read(rsa_seq, off2) + return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big") + +SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420") + + +def rsa_verify_sha256(n, e, signature, message): + try: + size = (n.bit_length() + 7) // 8 + m = pow(int.from_bytes(signature, "big"), e, n) + em = m.to_bytes(size, "big") + if em[0] != 0x00 or em[1] != 0x01: + return False, "PKCS#1 padding 前缀不符" + sep = em.index(b"\x00", 2) + digest_info = em[sep + 1:] + expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest() + if digest_info != expected: + return False, "DigestInfo 不匹配" + return True, "" + except Exception as exc: + return False, "{}: {}".format(type(exc).__name__, exc) + + +def split_message(raw): + sep = raw.find(b"\r\n\r\n") + if sep < 0: + return [], raw + head, body = raw[:sep], raw[sep + 4:] + headers = [] + name = None + for line in head.decode("latin-1").split("\r\n"): + if line[:1] in (" ", "\t") and name: + headers[-1] = (name, headers[-1][1] + "\r\n" + line) + else: + idx = line.find(":") + if idx > 0: + name = line[:idx] + headers.append((name, line[idx + 1:])) + return headers, body + + +def canon_header(name, value): + unfolded = value.replace("\r\n", "").replace("\n", "") + return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip() + + +def canon_body(body): + text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n") + lines = [re.sub(r"[ \t]+", " ", line).rstrip(" \t") for line in text.split("\n")] + joined = "\r\n".join(lines).rstrip("\r\n") + return (joined + "\r\n").encode("latin-1") if joined else b"" + + +def verify_dkim(raw, n, e): + """按 RFC 6376 relaxed/relaxed 验证整封邮件的 DKIM 签名。""" + headers, body = split_message(raw) + dkim = [v for k, v in headers if k.lower() == "dkim-signature"] + if not dkim: + return False, "报文没有 DKIM-Signature 头" + tags = {} + for part in dkim[0].split(";"): + if "=" in part: + key, _, value = part.partition("=") + tags[key.strip()] = value.strip() + if tags.get("a") != "rsa-sha256": + return False, "非 rsa-sha256: " + str(tags.get("a")) + + if base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode() != tags.get("bh"): + return False, "正文哈希(bh)不匹配" + + # RFC 6376 §3.7 第 2 步:先按 h= 顺序哈希各被签名头(每个后面跟一个 CRLF), + # 最后哈希 DKIM-Signature 头本身且不带结尾 CRLF。 + signing = "" + for name in tags.get("h", "").split(":"): + hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None) + if hit is None: + return False, "缺少被签名头 " + name + signing += canon_header(name, hit) + "\r\n" + signing += canon_header("DKIM-Signature", dkim[0].replace(tags["b"], "").rstrip()) + + return rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii")) + + +# --------------------------------------------------------------------- SMTP 辅助 + +def smtp_talk(host, port, commands, starttls=False, auth=None, timeout=25): + """返回每一步的响应码列表,便于断言协议细节。""" + out = [] + client = smtplib.SMTP(host, port) + client.ehlo("e2e.local") + out.append(("EHLO", 250, sorted(client.esmtp_features.keys()))) + if starttls: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + # Python 3.8 的 smtplib.starttls(keyfile, certfile, context):context 必须用关键字传, + # 否则会被当成 keyfile(报 'certfile must be specified') + client.starttls(context=ctx) + client.ehlo("e2e.local") + out.append(("EHLO-after-TLS", 250, sorted(client.esmtp_features.keys()))) + if auth: + client.login(auth[0], auth[1]) + out.append(("AUTH", 235, "")) + return client, out + + +def build_chinese_message(sender, recipient, subject, body): + msg = EmailMessage() + msg["From"] = sender + msg["To"] = recipient + msg["Subject"] = subject + msg["Date"] = email_utils.formatdate(localtime=True) + msg["Message-ID"] = email_utils.make_msgid(domain=sender.split("@")[-1]) + msg.set_content(body) + return msg + + +def build_raw_8bit(sender, recipient, subject, body, token): + return ( + "From: {} <{}>\r\n" + "To: {}\r\n" + "Subject: {}\r\n" + "Date: {}\r\n" + "Message-ID: \r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/plain; charset=UTF-8\r\n" + "Content-Transfer-Encoding: 8bit\r\n" + "\r\n" + "{}\r\n" + ).format("验收发件人", sender, recipient, subject, email_utils.formatdate(localtime=True), token, body).encode("utf-8") + + +# --------------------------------------------------------------------- 主流程 + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--report", default=r"C:\_probe\e2e_report.txt") + parser.add_argument("--host", default="127.0.0.1") + args = parser.parse_args() + + cfg = load_config() + domain = cfg["Domain"] + hostname = cfg["Hostname"] + account = cfg["AdminEmail"] + password = cfg["AdminPassword"] + smtp_port = int(cfg.get("SmtpPort", 25)) + submission_port = int(cfg.get("SubmissionPort", 587)) + imap_port = int(cfg.get("Imap", {}).get("Port", 143)) + api_base = "http://127.0.0.1:{}/".format( + re.search(r":(\d+)", cfg.get("HttpPrefix", "http://127.0.0.1:8787/")).group(1)) + stamp = time.strftime("%H%M%S") + host = args.host + print("WpywMail v2 端到端验收 domain={} account={} {}".format(domain, account, stamp)) + + # ---------- 1. 二进制自检 ---------- + section("1) 二进制自检(--check-config / --selftest)") + try: + out = subprocess.run([EXE, "--check-config"], capture_output=True, timeout=60) + check("配置校验通过", out.returncode == 0, out.stderr.decode("utf-8", "replace")[:120]) + out = subprocess.run([EXE, "--selftest"], capture_output=True, timeout=180) + text = out.stdout.decode("utf-8", "replace") + match = re.search(r"=+ 结果:(\d+) 项通过,(\d+) 项失败", text) + check("内建自检全部通过", out.returncode == 0 and match and match.group(2) == "0", + match.group(0) if match else text[-160:]) + except Exception as exc: + check("二进制自检", False, exc) + + # ---------- 2. DKIM 公钥可导出 ---------- + section("2) DKIM 公钥与 RSA 参数") + dkim_record = "" + n = e = None + try: + out = subprocess.run([EXE, "--dkim-dns"], capture_output=True, timeout=120) + for line in out.stdout.decode("utf-8", "replace").splitlines(): + if line.startswith("VALUE="): + dkim_record = line[len("VALUE="):].strip() + if line.startswith("NAME="): + dkim_name = line[len("NAME="):].strip() + check("--dkim-dns 输出公钥记录", dkim_record.startswith("v=DKIM1;"), dkim_record[:80]) + match = re.search(r"p=([A-Za-z0-9+/=]+)", dkim_record) + n, e = spki_to_rsa(base64.b64decode(match.group(1))) + check("公钥可解析为 RSA 参数(2048 位)", n.bit_length() == 2048, + "modulus {} 位".format(n.bit_length())) + print(" 待发布记录: {}.{} = {}...(共 {} 字符)".format( + dkim_name, domain, dkim_record[:48], len(dkim_record))) + except Exception as exc: + check("DKIM 公钥导出", False, exc) + + # ---------- 3. SMTP 收信(8bit 裸 UTF-8 中文) ---------- + section("3) SMTP 收信(公网收信端口,8bit 裸 UTF-8)") + inbox_token = "E2E-IN-" + stamp + try: + subject = "[验收] 外网中文邮件 " + inbox_token + body = "这是验收测试注入的中文正文。\n标记:{}\n全角标点:你好,世界。()《》——".format(inbox_token) + raw = build_raw_8bit("probe@e2e.local", account, subject, body, inbox_token) + client = smtplib.SMTP(host, smtp_port) + code, caps = client.ehlo("e2e.local") + check("25 端口 EHLO 成功", code == 250, code) + check("25 端口广告 8BITMIME", "8bitmime" in [c.lower() for c in caps.decode().split("\n")[-1:]] or b"8BITMIME" in caps, + caps.decode("utf-8", "replace")[:120]) + client.sendmail("probe@e2e.local", [account], raw, mail_options=["BODY=8BITMIME"]) + client.quit() + check("8bit 中文邮件投递被接受", True) + except Exception as exc: + check("8bit 中文邮件投递被接受", False, exc) + + # ---------- 4. API 登录与收信入库 ---------- + section("4) API:登录 / 收件箱 / 中文解码") + token = None + try: + status, obj = api(api_base, "/api/login", "POST", {"email": account, "password": password}) + check("登录成功", status == 200 and obj.get("token"), status) + token = obj.get("token") + status, obj = api(api_base, "/api/me", token=token) + check("/api/me 返回统计", status == 200 and "stats" in obj, status) + time.sleep(1.5) + status, obj = api(api_base, "/api/messages?folder=inbox&q=" + inbox_token, token=token) + found = obj.get("messages", []) if status == 200 else [] + check("刚投递的中文邮件出现在收件箱", len(found) == 1, "命中 {} 封".format(len(found))) + if found: + check("主题中文正确(无编解码乱码)", inbox_token in found[0]["subject"], found[0]["subject"]) + check("正文预览中文正确", "验收测试注入的中文正文" in found[0].get("preview", ""), found[0].get("preview")) + except Exception as exc: + check("API 收件箱检查", False, exc) + + # ---------- 5. SMTP 提交(STARTTLS + AUTH) ---------- + section("5) SMTP 提交端口:STARTTLS + AUTH + 中文提交") + submit_token = "E2E-OUT-" + stamp + try: + client, steps = smtp_talk(host, submission_port, [], starttls=True) + caps_plain = steps[0][2] + check("587 明文阶段广告 STARTTLS", "starttls" in caps_plain, caps_plain) + caps_tls = steps[1][2] if len(steps) > 1 else [] + check("TLS 之后才广告 AUTH", "auth" in caps_tls, caps_tls) + client.login(account, password) + check("AUTH 认证成功(旧版此处为 538 死锁)", True) + msg = build_chinese_message(account, account, "[验收] 587 提交中文邮件 " + submit_token, + "通过 STARTTLS + AUTH 提交的中文正文。\n标记:{}".format(submit_token)) + client.send_message(msg) + client.quit() + check("中文邮件经 587 提交成功", True) + except Exception as exc: + check("587 提交链路", False, exc) + + # ---------- 6. 队列与本地投递 ---------- + section("6) 出站队列与本地投递") + try: + deadline = time.time() + 40 + state = "?" + while time.time() < deadline: + status, obj = api(api_base, "/api/queue", token=token) + items = [x for x in obj.get("queue", []) if any(r == account for r in x["recipients"])] + if items and all(x["status"] == "sent" for x in items): + state = "sent" + break + state = items[-1]["status"] if items else "(无任务)" + time.sleep(3) + check("发给本机账号的任务投递完成", state == "sent", "最终状态=" + state) + except Exception as exc: + check("队列投递", False, exc) + + # ---------- 7. DKIM 独立验签(对公钥验证真实投递报文) ---------- + section("7) DKIM:对将来要发布的公钥验证真实投递报文") + if n and e: + try: + status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token) + msgs = obj.get("messages", []) if status == 200 else [] + signed_id = msgs[0]["id"] if msgs else None + if not signed_id: + check("找到带签名的投递副本", False, "收件箱没有找到 587 提交的那封") + else: + status, raw = api(api_base, "/api/messages/{}/raw".format(signed_id), token=token, raw=True) + has_sig = b"DKIM-Signature" in raw + check("投递副本带 DKIM-Signature", has_sig, "{} 字节".format(len(raw))) + if has_sig: + ok, reason = verify_dkim(raw, n, e) + check("DKIM 签名对公钥验证通过(rsa-sha256 / 正文哈希一致)", ok, reason) + except Exception as exc: + check("DKIM 独立验签", False, exc) + else: + check("DKIM 独立验签", False, "公钥不可用") + + # ---------- 8. API 其余端点 ---------- + section("8) API:附件 / 搜索 / 标记 / 长轮询") + try: + status, version = api(api_base, "/api/watch?since=0", token=token) + check("/api/watch 长轮询返回版本号", status == 200 and "version" in version, status) + + attachment = base64.b64encode("中文附件内容".encode("utf-8")).decode() + status, obj = api(api_base, "/api/send", "POST", { + "to": account, + "subject": "[验收] 带附件 " + submit_token, + "text": "正文见附件。", + "attachments": [{"fileName": "验收附件.txt", "contentType": "text/plain", "base64": attachment}], + }, token=token) + check("/api/send 带附件入队(202)", status == 202 and obj.get("queued"), status) + + time.sleep(6) + status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token) + hits = obj.get("messages", []) if status == 200 else [] + attach_msg = next((m for m in hits if m.get("hasAttachments")), None) + check("带附件的邮件入库并识别出附件", attach_msg is not None, + "命中 {} 封".format(len(hits))) + if attach_msg: + status, blob = api(api_base, "/api/messages/{}/attachments/0".format(attach_msg["id"]), + token=token, raw=True) + check("附件按原字节下载", status == 200 and blob.decode("utf-8", "replace") == "中文附件内容", + "{} 字节".format(len(blob) if isinstance(blob, bytes) else -1)) + + if hits: + mid = hits[0]["id"] + status, obj = api(api_base, "/api/messages/{}".format(mid), "PATCH", {"starred": True}, token=token) + check("PATCH 设置星标", status == 200 and obj["message"]["starred"], status) + status, obj = api(api_base, "/api/messages?folder=inbox&starred=1", token=token) + check("按星标筛选命中", status == 200 and any(m["id"] == mid for m in obj.get("messages", [])), status) + except Exception as exc: + check("API 其余端点", False, exc) + + # ---------- 9. IMAP ---------- + section("9) IMAP:真实客户端流程") + try: + imap = imaplib.IMAP4(host, imap_port) + check("IMAP 欢迎语含 IMAP4rev1", b"IMAP4rev1" in imap.welcome, imap.welcome[:60]) + caps = imap.capability()[1][0].decode() + check("广告 STARTTLS", "STARTTLS" in caps, caps[:80]) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + imap.starttls(ctx) + check("STARTTLS 握手成功", True) + imap.login(account, password) + check("IMAP LOGIN 成功", True) + boxes = imap.list()[1] + check("LIST 返回 6 个文件夹", len(boxes) == 6, "{} 个".format(len(boxes))) + typ, data = imap.select("INBOX") + exists = int(data[0]) + check("SELECT INBOX 有邮件", exists > 0, "{} 封".format(exists)) + typ, data = imap.search(None, "ALL") + ids = data[0].split() + check("SEARCH ALL 返回序号", len(ids) == exists, "{} vs {}".format(len(ids), exists)) + typ, data = imap.fetch(ids[-1], "(FLAGS RFC822.SIZE ENVELOPE)") + summary = data[0].decode("latin-1") if data and isinstance(data[0], bytes) else str(data[0]) + check("FETCH 摘要含 ENVELOPE 与 RFC822.SIZE", "ENVELOPE" in summary and "RFC822.SIZE" in summary, + summary[:120]) + check("RFC822.SIZE 非 0(旧邮件回退实际文件大小)", "RFC822.SIZE 0 " not in summary, summary[:120]) + typ, data = imap.fetch(ids[-1], "(BODY.PEEK[HEADER.FIELDS (SUBJECT)])") + head = data[0][1].decode("utf-8", "replace") if isinstance(data[0], tuple) else b"".decode() + check("HEADER.FIELDS 可取回主题", "Subject:" in head, head[:80]) + typ, data = imap.store(ids[-1], "+FLAGS", "(\\Seen)") + check("STORE +FLAGS 成功", typ == "OK", typ) + draft = "From: {}\r\nTo: someone@example.com\r\nSubject: =?UTF-8?B?{}?=\r\nMIME-Version: 1.0\r\n\r\n正文\r\n".format( + account, base64.b64encode("验收草稿".encode("utf-8")).decode()) + typ, data = imap.append("Drafts", "(\\Draft)", None, draft.encode("utf-8")) + check("APPEND 中文草稿成功", typ == "OK", typ) + imap.select("Drafts") + typ, data = imap.search(None, "ALL") + check("草稿已入库", len(data[0].split()) > 0, data[0]) + imap.logout() + check("IMAP 正常注销", True) + except Exception as exc: + check("IMAP 流程", False, "{}: {}".format(type(exc).__name__, exc)) + + # ---------- 10. 安全回归 ---------- + section("10) 安全回归:非开放中继 / 提交需认证 / 明文登录限制") + try: + client = smtplib.SMTP(host, smtp_port) + client.ehlo("e2e.local") + client.docmd("MAIL FROM:") + code, resp = client.docmd("RCPT TO:") + check("25 端口拒绝外域收件人(非开放中继)", code == 550, "{} {}".format(code, resp)) + client.quit() + except Exception as exc: + check("非开放中继", False, exc) + + try: + client = smtplib.SMTP(host, submission_port) + client.ehlo("e2e.local") + code, resp = client.docmd("MAIL FROM:") + check("587 未认证即发信被拒(530)", code == 530, "{} {}".format(code, resp)) + client.quit() + except Exception as exc: + check("提交需认证", False, exc) + + # ---------- 汇总 ---------- + section("汇总") + total = len(PASSED) + len(FAILED) + print(" 通过 {} / {},失败 {}".format(len(PASSED), total, len(FAILED))) + if FAILED: + print(" 失败用例:") + for name in FAILED: + print(" - " + name) + + try: + with open(args.report, "w", encoding="utf-8") as fh: + fh.write("WpywMail v2 端到端验收报告 {}\n".format(time.strftime("%Y-%m-%d %H:%M:%S"))) + fh.write("域: {} 账号: {} 主机: {}\n\n".format(domain, account, hostname)) + fh.write("通过 {} / {},失败 {}\n".format(len(PASSED), total, len(FAILED))) + if FAILED: + fh.write("\n失败用例:\n" + "\n".join("- " + x for x in FAILED) + "\n") + fh.write("\n全部用例:\n" + "\n".join("PASS " + x for x in PASSED) + "\n") + print(" 报告已写入: " + args.report) + except Exception as exc: + print(" 报告写入失败: {}".format(exc)) + + return len(FAILED) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-native-v2/tools/publish-dns.ps1 b/server-native-v2/tools/publish-dns.ps1 new file mode 100644 index 0000000..e2b9940 --- /dev/null +++ b/server-native-v2/tools/publish-dns.ps1 @@ -0,0 +1,100 @@ +<# +.SYNOPSIS + 把 WpywMail 需要的 DKIM / DMARC 记录写入 Cloudflare(一条命令补完 DNS)。 + +.DESCRIPTION + 记录值直接由服务端二进制从 DKIM 私钥推导(--dkim-dns),避免手工复制出错。 + 幂等:已存在的同名记录会被更新而不是重复创建。 + +.EXAMPLE + # 在本机执行(会通过 WinRM 读取服务器上的公钥) + .\publish-dns.ps1 -Server -Zone wpy.email -ApiToken "" + +.NOTES + Token 需要权限:Zone:DNS:Edit(对该 zone)。 + 也可先只看将要写入的内容而不实际提交:加上 -WhatIfOnly +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Server, + [Parameter(Mandatory)][string]$Zone, + [Parameter(Mandatory)][string]$ApiToken, + [string]$RemoteUser = 'Administrator', + [string]$RemotePassword, + [string]$RemoteExe = 'C:\Program Files\WpywMail\WpywMail.Native.exe', + [string]$DmarcPolicy = 'p=none', + [switch]$WhatIfOnly +) + +$ErrorActionPreference = 'Stop' + +function Invoke-Cf { + param([string]$Method, [string]$Path, $Body) + $uri = "https://api.cloudflare.com/client/v4$Path" + $headers = @{ Authorization = "Bearer $ApiToken"; 'Content-Type' = 'application/json' } + if ($Body) { + Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -Body ($Body | ConvertTo-Json -Depth 8) + } else { + Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers + } +} + +# ---------- 1) 从服务器取回需要写入的记录 ---------- +Write-Host '正在从服务器读取 DKIM 公钥…' -ForegroundColor Cyan +if ($RemotePassword) { + $sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force + $cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec) + $lines = Invoke-Command -ComputerName $Server -Credential $cred -ScriptBlock { + param($exe) & $exe --dkim-dns + } -ArgumentList $RemoteExe +} else { + $lines = Invoke-Command -ComputerName $Server -ScriptBlock { + param($exe) & $exe --dkim-dns + } -ArgumentList $RemoteExe +} + +$map = @{} +foreach ($line in $lines) { + if ($line -match '^([A-Z_]+)=(.*)$') { $map[$Matches[1]] = $Matches[2] } +} +if (-not $map['NAME'] -or -not $map['VALUE']) { throw "未能从服务器取得 DKIM 记录(输出:`n$($lines -join "`n"))" } + +$dmarcShortName = $map['DMARC_NAME'] -replace "\.$([regex]::Escape($Zone))$", '' +$dmarcValue = $map['DMARC_VALUE'] -replace 'p=none', $DmarcPolicy + +$records = @( + @{ Type = 'TXT'; Name = $map['NAME']; Content = $map['VALUE']; Comment = 'WpywMail DKIM' }, + @{ Type = 'TXT'; Name = $dmarcShortName; Content = $dmarcValue; Comment = 'WpywMail DMARC' } +) + +Write-Host "`n将写入以下记录(zone=$Zone):" -ForegroundColor Cyan +foreach ($r in $records) { + Write-Host (" {0,-4} {1,-28} {2}" -f $r.Type, $r.Name, ($r.Content.Substring(0, [Math]::Min(80, $r.Content.Length)) + $(if ($r.Content.Length -gt 80) { '…' } else { '' }))) +} +if ($WhatIfOnly) { Write-Host "`n-WhatIfOnly:未提交任何更改。" -ForegroundColor Yellow; return } + +# ---------- 2) 解析 zone id ---------- +$zones = Invoke-Cf -Method GET -Path "/zones?name=$Zone" +if (-not $zones.result -or $zones.result.Count -eq 0) { throw "Cloudflare 中找不到 zone:$Zone(检查 Token 权限与域名拼写)" } +$zoneId = $zones.result[0].id +Write-Host "`nzone id: $zoneId" -ForegroundColor DarkGray + +# ---------- 3) 幂等写入 ---------- +foreach ($r in $records) { + $fqdn = if ($r.Name) { "$($r.Name).$Zone" } else { $Zone } + $existing = Invoke-Cf -Method GET -Path "/zones/$zoneId/dns_records?type=$($r.Type)&name=$fqdn" + $payload = @{ type = $r.Type; name = $fqdn; content = $r.Content; ttl = 1; comment = $r.Comment } + + if ($existing.result.Count -gt 0) { + $id = $existing.result[0].id + $null = Invoke-Cf -Method PUT -Path "/zones/$zoneId/dns_records/$id" -Body $payload + Write-Host " [更新] $fqdn" -ForegroundColor Yellow + } else { + $null = Invoke-Cf -Method POST -Path "/zones/$zoneId/dns_records" -Body $payload + Write-Host " [新建] $fqdn" -ForegroundColor Green + } +} + +Write-Host "`n完成。等 1-2 分钟后可用以下命令核验:" -ForegroundColor Cyan +Write-Host " Resolve-DnsName $($map['NAME']).$Zone -Type TXT -Server 1.1.1.1" +Write-Host " Resolve-DnsName _dmarc.$Zone -Type TXT -Server 1.1.1.1" diff --git a/server-native-v2/tools/verify_published_dkim.py b/server-native-v2/tools/verify_published_dkim.py new file mode 100644 index 0000000..fe618bf --- /dev/null +++ b/server-native-v2/tools/verify_published_dkim.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +""" +用「DNS 上已发布的 DKIM 公钥」独立验证真实投递报文的签名。 + +与 e2e_acceptance.py 内的验签不同:本脚本**不接触私钥**,只吃 DNS TXT 记录的 +字面值(v=DKIM1; k=rsa; p=...),因此它证明的是收件方(Gmail/Outlook/QQ) +将会看到的事实:公钥一发布,签名即可被验证通过。 + +零第三方依赖:手写 DER 解析 SPKI -> (n, e),再用 pow() 做 RSA-SHA256 +PKCS#1 v1.5 验签。Python 3.8+ 可跑。 + +用法: + python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml a.eml --eml b.eml + python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml-dir C:\\path\\raw + python verify_published_dkim.py --dns-file ... --eml-dir ... --selector mail --domain wpy.email + +--dns-file 内容可以是整条 TXT(v=DKIM1; k=rsa; p=...),也可以是只含 p= 后面 +那段 base64 的纯文本;还能容忍 DNS 分段留下的空白/换行。 + +退出码 = 验签失败的报文数(0 表示全部通过)。 +""" +from __future__ import print_function + +import argparse +import base64 +import hashlib +import os +import re +import sys + +SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420") + + +# ------------------------------------------------------------------ DER / RSA + +def der_read(data, offset): + """读一个 TLV,返回 (tag, content, next_offset)。仅支持短/长形式长度。""" + tag = data[offset] + length = data[offset + 1] + offset += 2 + if length & 0x80: + count = length & 0x7F + length = int.from_bytes(data[offset:offset + count], "big") + offset += count + return tag, data[offset:offset + length], offset + length + + +def spki_to_rsa(der): + """从 SubjectPublicKeyInfo 里取出 (n, e)。""" + _, spki, _ = der_read(der, 0) + _, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过 + _, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING + if bitstring[0] != 0: + raise ValueError("BIT STRING 未使用位不为 0") + _, rsa_seq, _ = der_read(bitstring[1:], 0) + _, modulus, off2 = der_read(rsa_seq, 0) + _, exponent, _ = der_read(rsa_seq, off2) + return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big") + + +def rsa_verify_sha256(n, e, signature, message): + try: + size = (n.bit_length() + 7) // 8 + if len(signature) != size: + return False, "签名长度 %d 与模长 %d 不符" % (len(signature), size) + m = pow(int.from_bytes(signature, "big"), e, n) + em = m.to_bytes(size, "big") + if em[0] != 0x00 or em[1] != 0x01: + return False, "PKCS#1 v1.5 padding 前缀不符" + sep = em.index(b"\x00", 2) + digest_info = em[sep + 1:] + expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest() + if digest_info != expected: + return False, "DigestInfo 不匹配(摘要或签名输入被改动)" + return True, "" + except Exception as exc: # noqa: BLE001 - 验签失败即失败 + return False, "%s: %s" % (type(exc).__name__, exc) + + +# ------------------------------------------------------------------ DNS TXT 解析 + +def parse_txt_record(text): + """从 TXT 字面值里取出 p= 的 base64 并解析成 (n, e)。""" + flat = re.sub(r"\s+", "", text) # DNS 分段/换行一律去掉 + m = re.search(r"p=([A-Za-z0-9+/=]+)", flat) + if not m: + raise ValueError("TXT 里找不到 p= 公钥段") + pem_b64 = m.group(1) + try: + der = base64.b64decode(pem_b64, validate=True) + except Exception as exc: # noqa: BLE001 + raise ValueError("p= 不是合法 base64: %s" % exc) + return spki_to_rsa(der), pem_b64 + + +def record_tags(text): + flat = re.sub(r"\s+", " ", text).strip() + tags = {} + for part in flat.split(";"): + if "=" in part: + k, _, v = part.partition("=") + tags[k.strip().lower()] = v.strip() + return tags + + +# ------------------------------------------------------------------ DKIM 验证 + +def split_message(raw): + sep = raw.find(b"\r\n\r\n") + if sep < 0: + return [], raw + head, body = raw[:sep], raw[sep + 4:] + headers, name = [], None + for line in head.decode("latin-1").split("\r\n"): + if line[:1] in (" ", "\t") and name: + headers[-1] = (name, headers[-1][1] + "\r\n" + line) + else: + idx = line.find(":") + if idx > 0: + name = line[:idx] + headers.append((name, line[idx + 1:])) + return headers, body + + +def canon_header(name, value): + unfolded = value.replace("\r\n", "").replace("\n", "") + return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip() + + +def canon_body(body): + text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n") + lines = [re.sub(r"[ \t]+", " ", ln).rstrip(" \t") for ln in text.split("\n")] + joined = "\r\n".join(lines).rstrip("\r\n") + return (joined + "\r\n").encode("latin-1") if joined else b"" + + +def verify(raw, n, e): + """返回 (是否通过, 说明, 详情 dict)。relaxed/relaxed + rsa-sha256。""" + headers, body = split_message(raw) + sigs = [v for k, v in headers if k.lower() == "dkim-signature"] + if not sigs: + return False, "报文里没有 DKIM-Signature 头", {} + tags = {} + for part in sigs[0].split(";"): + if "=" in part: + k, _, v = part.partition("=") + tags[k.strip().lower()] = v.strip() + info = {"d": tags.get("d"), "s": tags.get("s"), "a": tags.get("a"), + "c": tags.get("c"), "h": tags.get("h"), "bh": tags.get("bh"), + "b_len": len(tags.get("b", ""))} + + if tags.get("a") != "rsa-sha256": + return False, "非 rsa-sha256(%s)" % tags.get("a"), info + if tags.get("c", "simple/simple") != "relaxed/relaxed": + return False, "非 relaxed/relaxed(%s)" % tags.get("c"), info + + bh_calc = base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode() + info["bh_calc"] = bh_calc + if bh_calc != tags.get("bh"): + return False, "正文哈希 bh 不匹配(正文被改动)", info + + signing = "" + for name in tags.get("h", "").split(":"): + if not name.strip(): + continue + hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None) + if hit is None: + return False, "被签名的头缺失: %s" % name, info + # RFC 6376 §3.7 第 2 步之 1:每个被签名头后面必须跟一个 CRLF + signing += canon_header(name, hit) + "\r\n" + # 之 2:DKIM-Signature 头本身放在**最后**,且结尾**不带 CRLF** + signing += canon_header("DKIM-Signature", sigs[0].replace(tags["b"], "").rstrip()) + + ok, why = rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii")) + return ok, why, info + + +# ------------------------------------------------------------------ 主流程 + +def collect(paths, eml_dir): + files = list(paths) + if eml_dir: + for name in sorted(os.listdir(eml_dir)): + if name.lower().endswith(".eml"): + files.append(os.path.join(eml_dir, name)) + return files + + +def main(): + ap = argparse.ArgumentParser(description="用 DNS 已发布的公钥验证 DKIM") + ap.add_argument("--dns-file", required=True, help="含已发布 TXT 值的文本文件") + ap.add_argument("--eml", action="append", default=[], help="待验报文(可重复)") + ap.add_argument("--eml-dir", help="目录下所有 .eml 都验") + ap.add_argument("--selector", default=None, help="期望的选择器(校验 s=)") + ap.add_argument("--domain", default=None, help="期望的签名域(校验 d=)") + args = ap.parse_args() + + raw_txt = open(args.dns_file, "rb").read().decode("utf-8", "replace") + try: + (n, e), pem_b64 = parse_txt_record(raw_txt) + except ValueError as exc: + print("[致命] 无法从 DNS 值解析公钥: %s" % exc) + return 2 + tags = record_tags(raw_txt) + + print("=" * 68) + print("DNS 已发布 DKIM 公钥") + print("=" * 68) + print(" 记录长度 : %d 字符" % len(re.sub(r"\s+", "", raw_txt))) + print(" v / k : %s / %s" % (tags.get("v"), tags.get("k"))) + print(" 公钥位长 : %d bit" % n.bit_length()) + print(" 公钥指数 : %d" % e) + print(" p= 长度 : %d 字符 base64" % len(pem_b64)) + if tags.get("v") != "DKIM1": + print(" [警告] v 不是 DKIM1") + if n.bit_length() < 1024: + print(" [警告] 密钥短于 1024 bit,多数收件方会直接判失败") + + files = collect(args.eml, args.eml_dir) + if not files: + print("\n[致命] 没有指定任何 .eml") + return 2 + + print("\n" + "=" * 68) + print("对真实报文验签(只用上面这把公钥,不接触私钥)") + print("=" * 68) + failures = 0 + for path in files: + name = os.path.basename(path) + data = open(path, "rb").read() + ok, why, info = verify(data, n, e) + print("\n%s (%d 字节)" % (name, len(data))) + print(" d=%s s=%s a=%s c=%s" % (info.get("d"), info.get("s"), info.get("a"), info.get("c"))) + print(" 签名字段 b 长度 %s,h=%s" % (info.get("b_len"), info.get("h"))) + print(" 正文哈希 bh: %s" % ("一致" if info.get("bh") == info.get("bh_calc") else "不一致")) + if args.domain and info.get("d") != args.domain: + ok, why = False, "d= 与期望域名 %s 不符(实际 %s)" % (args.domain, info.get("d")) + if args.selector and info.get("s") != args.selector: + ok, why = False, "s= 与期望选择器 %s 不符(实际 %s)" % (args.selector, info.get("s")) + if ok: + print(" [通过] RSA-SHA256 签名验证成功 —— 收件方用这条 DNS 记录即可验签") + else: + print(" [失败] %s" % why) + failures += 1 + + print("\n" + "=" * 68) + print("合计: %d 个报文,通过 %d,失败 %d" % (len(files), len(files) - failures, failures)) + print("=" * 68) + return failures + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-native/ApiServer.cs b/server-native/ApiServer.cs new file mode 100644 index 0000000..6f99c54 --- /dev/null +++ b/server-native/ApiServer.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace WpywMail.Native; + +public sealed class ApiServer +{ + private readonly AppConfig config; + private readonly FileStore store; + private readonly HttpListener listener = new(); + private readonly ConcurrentDictionary sessions = new(); + private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web); + + public ApiServer(AppConfig config, FileStore store) { this.config = config; this.store = store; listener.Prefixes.Add(config.HttpPrefix); } + + public async Task RunAsync(CancellationToken token) + { + listener.Start(); + AppLog.Info($"[接口] 已监听:{config.HttpPrefix}"); + try + { + while (!token.IsCancellationRequested) + { + var context = await listener.GetContextAsync().WaitAsync(token); + _ = Task.Run(() => Handle(context), token); + } + } + catch (OperationCanceledException) { } + finally { listener.Stop(); } + } + + private async Task Handle(HttpListenerContext context) + { + var request = context.Request; var response = context.Response; + response.Headers["Access-Control-Allow-Origin"] = "*"; + response.Headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"; + response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, OPTIONS"; + if (request.HttpMethod == "OPTIONS") { response.StatusCode = 204; response.Close(); return; } + try + { + var path = request.Url?.AbsolutePath.TrimEnd('/') ?? ""; + if (path == "/api/health") { await Reply(response, new { ok = true, service = "wpyw.mail.native", hostname = config.Hostname, domain = config.Domain }); return; } + if (path == "/api/login" && request.HttpMethod == "POST") { await Login(request, response); return; } + var user = Authenticate(request); + if (user is null) { await Reply(response, new { error = "登录已失效" }, 401); return; } + if (path == "/api/me") { await Reply(response, new { user = new { email = user.Email, role = user.Role }, stats = store.Stats(user.Email) }); return; } + if (path == "/api/config") { await Reply(response, new { domain = config.Domain, hostname = config.Hostname, account = user.Email, protocols = new { smtp = config.SmtpPort, submission = config.SubmissionPort, api = config.HttpPrefix } }); return; } + if (path == "/api/logout" && request.HttpMethod == "POST") { RemoveSession(request); await Reply(response, new { ok = true }); return; } + if (path == "/api/messages" && request.HttpMethod == "GET") { await ListMessages(request, response, user); return; } + if (path.StartsWith("/api/messages/", StringComparison.OrdinalIgnoreCase)) { await MessageDetail(request, response, user, path[14..]); return; } + if (path == "/api/send" && request.HttpMethod == "POST") { await SendMessage(request, response, user); return; } + if (path == "/api/account/password" && request.HttpMethod == "POST") { await ChangePassword(request, response, user); return; } + if (path == "/api/admin/users" && user.Role == "admin") { await AdminUsers(request, response); return; } + await Reply(response, new { error = "接口不存在" }, 404); + } + catch (Exception ex) { AppLog.Error($"[接口] {request.HttpMethod} {request.Url}:{ex.Message}"); await Reply(response, new { error = ex.Message }, 500); } + } + + private async Task Login(HttpListenerRequest request, HttpListenerResponse response) + { + var body = await ReadJson(request) ?? new LoginRequest("", ""); + var user = store.Authenticate(body.Email, body.Password); + if (user is null) { await Reply(response, new { error = "邮箱或密码不正确" }, 401); return; } + var token = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + sessions[token] = new Session(user.Email, DateTimeOffset.UtcNow.AddDays(7)); + await Reply(response, new { token, user = new { email = user.Email, role = user.Role, domain = config.Domain } }); + } + + private MailUser? Authenticate(HttpListenerRequest request) + { + var token = request.Headers["Authorization"]?.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase).Trim(); + if (string.IsNullOrWhiteSpace(token) || !sessions.TryGetValue(token, out var session) || session.Expires < DateTimeOffset.UtcNow) return null; + return store.FindUser(session.Email); + } + + private void RemoveSession(HttpListenerRequest request) + { + var token = request.Headers["Authorization"]?.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase).Trim(); + if (!string.IsNullOrWhiteSpace(token)) sessions.TryRemove(token, out _); + } + + private async Task ListMessages(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var folder = request.QueryString["folder"] ?? "inbox"; var query = request.QueryString["q"] ?? ""; + var result = store.ListMessages(user.Email, folder, query).Select(x => new { x.Id, x.From, x.To, x.Subject, x.Date, x.Unread, x.Starred, x.DeliveryStatus, preview = x.Text.Replace("\r", " ").Replace("\n", " ")[..Math.Min(140, x.Text.Length)] }); + await Reply(response, new { messages = result }); + } + + private async Task MessageDetail(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string id) + { + var message = store.GetMessage(user.Email, id); + if (message is null) { await Reply(response, new { error = "邮件不存在" }, 404); return; } + store.MarkRead(user.Email, id); + await Reply(response, new { message }); + } + + private async Task SendMessage(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var body = await ReadJson(request) ?? new SendRequest("", "", ""); + var recipients = Mime.Addresses(body.To); + if (recipients.Length == 0 || string.IsNullOrWhiteSpace(body.Subject) || string.IsNullOrWhiteSpace(body.Text)) { await Reply(response, new { error = "收件人、主题和正文不能为空" }, 400); return; } + var raw = Mime.Build(user.Email, recipients, body.Subject, body.Text); + var message = store.QueueOutbound(user.Email, recipients, body.Subject, body.Text, raw); + await Reply(response, new { queued = true, messageId = message.Id }, 202); + } + + private async Task ChangePassword(HttpListenerRequest request, HttpListenerResponse response, MailUser user) + { + var body = await ReadJson>(request) ?? new(); + if (!body.TryGetValue("password", out var password) || password.Length < 12) { await Reply(response, new { error = "密码至少需要 12 个字符" }, 400); return; } + store.ChangePassword(user.Email, password); await Reply(response, new { ok = true }); + } + + private async Task AdminUsers(HttpListenerRequest request, HttpListenerResponse response) + { + if (request.HttpMethod == "GET") { await Reply(response, new { users = store.ListUsers().Select(x => new { x.Email, x.DisplayName, x.Role, x.Active }) }); return; } + if (request.HttpMethod == "POST") + { + var body = await ReadJson>(request) ?? new(); + if (!body.TryGetValue("email", out var email) || !body.TryGetValue("password", out var password) || password.Length < 12) { await Reply(response, new { error = "邮箱和至少 12 位密码是必需的" }, 400); return; } + var user = store.CreateUser(email, password, body.GetValueOrDefault("displayName", "")); await Reply(response, new { user = new { user.Email, user.DisplayName, user.Role } }, 201); return; + } + await Reply(response, new { error = "不支持的请求方法" }, 405); + } + + private static async Task ReadJson(HttpListenerRequest request) { using var reader = new StreamReader(request.InputStream, Encoding.UTF8); return JsonSerializer.Deserialize(await reader.ReadToEndAsync(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); } + private async Task Reply(HttpListenerResponse response, object value, int status = 200) { response.StatusCode = status; response.ContentType = "application/json; charset=utf-8"; var bytes = JsonSerializer.SerializeToUtf8Bytes(value, json); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); response.Close(); } + private sealed record Session(string Email, DateTimeOffset Expires); +} diff --git a/server-native/AppLog.cs b/server-native/AppLog.cs new file mode 100644 index 0000000..ab6eb0c --- /dev/null +++ b/server-native/AppLog.cs @@ -0,0 +1,32 @@ +using System.Text; + +namespace WpywMail.Native; + +public static class AppLog +{ + private static readonly object Gate = new(); + private static string path = ""; + + public static void Configure(AppConfig config) + { + Directory.CreateDirectory(config.DataDirectory); + path = Path.Combine(config.DataDirectory, "service.log"); + Info("日志系统已启动。"); + } + + public static void Info(string message) => Write("信息", message); + public static void Warn(string message) => Write("警告", message); + public static void Error(string message) => Write("错误", message); + + private static void Write(string level, string message) + { + var line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz} [{level}] {message}"; + Console.WriteLine(line); + if (string.IsNullOrWhiteSpace(path)) return; + try + { + lock (Gate) File.AppendAllText(path, line + Environment.NewLine, new UTF8Encoding(false)); + } + catch { } + } +} diff --git a/server-native/DeliveryQueue.cs b/server-native/DeliveryQueue.cs new file mode 100644 index 0000000..ce1a142 --- /dev/null +++ b/server-native/DeliveryQueue.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Mail; + +namespace WpywMail.Native; + +public sealed class DeliveryQueue +{ + private readonly AppConfig config; + private readonly FileStore store; + private readonly DirectSmtpDelivery direct; + + public DeliveryQueue(AppConfig config, FileStore store) + { + this.config = config; + this.store = store; + direct = new DirectSmtpDelivery(config, store); + } + + public async Task RunAsync(CancellationToken token) + { + AppLog.Info($"[发送] 投递模式:{(config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) ? "SMTP 中继" : "按 MX 直接投递")}"); + while (!token.IsCancellationRequested) + { + foreach (var job in store.TakeDueQueue(10)) + { + try + { + var message = store.GetById(job.MessageId) ?? throw new InvalidOperationException("发送队列中的邮件不存在。"); + await Deliver(message, job.Recipients, token); + store.CompleteQueue(job); + AppLog.Info($"[发送] 投递成功:{string.Join(", ", job.Recipients)}"); + } + catch (Exception ex) + { + store.FailQueue(job, ex); + AppLog.Error($"[发送队列] {job.MessageId} → {string.Join(", ", job.Recipients)}:{ex.Message}"); + } + } + await Task.Delay(TimeSpan.FromSeconds(5), token).ContinueWith(_ => { }); + } + } + + private async Task Deliver(MailMessage message, string[] recipients, CancellationToken token) + { + if (config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase)) + { + await DeliverThroughRelay(message, recipients, token); + return; + } + + await direct.DeliverAsync(message, recipients, token); + } + + private async Task DeliverThroughRelay(MailMessage message, string[] recipients, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(config.Relay.Host)) throw new InvalidOperationException("DeliveryMode=relay 时必须配置 Relay.Host。"); + using var client = new SmtpClient(config.Relay.Host, config.Relay.Port) + { + EnableSsl = config.Relay.EnableSsl, + DeliveryMethod = SmtpDeliveryMethod.Network, + Timeout = 60_000 + }; + if (!string.IsNullOrWhiteSpace(config.Relay.User)) client.Credentials = new NetworkCredential(config.Relay.User, config.Relay.Password); + using var mail = new System.Net.Mail.MailMessage + { + From = new MailAddress(message.From), + Subject = message.Subject, + Body = message.Text, + BodyEncoding = System.Text.Encoding.UTF8, + SubjectEncoding = System.Text.Encoding.UTF8 + }; + foreach (var recipient in recipients) mail.To.Add(recipient); + await client.SendMailAsync(mail, token); + } +} diff --git a/server-native/DirectSmtpDelivery.cs b/server-native/DirectSmtpDelivery.cs new file mode 100644 index 0000000..2942687 --- /dev/null +++ b/server-native/DirectSmtpDelivery.cs @@ -0,0 +1,317 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; + +namespace WpywMail.Native; + +public sealed class DirectSmtpDelivery +{ + private readonly AppConfig config; + private readonly FileStore store; + + public DirectSmtpDelivery(AppConfig config, FileStore store) + { + this.config = config; + this.store = store; + } + + public async Task DeliverAsync(MailMessage message, string[] recipients, CancellationToken token) + { + if (recipients.Length == 0) throw new InvalidOperationException("没有可投递的收件人。"); + var raw = store.ReadRaw(message.RawPath); + foreach (var group in recipients.Where(IsValidAddress).GroupBy(GetDomain, StringComparer.OrdinalIgnoreCase)) + { + await DeliverDomainAsync(message.From, group.Key, group.ToArray(), raw, token); + } + } + + private async Task DeliverDomainAsync(string sender, string domain, string[] recipients, byte[] raw, CancellationToken token) + { + var mxHosts = await MxResolver.ResolveAsync(domain, config.DirectDelivery, token); + if (mxHosts.Count == 0) throw new InvalidOperationException($"找不到 {domain} 的 MX 记录。"); + + Exception? last = null; + foreach (var mxHost in mxHosts) + { + try + { + await SendToMxAsync(mxHost, sender, recipients, raw, token, tryStartTls: true); + return; + } + catch (StartTlsUnavailableException) when (!config.DirectDelivery.RequireStartTls) + { + try + { + await SendToMxAsync(mxHost, sender, recipients, raw, token, tryStartTls: false); + return; + } + catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException or AuthenticationException) + { + last = ex; + AppLog.Warn($"[发送] MX {mxHost} 明文重试失败:{ex.Message}"); + } + } + catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException or AuthenticationException) + { + last = ex; + AppLog.Warn($"[发送] MX {mxHost} 失败:{ex.Message}"); + } + } + + throw new InvalidOperationException($"无法投递到 {domain}:{last?.Message ?? "所有 MX 服务器均失败"}"); + } + + private async Task SendToMxAsync(string mxHost, string sender, string[] recipients, byte[] raw, CancellationToken token, bool tryStartTls) + { + using var client = new TcpClient { NoDelay = true }; + await client.ConnectAsync(mxHost, 25, token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.ConnectionTimeoutSeconds), token); + Stream stream = client.GetStream(); + StreamReader reader = NewReader(stream); + StreamWriter writer = NewWriter(stream); + + try + { + Expect(await ReadReplyAsync(reader, token), "连接欢迎语", 220); + var hello = await CommandAsync(reader, writer, $"EHLO {config.Hostname}", token); + if (hello.Code < 200 || hello.Code >= 300) + { + Expect(await CommandAsync(reader, writer, $"HELO {config.Hostname}", token), "HELO", 250); + } + else if (tryStartTls && config.DirectDelivery.OpportunisticStartTls && HasCapability(hello, "STARTTLS")) + { + var startTls = await CommandAsync(reader, writer, "STARTTLS", token); + if (startTls.Code == 220) + { + try + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false, ValidateCertificate); + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = mxHost, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + }, token).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token); + stream = ssl; + reader = NewReader(stream); + writer = NewWriter(stream); + hello = await CommandAsync(reader, writer, $"EHLO {config.Hostname}", token); + } + catch (AuthenticationException) when (!config.DirectDelivery.RequireStartTls) + { + throw new StartTlsUnavailableException("对方 STARTTLS 证书验证失败。"); + } + } + else if (config.DirectDelivery.RequireStartTls) + { + throw new InvalidOperationException("对方 SMTP 不接受 STARTTLS。"); + } + } + else if (config.DirectDelivery.RequireStartTls) + { + throw new InvalidOperationException("对方 SMTP 未提供 STARTTLS。"); + } + + Expect(await CommandAsync(reader, writer, $"MAIL FROM:<{NormalizeAddress(sender)}>", token), "MAIL FROM", 250); + foreach (var recipient in recipients) + { + Expect(await CommandAsync(reader, writer, $"RCPT TO:<{NormalizeAddress(recipient)}>", token), $"RCPT TO {recipient}", 250, 251); + } + + Expect(await CommandAsync(reader, writer, "DATA", token), "DATA", 354); + await WriteDataAsync(writer, raw, token); + Expect(await ReadReplyAsync(reader, token), "邮件正文", 250); + await TryQuitAsync(reader, writer, token); + } + finally + { + await stream.DisposeAsync(); + } + } + + private async Task WriteDataAsync(StreamWriter writer, byte[] raw, CancellationToken token) + { + var text = Encoding.UTF8.GetString(raw).Replace("\r\n", "\n").Replace('\r', '\n'); + var lines = text.Split('\n'); + for (var index = 0; index < lines.Length; index++) + { + if (index == lines.Length - 1 && lines[index].Length == 0) continue; + var line = lines[index]; + if (line.StartsWith('.')) line = "." + line; + await writer.WriteLineAsync(line).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token); + } + await writer.WriteLineAsync(".").WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token); + } + + private async Task CommandAsync(StreamReader reader, StreamWriter writer, string command, CancellationToken token) + { + await writer.WriteLineAsync(command).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token); + return await ReadReplyAsync(reader, token); + } + + private async Task ReadReplyAsync(StreamReader reader, CancellationToken token) + { + var lines = new List(); + var first = await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token) + ?? throw new IOException("SMTP 连接提前关闭。"); + lines.Add(first); + if (first.Length < 3 || !int.TryParse(first[..3], out var code)) throw new InvalidOperationException($"SMTP 返回无效响应:{first}"); + if (first.Length > 3 && first[3] == '-') + { + while (true) + { + var line = await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token) + ?? throw new IOException("SMTP 多行响应提前结束。"); + lines.Add(line); + if (line.StartsWith($"{code:D3} ", StringComparison.Ordinal)) break; + } + } + return new SmtpReply(code, lines); + } + + private static bool HasCapability(SmtpReply reply, string capability) => + reply.Lines.Any(x => x.Length > 4 && x[4..].StartsWith(capability, StringComparison.OrdinalIgnoreCase)); + + private static void Expect(SmtpReply reply, string step, params int[] expected) + { + if (!expected.Contains(reply.Code)) throw new SmtpDeliveryException(step, reply.Code, reply.Lines.LastOrDefault() ?? ""); + } + + private static async Task TryQuitAsync(StreamReader reader, StreamWriter writer, CancellationToken token) + { + try + { + await writer.WriteLineAsync("QUIT").WaitAsync(TimeSpan.FromSeconds(5), token); + await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(5), token); + } + catch { } + } + + private static StreamReader NewReader(Stream stream) => new(stream, Encoding.ASCII, false, 8192, true); + private static StreamWriter NewWriter(Stream stream) => new(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = "\r\n" }; + private static bool ValidateCertificate(object sender, System.Security.Cryptography.X509Certificates.X509Certificate? certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, SslPolicyErrors errors) => errors == SslPolicyErrors.None; + private static bool IsValidAddress(string value) => value.Contains('@') && value.IndexOf('@') > 0 && value.IndexOf('@') < value.Length - 1; + private static string GetDomain(string value) => value[(value.LastIndexOf('@') + 1)..].Trim().TrimEnd('.').ToLowerInvariant(); + private static string NormalizeAddress(string value) => value.Trim().Trim('<', '>'); + private sealed record SmtpReply(int Code, IReadOnlyList Lines); + private sealed class StartTlsUnavailableException(string message) : Exception(message); +} + +public sealed class SmtpDeliveryException(string step, int code, string detail) : Exception($"{step} 失败:{code} {detail}") +{ + public int Code { get; } = code; + public bool Permanent => Code >= 500 && Code <= 599; +} + +internal static class MxResolver +{ + public static async Task> ResolveAsync(string domain, DirectDeliveryConfig config, CancellationToken token) + { + var servers = GetDnsServers(config.DnsServer); + foreach (var server in servers) + { + try + { + var records = await QueryAsync(domain, server, config.DnsTimeoutSeconds, token); + if (records.Count > 0) return records; + } + catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException) + { + AppLog.Warn($"[DNS] 查询 {domain} 的 MX 失败({server}):{ex.Message}"); + } + } + + try + { + var addresses = await Dns.GetHostAddressesAsync(domain, token); + return addresses.Select(x => x.ToString()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + catch { return []; } + } + + private static async Task> QueryAsync(string domain, IPAddress server, int timeoutSeconds, CancellationToken token) + { + using var udp = new UdpClient(server.AddressFamily); + var query = BuildQuery(domain, out var id); + await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53)); + var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)), token); + return ParseResponse(result.Buffer, id); + } + + private static byte[] BuildQuery(string domain, out ushort id) + { + id = (ushort)Random.Shared.Next(1, ushort.MaxValue); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + writer.Write(ToNetwork(id)); writer.Write(ToNetwork((ushort)0x0100)); writer.Write(ToNetwork((ushort)1)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0)); + foreach (var label in domain.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + var bytes = Encoding.ASCII.GetBytes(label); + writer.Write((byte)bytes.Length); writer.Write(bytes); + } + writer.Write((byte)0); writer.Write(ToNetwork((ushort)15)); writer.Write(ToNetwork((ushort)1)); + return stream.ToArray(); + } + + private static IReadOnlyList ParseResponse(byte[] data, ushort expectedId) + { + if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return []; + var flags = ReadUInt16(data, 2); + if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return []; + var questions = ReadUInt16(data, 4); var answers = ReadUInt16(data, 6); var authority = ReadUInt16(data, 8); var additional = ReadUInt16(data, 10); + var offset = 12; + for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; } + var records = new List<(ushort Preference, string Host)>(); + for (var i = 0; i < answers + authority + additional && offset < data.Length; i++) + { + ReadName(data, ref offset); + if (offset + 10 > data.Length) break; + var type = ReadUInt16(data, offset); var cls = ReadUInt16(data, offset + 2); var length = ReadUInt16(data, offset + 8); offset += 10; + if (offset + length > data.Length) break; + if (type == 15 && cls == 1 && length >= 3) + { + var preference = ReadUInt16(data, offset); var nameOffset = offset + 2; var host = ReadName(data, ref nameOffset); + if (!string.IsNullOrWhiteSpace(host)) records.Add((preference, host.TrimEnd('.'))); + } + offset += length; + } + return records.OrderBy(x => x.Preference).Select(x => x.Host).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private static string ReadName(byte[] data, ref int offset) + { + var labels = new List(); var cursor = offset; var jumped = false; var next = offset; + while (cursor < data.Length) + { + var length = data[cursor++]; + if (length == 0) { if (!jumped) next = cursor; break; } + if ((length & 0xC0) == 0xC0) + { + if (cursor >= data.Length) throw new InvalidOperationException("DNS 名称指针无效。"); + var pointer = ((length & 0x3F) << 8) | data[cursor++]; + if (!jumped) next = cursor; cursor = pointer; jumped = true; continue; + } + if (length > 63 || cursor + length > data.Length) throw new InvalidOperationException("DNS 名称长度无效。"); + labels.Add(Encoding.ASCII.GetString(data, cursor, length)); cursor += length; + } + offset = next; + return string.Join('.', labels); + } + + private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]); + private static ushort ToNetwork(ushort value) => (ushort)((value << 8) | (value >> 8)); + + private static IReadOnlyList GetDnsServers(string configured) + { + if (IPAddress.TryParse(configured, out var parsed)) return [parsed]; + var system = NetworkInterface.GetAllNetworkInterfaces() + .Where(x => x.OperationalStatus == OperationalStatus.Up) + .SelectMany(x => x.GetIPProperties().DnsAddresses) + .Where(x => x.AddressFamily == AddressFamily.InterNetwork || (x.AddressFamily == AddressFamily.InterNetworkV6 && !x.IsIPv6SiteLocal)) + .Distinct() + .OrderBy(x => x.AddressFamily == AddressFamily.InterNetwork ? 0 : 1) + .ToArray(); + return system.Length > 0 ? system : [IPAddress.Parse("223.5.5.5"), IPAddress.Parse("1.1.1.1")]; + } +} diff --git a/server-native/FileStore.cs b/server-native/FileStore.cs new file mode 100644 index 0000000..901331b --- /dev/null +++ b/server-native/FileStore.cs @@ -0,0 +1,234 @@ +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 users = []; + private List messages = []; + private List 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>(usersPath) ?? []; + messages = Read>(messagesPath) ?? []; + queue = Read>(queuePath) ?? []; + foreach (var item in queue.Where(x => x.Status == "processing")) + { + item.Status = "retry"; + item.NextAttempt = DateTimeOffset.UtcNow; + } + } + } + + private T? Read(string path) + { + if (!File.Exists(path)) return default; + try { return JsonSerializer.Deserialize(File.ReadAllText(path), json); } + catch { return default; } + } + + private void Write(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 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 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 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))); +} diff --git a/server-native/Mime.cs b/server-native/Mime.cs new file mode 100644 index 0000000..2e398c7 --- /dev/null +++ b/server-native/Mime.cs @@ -0,0 +1,47 @@ +using System.Text; + +namespace WpywMail.Native; + +public sealed record ParsedMime(string From, string To, string Subject, string MessageId, string Text); + +public static class Mime +{ + public static ParsedMime Parse(byte[] raw) + { + var value = Encoding.UTF8.GetString(raw).Replace("\r\n", "\n"); + var split = value.IndexOf("\n\n", StringComparison.Ordinal); + var headerText = split >= 0 ? value[..split] : value; + var body = split >= 0 ? value[(split + 2)..] : ""; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + string? current = null; + foreach (var line in headerText.Split('\n')) + { + if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) headers[current] += " " + line.Trim(); + else { var colon = line.IndexOf(':'); if (colon > 0) { current = line[..colon]; headers[current] = line[(colon + 1)..].Trim(); } } + } + return new ParsedMime(headers.GetValueOrDefault("From", ""), headers.GetValueOrDefault("To", ""), headers.GetValueOrDefault("Subject", "(无主题)"), headers.GetValueOrDefault("Message-ID", ""), body.TrimEnd()); + } + + public static byte[] Build(string from, string[] to, string subject, string text) + { + var body = WrapBase64(Encoding.UTF8.GetBytes(text)); + var value = $"From: {from}\r\nTo: {string.Join(", ", to)}\r\nSubject: {EncodeHeader(subject)}\r\nDate: {DateTimeOffset.UtcNow:R}\r\nMessage-ID: <{Guid.NewGuid():N}@wpyw.site>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{body}\r\n"; + return Encoding.UTF8.GetBytes(value); + } + + private static string EncodeHeader(string value) + { + if (value.All(ch => ch <= 0x7F)) return value; + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + return $"=?UTF-8?B?{encoded}?="; + } + + private static string WrapBase64(byte[] bytes) + { + var encoded = Convert.ToBase64String(bytes); + return string.Join("\r\n", Enumerable.Range(0, (encoded.Length + 75) / 76) + .Select(index => encoded.Substring(index * 76, Math.Min(76, encoded.Length - index * 76)))); + } + + public static string[] Addresses(string value) => value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(x => x.Contains('<') ? x[(x.IndexOf('<') + 1)..x.IndexOf('>')] : x).Where(x => x.Contains('@')).ToArray(); +} diff --git a/server-native/Models.cs b/server-native/Models.cs new file mode 100644 index 0000000..56f1e82 --- /dev/null +++ b/server-native/Models.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Serialization; + +namespace WpywMail.Native; + +public sealed class AppConfig +{ + public string Domain { get; set; } = "wpyw.site"; + public string Hostname { get; set; } = "mail.wpyw.site"; + public string HttpPrefix { get; set; } = "http://127.0.0.1:8787/"; + public int SmtpPort { get; set; } = 25; + public int SubmissionPort { get; set; } = 587; + public string DataDirectory { get; set; } = @"H:\MailData"; + public string AdminEmail { get; set; } = "admin@wpyw.site"; + public string AdminPassword { get; set; } = ""; + public string TlsCertificatePath { get; set; } = ""; + public string TlsCertificatePassword { get; set; } = ""; + public string DeliveryMode { get; set; } = "direct"; + public DirectDeliveryConfig DirectDelivery { get; set; } = new(); + public RelayConfig Relay { get; set; } = new(); +} + +public sealed class DirectDeliveryConfig +{ + public int ConnectionTimeoutSeconds { get; set; } = 30; + public int CommandTimeoutSeconds { get; set; } = 30; + public int DnsTimeoutSeconds { get; set; } = 5; + public bool OpportunisticStartTls { get; set; } = true; + public bool RequireStartTls { get; set; } + public string DnsServer { get; set; } = ""; +} + +public sealed class RelayConfig +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = 587; + public string User { get; set; } = ""; + public string Password { get; set; } = ""; + public bool EnableSsl { get; set; } = true; +} + +public sealed class MailUser +{ + public string Email { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public string PasswordSalt { get; set; } = ""; + public bool Active { get; set; } = true; + public string Role { get; set; } = "user"; +} + +public sealed class MailMessage +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string OwnerEmail { get; set; } = ""; + public string Folder { get; set; } = "inbox"; + public string From { get; set; } = ""; + public string To { get; set; } = ""; + public string Subject { get; set; } = "(无主题)"; + public string Text { get; set; } = ""; + public string RawPath { get; set; } = ""; + public string MessageId { get; set; } = ""; + public DateTimeOffset Date { get; set; } = DateTimeOffset.UtcNow; + public bool Unread { get; set; } = true; + public bool Starred { get; set; } + public string DeliveryStatus { get; set; } = "received"; +} + +public sealed class QueueItem +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string MessageId { get; set; } = ""; + public string OwnerEmail { get; set; } = ""; + public string[] Recipients { get; set; } = []; + public int Attempts { get; set; } + public DateTimeOffset NextAttempt { get; set; } = DateTimeOffset.UtcNow; + public string Status { get; set; } = "pending"; + public string LastError { get; set; } = ""; +} + +public sealed record LoginRequest(string Email, string Password); +public sealed record SendRequest(string To, string Subject, string Text); diff --git a/server-native/Program.cs b/server-native/Program.cs new file mode 100644 index 0000000..b6aaa34 --- /dev/null +++ b/server-native/Program.cs @@ -0,0 +1,23 @@ +using System.Text.Json; + +namespace WpywMail.Native; + +public static class Program +{ + public static async Task Main() + { + var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(settingsPath)) throw new FileNotFoundException("请将 appsettings.example.json 复制为 appsettings.json 并填写密码。", settingsPath); + var config = JsonSerializer.Deserialize(await File.ReadAllTextAsync(settingsPath), new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? throw new InvalidOperationException("无法读取 appsettings.json"); + if (config.AdminPassword.Length < 12 || config.AdminPassword.Contains("replace-with", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请在 appsettings.json 设置至少 12 位 AdminPassword。"); + AppLog.Configure(config); + var store = new FileStore(config); + using var cancellation = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cancellation.Cancel(); }; + var api = new ApiServer(config, store); + var smtp = new SmtpServer(config, store); + var queue = new DeliveryQueue(config, store); + AppLog.Info($"中文邮箱服务正在启动,域名:{config.Domain},主机名:{config.Hostname}"); + await Task.WhenAll(api.RunAsync(cancellation.Token), smtp.RunAsync(cancellation.Token), queue.RunAsync(cancellation.Token)); + } +} diff --git a/server-native/README.md b/server-native/README.md new file mode 100644 index 0000000..03594c6 --- /dev/null +++ b/server-native/README.md @@ -0,0 +1,78 @@ +# wpyw.mail Windows 邮箱服务 + +这是一个不依赖 Node、npm、数据库或第三方运行库的 Windows 原生服务端。安装包会自动配置: + +- 邮箱:`wpy@wpyw.site` +- 收信:SMTP 25 +- 客户端发信:SMTP Submission 587 +- 客户端接口:本机 `127.0.0.1:8787` +- 文件存储、收件箱、已发送、发送队列和失败重试 +- STARTTLS(需要 `mail.wpyw.site` 的 PFX 证书) + +## 一、最简单的安装方式 + +1. 在 Windows Server 2022 上以管理员身份运行 `wpyw-mail-server-installer.exe`。 +2. 安装器出现中文问题时,按下面的规则填写: + + | 安装器问题 | 应填写什么 | 第一次安装建议 | + | --- | --- | --- | + | 程序安装目录 | 服务程序放在哪里 | 直接回车 | + | 邮件数据目录 | 邮件和账户数据长期保存在哪里 | 磁盘空间充足时填 `D:\WpywMailData`,没有 D 盘就直接回车 | + | 邮箱密码 | `wpy@wpyw.site` 的登录密码 | 输入至少 12 位强密码,输入时屏幕不会显示 | + | SMTP 外发中继服务器 | 用来把邮件发到公网的 SMTP 服务器 | 没有就直接回车,使用 MX 直投 | + | SMTP 外发中继端口 | 中继服务器端口 | 只有填写中继服务器后才出现,默认 587 | + | SMTP 中继账号 | 中继账号 | 只有填写中继服务器后才出现 | + | SMTP 中继密码 | 中继密码 | 只有填写中继账号后才出现 | + | PFX 证书路径 | `mail.wpyw.site` 的证书文件路径 | 没有就直接回车,安装器会生成临时证书 | + | PFX 证书密码 | PFX 文件密码 | 没密码就直接回车 | + +3. 安装器会创建 Windows 防火墙规则、注册开机启动任务并启动服务。 +4. 安装完成后,记录安装器显示的邮箱地址和数据目录。 + +## 二、第一次安装可以直接这样填 + +如果你暂时没有 SMTP 中继,也没有正式 PFX 证书: + +```text +程序安装目录:直接回车 +邮件数据目录:直接回车 +邮箱密码:输入你自己设置的至少 12 位密码 +SMTP 外发中继服务器:直接回车 +SMTP 外发中继端口:不会出现 +SMTP 中继账号:不会出现 +SMTP 中继密码:不会出现 +PFX 证书路径:直接回车 +PFX 证书密码:不会出现,或直接回车 +``` + +不填写 SMTP 中继时,服务端会使用 MX 直投:查询收件人域名的 MX 记录,再连接对方 25 端口发送。你已经确认服务器可以连接 QQ MX 的 25 端口。 + +## 三、Cloudflare DNS 保持这样 + +```text +mail.wpyw.site A DNS only +wpyw.site MX mail.wpyw.site DNS only +``` + +网站或未来 WinUI 客户端的 Web/API 路由可以通过 Cloudflare Tunnel 指向: + +```text +http://127.0.0.1:8787/ +``` + +SMTP 25 和 587 不要放到普通 HTTP Tunnel 路由里;它们应直接连接 `mail.wpyw.site`,并在服务器和云主机防火墙中开放。 + +## 四、当前版本的边界 + +当前服务端还没有 IMAP/POP3、DKIM 签名和完整反垃圾系统,因此第一版 WinUI 客户端通过 REST API 工作,Outlook/手机暂时不能直接用 IMAP 登录。MX 直投能工作不代表所有服务商都会接受邮件;正式公网使用还应补齐 DKIM、DMARC、反向 DNS 和退信处理。 + +## 五、手工启动和查看 + +安装器默认注册的任务名是 `WpywMail`。管理员 PowerShell 中可以查看: + +```powershell +Get-ScheduledTask -TaskName WpywMail +Get-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口' +``` + +服务数据在安装时填写的数据目录中;不要删除其中的 `users.json`、`messages.json` 和 `queue.json`。 diff --git a/server-native/SmtpServer.cs b/server-native/SmtpServer.cs new file mode 100644 index 0000000..99a8186 --- /dev/null +++ b/server-native/SmtpServer.cs @@ -0,0 +1,181 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WpywMail.Native; + +public sealed class SmtpServer +{ + private readonly AppConfig config; + private readonly FileStore store; + private readonly X509Certificate2? certificate; + private readonly bool advertiseStartTls; + + public SmtpServer(AppConfig config, FileStore store) + { + this.config = config; + this.store = store; + if (File.Exists(config.TlsCertificatePath)) certificate = new X509Certificate2(config.TlsCertificatePath, config.TlsCertificatePassword); + advertiseStartTls = certificate is not null && !certificate.Subject.Equals(certificate.Issuer, StringComparison.OrdinalIgnoreCase); + if (certificate is null) AppLog.Warn("[SMTP] 未找到 TLS 证书;正式公开使用前请配置 TlsCertificatePath。"); + else if (!advertiseStartTls) AppLog.Warn("[SMTP] 当前 TLS 证书是自签名证书,公网收信暂不发布 STARTTLS,避免远端因证书不受信而退信。"); + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + var inbound = new TcpListener(IPAddress.Any, config.SmtpPort); + var submission = new TcpListener(IPAddress.Any, config.SubmissionPort); + inbound.Start(); submission.Start(); + AppLog.Info($"[SMTP] 收信端口已监听:{config.SmtpPort}"); + AppLog.Info($"[SMTP] 客户端发信端口已监听:{config.SubmissionPort}"); + await Task.WhenAll(AcceptLoop(inbound, false, cancellationToken), AcceptLoop(submission, true, cancellationToken)); + } + + private async Task AcceptLoop(TcpListener listener, bool submission, CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(token); + _ = Task.Run(() => HandleClient(client, submission, token), token); + } + } + catch (OperationCanceledException) { } + finally { listener.Stop(); } + } + + private async Task HandleClient(TcpClient client, bool submission, CancellationToken token) + { + await using var rawStream = client.GetStream(); + Stream stream = rawStream; + var reader = NewReader(stream); + var writer = NewWriter(stream); + var tls = false; + string? authenticatedUser = null; + string? sender = null; + var recipients = new List(); + var remote = client.Client.RemoteEndPoint?.ToString() ?? "未知地址"; + try + { + AppLog.Info($"[SMTP] 收到连接:{remote},模式={(submission ? "客户端发信" : "公网收信")}"); + await Send(writer, $"220 {config.Hostname} ESMTP WpywMail"); + while (!token.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(token); + if (line is null) break; + var command = line.Trim(); + var upper = command.ToUpperInvariant(); + if (upper.StartsWith("EHLO") || upper.StartsWith("HELO")) + { + await SendMulti(writer, $"250-{config.Hostname}", "250-SIZE 26214400", "250-8BITMIME", "250-PIPELINING", advertiseStartTls && !tls ? "250-STARTTLS" : "250 AUTH LOGIN PLAIN"); + if (advertiseStartTls && !tls) await Send(writer, "250 AUTH LOGIN PLAIN"); + } + else if (upper == "STARTTLS") + { + if (certificate is null || (!submission && !advertiseStartTls)) { await Send(writer, "454 TLS unavailable"); continue; } + await Send(writer, "220 Ready to start TLS"); + var ssl = new SslStream(stream, false); + await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 }, token); + stream = ssl; reader = NewReader(stream); writer = NewWriter(stream); tls = true; authenticatedUser = null; + } + else if (upper.StartsWith("AUTH")) + { + if (submission && !tls) { await Send(writer, "538 Encryption required for authentication"); continue; } + authenticatedUser = await Authenticate(command, reader, writer, token); + await Send(writer, authenticatedUser is null ? "535 Authentication failed" : "235 Authentication successful"); + } + else if (upper == "RSET") + { + sender = null; recipients.Clear(); await Send(writer, "250 Reset"); + } + else if (upper.StartsWith("MAIL FROM:")) + { + sender = ExtractAddress(command); recipients.Clear(); + if (submission && authenticatedUser is null) { AppLog.Warn($"[SMTP] {remote} 未认证就尝试发信:{sender}"); await Send(writer, "530 Authentication required"); } + else if (submission && !sender.Equals(authenticatedUser, StringComparison.OrdinalIgnoreCase)) { AppLog.Warn($"[SMTP] {remote} 发件人不匹配:{sender}"); await Send(writer, "553 Sender must match authenticated mailbox"); } + else { AppLog.Info($"[SMTP] {remote} MAIL FROM:{sender}"); await Send(writer, "250 Sender accepted"); } + } + else if (upper.StartsWith("RCPT TO:")) + { + var recipient = ExtractAddress(command); + if (sender is null) { AppLog.Warn($"[SMTP] {remote} 未先发送 MAIL FROM 就发送 RCPT TO:{recipient}"); await Send(writer, "503 Need MAIL FROM first"); } + else if (!submission && !store.IsLocalAddress(recipient)) { AppLog.Warn($"[SMTP] {remote} 非本地收件人被拒绝:{recipient}"); await Send(writer, "550 Relay denied"); } + else { recipients.Add(recipient); AppLog.Info($"[SMTP] {remote} RCPT TO:{recipient}"); await Send(writer, "250 Recipient accepted"); } + } + else if (upper == "DATA") + { + if (sender is null || recipients.Count == 0) { await Send(writer, "503 Need sender and recipient"); continue; } + await Send(writer, "354 End data with ."); + var raw = await ReadData(reader, token); + var parsed = Mime.Parse(raw); + if (submission) + { + store.QueueOutbound(authenticatedUser!, recipients.ToArray(), parsed.Subject, parsed.Text, raw); + } + else + { + foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (store.FindUser(recipient) is not null) + store.SaveMessage(new MailMessage { OwnerEmail = recipient.ToLowerInvariant(), Folder = "inbox", From = parsed.From.Length > 0 ? parsed.From : sender, To = recipient, Subject = parsed.Subject, Text = parsed.Text, MessageId = parsed.MessageId, Date = DateTimeOffset.UtcNow, DeliveryStatus = "received" }, raw); + } + AppLog.Info($"[SMTP] 已接收邮件:{sender} → {string.Join(", ", recipients)},主题:{parsed.Subject}"); + } + await Send(writer, "250 Message queued"); sender = null; recipients.Clear(); + } + else if (upper == "NOOP") await Send(writer, "250 OK"); + else if (upper == "QUIT") { await Send(writer, "221 Bye"); break; } + else await Send(writer, "502 Command not implemented"); + } + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) { } + catch (Exception ex) { AppLog.Error($"[SMTP] {remote} 会话错误:{ex.Message}"); } + finally { client.Dispose(); } + } + + private async Task Authenticate(string command, StreamReader reader, StreamWriter writer, CancellationToken token) + { + var parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + string? email = null; string? password = null; + if (parts.Length >= 3 && parts[1].Equals("PLAIN", StringComparison.OrdinalIgnoreCase)) + { + var bytes = Convert.FromBase64String(parts[2]); + var values = Encoding.UTF8.GetString(bytes).Split('\0'); + email = values.Length > 1 ? values[1] : null; password = values.Length > 2 ? values[2] : null; + } + else if (parts.Length >= 2 && parts[1].Equals("LOGIN", StringComparison.OrdinalIgnoreCase)) + { + await Send(writer, "334 VXNlcm5hbWU6"); email = Encoding.UTF8.GetString(Convert.FromBase64String(await reader.ReadLineAsync(token) ?? "")); + await Send(writer, "334 UGFzc3dvcmQ6"); password = Encoding.UTF8.GetString(Convert.FromBase64String(await reader.ReadLineAsync(token) ?? "")); + } + else { await Send(writer, "504 Authentication mechanism not supported"); return null; } + return store.Authenticate(email ?? "", password ?? "")?.Email; + } + + private static async Task ReadData(StreamReader reader, CancellationToken token) + { + var lines = new List(); + while (true) + { + var line = await reader.ReadLineAsync(token) ?? "."; + if (line == ".") break; + lines.Add(line.StartsWith("..") ? line[1..] : line); + if (lines.Sum(x => x.Length) > 25 * 1024 * 1024) throw new InvalidOperationException("Message too large"); + } + return Encoding.UTF8.GetBytes(string.Join("\r\n", lines) + "\r\n"); + } + + private static string ExtractAddress(string command) + { + var start = command.IndexOf('<'); var end = command.IndexOf('>', start + 1); + return start >= 0 && end > start ? command[(start + 1)..end].Trim().ToLowerInvariant() : command[(command.IndexOf(':') + 1)..].Trim().ToLowerInvariant(); + } + + private static StreamReader NewReader(Stream stream) => new(stream, Encoding.UTF8, false, 8192, true); + private static StreamWriter NewWriter(Stream stream) => new(stream, new UTF8Encoding(false), 8192, true) { AutoFlush = true, NewLine = "\r\n" }; + private static Task Send(StreamWriter writer, string value) => writer.WriteLineAsync(value); + private static async Task SendMulti(StreamWriter writer, params string[] lines) { foreach (var line in lines) await Send(writer, line); } +} diff --git a/server-native/WpywMail.Native.csproj b/server-native/WpywMail.Native.csproj new file mode 100644 index 0000000..d691bde --- /dev/null +++ b/server-native/WpywMail.Native.csproj @@ -0,0 +1,10 @@ + + + Exe + net8.0 + win-x64 + enable + enable + true + + diff --git a/server-native/appsettings.example.json b/server-native/appsettings.example.json new file mode 100644 index 0000000..46d7dbe --- /dev/null +++ b/server-native/appsettings.example.json @@ -0,0 +1,28 @@ +{ + "Domain": "wpyw.site", + "Hostname": "mail.wpyw.site", + "HttpPrefix": "http://127.0.0.1:8787/", + "SmtpPort": 25, + "SubmissionPort": 587, + "DataDirectory": "H:\\MailData", + "AdminEmail": "admin@wpyw.site", + "AdminPassword": "replace-with-a-long-password", + "TlsCertificatePath": "H:\\MailData\\certs\\mail.wpyw.site.pfx", + "TlsCertificatePassword": "replace-with-certificate-password", + "DeliveryMode": "direct", + "DirectDelivery": { + "ConnectionTimeoutSeconds": 30, + "CommandTimeoutSeconds": 30, + "DnsTimeoutSeconds": 5, + "OpportunisticStartTls": true, + "RequireStartTls": false, + "DnsServer": "" + }, + "Relay": { + "Host": "", + "Port": 587, + "User": "", + "Password": "", + "EnableSsl": true + } +} diff --git a/server-native/install-firewall.ps1 b/server-native/install-firewall.ps1 new file mode 100644 index 0000000..8a5a833 --- /dev/null +++ b/server-native/install-firewall.ps1 @@ -0,0 +1,3 @@ +New-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口' -Direction Inbound -Protocol TCP -LocalPort 25,587 -Action Allow +# API 8787 默认只监听本机,再通过 Cloudflare Tunnel 暴露 Web/API。 +# 如需让独立客户端直连,请先评估安全策略后再单独开放 8787。 diff --git a/server-native/install-task.ps1 b/server-native/install-task.ps1 new file mode 100644 index 0000000..8a4f379 --- /dev/null +++ b/server-native/install-task.ps1 @@ -0,0 +1,17 @@ +param( + [string]$InstallDirectory = 'C:\WpywMail', + [string]$TaskName = 'WpywMail' +) + +$exe = Join-Path $InstallDirectory 'WpywMail.Native.exe' +if (-not (Test-Path -LiteralPath $exe)) { + throw "找不到 $exe,请先把 self-contained publish 目录复制到服务器。" +} + +$action = New-ScheduledTaskAction -Execute $exe -WorkingDirectory $InstallDirectory +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force +Start-ScheduledTask -TaskName $TaskName +Write-Host "已注册并启动任务:$TaskName" diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..b632421 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,21 @@ +MAIL_DOMAIN=wpyw.site +MAIL_HOSTNAME=mail.wpyw.site +MAIL_USER=admin@wpyw.site +MAIL_PASSWORD=replace-with-a-long-password +WEB_PORT=8787 +SMTP_PORT=25 +SUBMISSION_PORT=587 +MAIL_DATA_DIR=H:\\MailData +CLIENT_ORIGIN=https://webmail.wpyw.site + +# Optional outbound SMTP relay. If omitted, the server delivers directly to recipient MX hosts. +# SMTP_RELAY_HOST=smtp.example.com +# SMTP_RELAY_PORT=587 +# SMTP_RELAY_USER=username +# SMTP_RELAY_PASSWORD=password +# SMTP_RELAY_SECURE=false +# SMTP_DIRECT_TLS_REJECT_UNAUTHORIZED=true + +# Optional TLS for SMTP STARTTLS. Use a certificate for mail.wpyw.site. +# SMTP_TLS_KEY=H:\\MailData\\certs\\privkey.pem +# SMTP_TLS_CERT=H:\\MailData\\certs\\fullchain.pem diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..7b264ed --- /dev/null +++ b/server/README.md @@ -0,0 +1,92 @@ +# wpyw.mail server + +这是独立的邮箱服务端,不包含任何 Webmail 客户端代码。 + +当前服务端提供: + +- SMTP 25:接收发往 `@wpyw.site` 的邮件 +- SMTP Submission 587:登录认证后发信 +- REST API:供未来独立客户端使用 +- 本地 JSON 邮件存储和附件落盘 +- 可选外部 SMTP 中继 + +## 启动 + +在 `server/` 目录执行: + +```powershell +npm install +Copy-Item .env.example .env +notepad .env +npm start +``` + +必须设置 `MAIL_PASSWORD`,服务端不会使用默认密码启动。 + +默认监听: + +```text +REST API 8787 +SMTP 25 +SMTP Submission 587 +``` + +测试时可以临时改成非特权端口: + +```powershell +$env:WEB_PORT='8787' +$env:SMTP_PORT='2525' +$env:SUBMISSION_PORT='2587' +npm start +``` + +## API + +登录: + +```http +POST /api/login +Content-Type: application/json + +{"email":"admin@wpyw.site","password":"你的密码"} +``` + +之后把返回的 token 放入请求头: + +```http +Authorization: Bearer +``` + +主要接口: + +```text +GET /api/health +GET /api/config +GET /api/me +GET /api/messages?folder=inbox +GET /api/messages/:id +POST /api/send +POST /api/logout +``` + +发信请求示例: + +```json +{ + "to": "someone@example.com", + "subject": "测试邮件", + "text": "邮件正文" +} +``` + +## Cloudflare 和端口 + +`mail.wpyw.site` 应保持 DNS only,MX 指向 `mail.wpyw.site`。网站或未来的 Webmail 客户端可以继续通过 Cloudflare Tunnel,但 SMTP 25/587 直接连接服务器公网 IP。 + +## 当前边界 + +这是第一版服务端:已经具备收信、发信和客户端 API,但还没有实现 IMAP/POP3、多用户、DKIM 签名、DMARC 报告、反垃圾、配额和管理后台。未来客户端应通过 REST API 或后续增加的 IMAP 服务访问邮箱。 + +不要把它配置成 Open Relay。正式公网使用前,应配置 `mail.wpyw.site` 的 TLS 证书、PTR 反向解析、SPF、DKIM、DMARC,并优先考虑 SMTP 中继以提高投递率。 + +当前没有监听 993/995,因此暂时不要把它当作 Outlook/手机的 IMAP/POP3 服务器使用;第一版客户端应通过 REST API 连接。 diff --git a/server/data/.gitkeep b/server/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..160f6d8 --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,102 @@ +import 'dotenv/config' +import crypto from 'node:crypto' +import express from 'express' +import { initStore, listMessages, getMessage, markRead, mailboxStats, saveMessage } from './store.mjs' +import { mailConfig, sendMail, startMailServers } from './mail.mjs' + +const app = express() +const port = Number(process.env.WEB_PORT || 8787) +const sessions = new Map() +const account = (process.env.MAIL_USER || `admin@${mailConfig.domain}`).toLowerCase() +const password = process.env.MAIL_PASSWORD + +if (!password) { + throw new Error('MAIL_PASSWORD is required. Copy server/.env.example to server/.env and set it before starting.') +} + +app.use(express.json({ limit: '2mb' })) +app.use((req, res, next) => { + const allowedOrigin = process.env.CLIENT_ORIGIN || '*' + res.setHeader('Access-Control-Allow-Origin', allowedOrigin) + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS') + if (req.method === 'OPTIONS') return res.sendStatus(204) + next() +}) + +function auth(req, res, next) { + const token = req.headers.authorization?.replace(/^Bearer\s+/i, '') + if (!token || !sessions.has(token)) return res.status(401).json({ error: '登录已失效' }) + req.user = sessions.get(token) + next() +} + +app.get('/api/health', (_req, res) => { + res.json({ ok: true, service: 'wpyw.mail', hostname: mailConfig.hostname, domain: mailConfig.domain }) +}) + +app.get('/api/config', auth, (_req, res) => { + res.json({ + domain: mailConfig.domain, + hostname: mailConfig.hostname, + account, + protocols: { + smtp: Number(process.env.SMTP_PORT || 25), + submission: Number(process.env.SUBMISSION_PORT || 587), + api: port, + }, + }) +}) + +app.post('/api/login', (req, res) => { + const email = String(req.body?.email || '').toLowerCase().trim() + const pass = String(req.body?.password || '') + if (email !== account || pass !== password) return res.status(401).json({ error: '邮箱或密码不正确' }) + const token = crypto.randomBytes(32).toString('hex') + sessions.set(token, { email: account, createdAt: Date.now() }) + res.json({ token, user: { email: account, domain: mailConfig.domain } }) +}) + +app.post('/api/logout', auth, (req, res) => { + const token = req.headers.authorization.replace(/^Bearer\s+/i, '') + sessions.delete(token) + res.json({ ok: true }) +}) + +app.get('/api/me', auth, async (req, res) => { + res.json({ user: req.user, stats: await mailboxStats() }) +}) + +app.get('/api/messages', auth, async (req, res) => { + const folder = ['inbox', 'sent', 'drafts', 'archive'].includes(req.query.folder) ? req.query.folder : 'inbox' + res.json({ messages: await listMessages(folder, String(req.query.q || '')) }) +}) + +app.get('/api/messages/:id', auth, async (req, res) => { + const message = await getMessage(req.params.id) + if (!message) return res.status(404).json({ error: '邮件不存在' }) + await markRead(req.params.id) + res.json({ message: { ...message, unread: false } }) +}) + +app.post('/api/send', auth, async (req, res) => { + const { to, subject, text, html } = req.body || {} + if (!to || !subject || !text) return res.status(400).json({ error: '收件人、主题和正文不能为空' }) + try { + const result = await sendMail({ to, subject, text, html }) + await saveMessage({ folder: 'sent', from: account, to, subject, text, html, unread: false }) + res.json({ ok: true, result }) + } catch (error) { + console.error('[send]', error) + res.status(502).json({ error: `发信失败:${error.message}` }) + } +}) + +app.use('/api', (_req, res) => res.status(404).json({ error: 'API endpoint not found' })) + +await initStore() +app.listen(port, '0.0.0.0', () => console.log(`[web] wpyw.mail listening on ${port}`)) +startMailServers() + +process.on('SIGINT', () => process.exit(0)) +process.on('SIGTERM', () => process.exit(0)) diff --git a/server/mail.mjs b/server/mail.mjs new file mode 100644 index 0000000..54da0dd --- /dev/null +++ b/server/mail.mjs @@ -0,0 +1,197 @@ +import fs from 'node:fs' +import dns from 'node:dns/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { simpleParser } from 'mailparser' +import nodemailer from 'nodemailer' +import { SMTPServer } from 'smtp-server' +import { saveMessage } from './store.mjs' + +const domain = (process.env.MAIL_DOMAIN || 'wpyw.site').toLowerCase() +const account = (process.env.MAIL_USER || `admin@${domain}`).toLowerCase() +const password = process.env.MAIL_PASSWORD +const hostname = process.env.MAIL_HOSTNAME || `mail.${domain}` +const serverDir = path.dirname(fileURLToPath(import.meta.url)) +const dataDir = process.env.MAIL_DATA_DIR || path.join(serverDir, 'data') + +if (!password) { + throw new Error('MAIL_PASSWORD is required. Copy server/.env.example to server/.env and set it before starting.') +} + +function addressOf(value) { + if (!value) return '' + if (typeof value === 'string') return value.toLowerCase() + if (Array.isArray(value)) return addressOf(value[0]) + if (value.value?.[0]?.address) return value.value[0].address.toLowerCase() + if (value.address) return value.address.toLowerCase() + return '' +} + +function addressList(value) { + if (!value) return [] + if (typeof value === 'string') return [value] + if (Array.isArray(value)) return value.flatMap(addressList) + if (value.value) return value.value.map((item) => item.address || item.name).filter(Boolean) + return value.address ? [value.address] : [] +} + +function isLocalAddress(address) { + return address.toLowerCase().endsWith(`@${domain}`) +} + +async function storeIncoming(parsed, envelopeRecipients = []) { + const attachments = [] + for (const attachment of parsed.attachments || []) { + const filename = `${Date.now()}-${attachment.filename || 'attachment.bin'}`.replace(/[^a-zA-Z0-9._-]/g, '_') + const attachmentDir = path.join(dataDir, 'attachments') + await fs.promises.mkdir(attachmentDir, { recursive: true }) + await fs.promises.writeFile(path.join(attachmentDir, filename), attachment.content) + attachments.push({ filename: attachment.filename || filename, storedAs: filename, contentType: attachment.contentType }) + } + + const to = envelopeRecipients.length ? envelopeRecipients : addressList(parsed.to) + await saveMessage({ + folder: 'inbox', + from: parsed.from?.text || addressOf(parsed.from), + to: to.join(', '), + subject: parsed.subject || '(无主题)', + text: parsed.text || '', + html: typeof parsed.html === 'string' ? parsed.html : '', + date: parsed.date?.toISOString() || new Date().toISOString(), + unread: true, + attachments, + messageId: parsed.messageId || '', + }) +} + +async function parseAndRoute(stream, session, submission) { + const parsed = await simpleParser(stream) + const envelopeRecipients = session.envelope.rcptTo.map((item) => item.address) + + if (!submission) { + await storeIncoming(parsed, envelopeRecipients) + return + } + + const recipients = envelopeRecipients.length ? envelopeRecipients : addressList(parsed.to) + if (!recipients.length) throw new Error('No recipients in submitted message') + await sendMail({ + to: recipients, + subject: parsed.subject || '(无主题)', + text: parsed.text || '', + html: typeof parsed.html === 'string' ? parsed.html : undefined, + }) + await saveMessage({ + folder: 'sent', + from: account, + to: recipients.join(', '), + subject: parsed.subject || '(无主题)', + text: parsed.text || '', + html: typeof parsed.html === 'string' ? parsed.html : '', + date: parsed.date?.toISOString() || new Date().toISOString(), + unread: false, + messageId: parsed.messageId || '', + }) +} + +function tlsOptions() { + const keyPath = process.env.SMTP_TLS_KEY + const certPath = process.env.SMTP_TLS_CERT + if (!keyPath || !certPath || !fs.existsSync(keyPath) || !fs.existsSync(certPath)) return {} + return { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) } +} + +function makeSmtpServer({ submission = false } = {}) { + const options = tlsOptions() + return new SMTPServer({ + name: hostname, + secure: false, + ...options, + authOptional: !submission, + allowInsecureAuth: false, + onAuth(auth, _session, callback) { + if (auth.username?.toLowerCase() === account && auth.password === password) { + return callback(null, { user: account }) + } + const error = new Error('Invalid username or password') + error.responseCode = 535 + return callback(error) + }, + onMailFrom(address, _session, callback) { + if (submission && address.address.toLowerCase() !== account) { + const error = new Error('Sender address must match the authenticated mailbox') + error.responseCode = 553 + return callback(error) + } + callback() + }, + onRcptTo(address, _session, callback) { + const recipient = address.address.toLowerCase() + if (!submission && !isLocalAddress(recipient)) { + const error = new Error('Relay denied') + error.responseCode = 550 + return callback(error) + } + callback() + }, + onData(stream, session, callback) { + parseAndRoute(stream, session, submission) + .then(() => callback()) + .catch((error) => callback(error)) + }, + }) +} + +export function startMailServers() { + const smtpPort = Number(process.env.SMTP_PORT || 25) + const submissionPort = Number(process.env.SUBMISSION_PORT || 587) + const inbound = makeSmtpServer({ submission: false }) + const submission = makeSmtpServer({ submission: true }) + + inbound.listen(smtpPort, '0.0.0.0', () => console.log(`[smtp] inbound listening on ${smtpPort}`)) + submission.listen(submissionPort, '0.0.0.0', () => console.log(`[smtp] submission listening on ${submissionPort}`)) + inbound.on('error', (error) => console.error('[smtp] inbound error', error.message)) + submission.on('error', (error) => console.error('[smtp] submission error', error.message)) + return { inbound, submission } +} + +async function directTransport(recipient) { + const recipientDomain = recipient.split('@').pop() + const mxRecords = await dns.resolveMx(recipientDomain) + if (!mxRecords.length) throw new Error(`No MX record found for ${recipientDomain}`) + mxRecords.sort((a, b) => a.priority - b.priority) + return nodemailer.createTransport({ + host: mxRecords[0].exchange, + port: 25, + secure: false, + name: hostname, + tls: { + rejectUnauthorized: process.env.SMTP_DIRECT_TLS_REJECT_UNAUTHORIZED !== 'false', + }, + }) +} + +function relayTransport() { + const host = process.env.SMTP_RELAY_HOST + if (!host) return null + return nodemailer.createTransport({ + host, + port: Number(process.env.SMTP_RELAY_PORT || 587), + secure: process.env.SMTP_RELAY_SECURE === 'true', + auth: process.env.SMTP_RELAY_USER + ? { user: process.env.SMTP_RELAY_USER, pass: process.env.SMTP_RELAY_PASSWORD } + : undefined, + }) +} + +export async function sendMail({ to, subject, text, html }) { + const sender = account + const recipients = Array.isArray(to) ? to : String(to).split(',').map((item) => item.trim()).filter(Boolean) + if (!recipients.length) throw new Error('Recipient is required') + const transport = relayTransport() || await directTransport(recipients[0]) + const info = await transport.sendMail({ from: sender, to: recipients.join(', '), subject, text, html: html || undefined }) + transport.close?.() + return { messageId: info.messageId, accepted: info.accepted } +} + +export const mailConfig = { domain, account, hostname } diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..bfc56d1 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1306 @@ +{ + "name": "wpyw-mail-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wpyw-mail-server", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^13.0.3", + "dotenv": "^16.4.7", + "express": "^5.1.0", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", + "mailparser": "^3.7.2", + "nodemailer": "^10.0.1", + "smtp-server": "^3.15.0" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.16", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz", + "integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.3", + "libqp": "2.1.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge-ts": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-japanese": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz", + "integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/html-to-text": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz", + "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^8.0.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipv6-normalize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz", + "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz", + "integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.3.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/mailparser": { + "version": "3.9.23", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.23.tgz", + "integrity": "sha512-5jpsKltHt9oudhMu6mXxiwWMWV/CILfly+m9xhKiKW6cdmfOVvcAZ/NCuChV6QlADJqx1e5E2hwDAkP7lTP+Lg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.16", + "encoding-japanese": "2.3.0", + "he": "1.2.0", + "html-to-text": "10.0.1", + "iconv-lite": "0.7.3", + "libmime": "5.4.3", + "linkify-it": "5.0.2", + "nodemailer": "10.0.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/nodemailer": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz", + "integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smtp-server": { + "version": "3.19.9", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.9.tgz", + "integrity": "sha512-ljndWZ9km1qI/1fUj+VTQwG0ts5NRX+Lb95NPttoUZRvaOMUSPKdph9YoHpxpQfOomafzWvNHJcZkFL2v6OXYw==", + "license": "MIT-0", + "dependencies": { + "ipv6-normalize": "1.0.1", + "nodemailer": "10.0.1", + "punycode.js": "2.3.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..8dded38 --- /dev/null +++ b/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "wpyw-mail-server", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "start": "node index.mjs", + "dev": "node --watch index.mjs" + }, + "dependencies": { + "better-sqlite3": "^13.0.3", + "dotenv": "^16.4.7", + "express": "^5.1.0", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", + "mailparser": "^3.7.2", + "nodemailer": "^10.0.1", + "smtp-server": "^3.15.0" + } +} diff --git a/server/store.mjs b/server/store.mjs new file mode 100644 index 0000000..ae430e8 --- /dev/null +++ b/server/store.mjs @@ -0,0 +1,143 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' + +const serverDir = path.dirname(fileURLToPath(import.meta.url)) +const dataDir = process.env.MAIL_DATA_DIR || path.join(serverDir, 'data') +const messagesFile = path.join(dataDir, 'messages.json') + +let writeQueue = Promise.resolve() + +async function ensureStore() { + await fs.mkdir(dataDir, { recursive: true }) + try { + await fs.access(messagesFile) + } catch { + await fs.writeFile(messagesFile, '[]', 'utf8') + } +} + +async function readMessages() { + await ensureStore() + const raw = await fs.readFile(messagesFile, 'utf8') + try { + return JSON.parse(raw) + } catch { + return [] + } +} + +function queueWrite(messages) { + writeQueue = writeQueue.then(async () => { + const tmp = `${messagesFile}.${process.pid}.tmp` + await fs.writeFile(tmp, JSON.stringify(messages, null, 2), 'utf8') + await fs.rename(tmp, messagesFile) + }) + return writeQueue +} + +export async function initStore() { + await ensureStore() + if (process.env.SEED_DEMO === 'true') { + const messages = await readMessages() + if (!messages.length) { + const now = Date.now() + await queueWrite([ + makeMessage({ + folder: 'inbox', + from: 'Cloudflare ', + to: process.env.MAIL_USER || 'admin@wpyw.site', + subject: '你的 wpyw.site 邮件服务已准备就绪', + text: '这是本地演示邮件。正式使用时,来自公网的 SMTP 邮件会自动进入这里。', + date: new Date(now - 1000 * 60 * 12).toISOString(), + unread: true, + }), + makeMessage({ + folder: 'inbox', + from: '系统管理员 ', + to: process.env.MAIL_USER || 'admin@wpyw.site', + subject: '欢迎使用 wpyw.mail', + text: '你可以从左侧开始管理收件箱,或点击右上角写信。', + date: new Date(now - 1000 * 60 * 60 * 4).toISOString(), + unread: false, + }), + makeMessage({ + folder: 'sent', + from: process.env.MAIL_USER || 'admin@wpyw.site', + to: 'hello@example.com', + subject: '测试发信', + text: 'SMTP 提交链路测试。', + date: new Date(now - 1000 * 60 * 60 * 22).toISOString(), + unread: false, + }), + ]) + } + } +} + +export function makeMessage(input) { + const text = input.text || '' + return { + id: input.id || crypto.randomUUID(), + folder: input.folder || 'inbox', + from: input.from || '', + to: input.to || '', + subject: input.subject || '(无主题)', + text, + html: input.html || '', + preview: input.preview || text.replace(/\s+/g, ' ').trim().slice(0, 140), + date: input.date || new Date().toISOString(), + unread: input.unread ?? true, + attachments: input.attachments || [], + messageId: input.messageId || '', + } +} + +export async function listMessages(folder = 'inbox', query = '') { + const messages = await readMessages() + const normalized = query.trim().toLowerCase() + return messages + .filter((message) => message.folder === folder) + .filter((message) => { + if (!normalized) return true + return [message.from, message.to, message.subject, message.text] + .join(' ') + .toLowerCase() + .includes(normalized) + }) + .sort((a, b) => new Date(b.date) - new Date(a.date)) + .map(({ text, html, ...summary }) => summary) +} + +export async function getMessage(id) { + const messages = await readMessages() + return messages.find((message) => message.id === id) || null +} + +export async function saveMessage(input) { + const messages = await readMessages() + const message = makeMessage(input) + messages.push(message) + await queueWrite(messages) + return message +} + +export async function markRead(id) { + const messages = await readMessages() + const index = messages.findIndex((message) => message.id === id) + if (index < 0) return null + messages[index].unread = false + await queueWrite(messages) + return messages[index] +} + +export async function mailboxStats() { + const messages = await readMessages() + return { + inbox: messages.filter((message) => message.folder === 'inbox').length, + unread: messages.filter((message) => message.folder === 'inbox' && message.unread).length, + sent: messages.filter((message) => message.folder === 'sent').length, + drafts: messages.filter((message) => message.folder === 'drafts').length, + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..97b825c --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Archive, + ArrowLeft, + ChevronDown, + ChevronRight, + CircleUserRound, + Clock3, + Inbox, + LogOut, + Mail, + Menu, + MoreHorizontal, + Paperclip, + PenLine, + Plus, + Search, + Send, + Settings, + ShieldCheck, + Star, + Trash2, + X, +} from 'lucide-react' + +const API = '/api' + +async function request(path, options = {}, token) { + const response = await fetch(`${API}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.error || '请求失败') + return data +} + +const folders = [ + { id: 'inbox', label: '收件箱', icon: Inbox }, + { id: 'sent', label: '已发送', icon: Send }, + { id: 'drafts', label: '草稿', icon: PenLine }, + { id: 'archive', label: '归档', icon: Archive }, +] + +function formatDate(value, detail = false) { + const date = new Date(value) + if (detail) return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date) + const now = new Date() + if (date.toDateString() === now.toDateString()) return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date) + return new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(date) +} + +function senderName(value = '') { + const match = value.match(/^(.+?)\s*<[^>]+>$/) + if (match) return match[1].replace(/^"|"$/g, '') + return value.split('@')[0] || value +} + +function Login({ onLogin }) { + const [email, setEmail] = useState('admin@wpyw.site') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function submit(event) { + event.preventDefault() + setLoading(true) + setError('') + try { + const data = await request('/login', { method: 'POST', body: JSON.stringify({ email, password }) }) + onLogin(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+
wpyw.mail
+
+

PRIVATE MAIL SERVER

+

你的邮件,留在自己的服务器上。

+

通过安全连接访问 wpyw.site 的收发件箱。

+
+
+ + + {error &&
{error}
} + +
+
邮件服务运行在你的 Windows 服务器上
+
+
+
+ ) +} + +function Sidebar({ folder, setFolder, stats, onCompose, onLogout, mobileOpen, onClose }) { + return ( + + ) +} + +function MessageRow({ message, selected, onSelect }) { + return ( + + ) +} + +function Reader({ message, onBack, onCompose }) { + if (!message) return

选择一封邮件

从左侧收件箱中选择邮件,在这里查看内容。

+ return ( +
+
+
+
{senderName(message.from).slice(0, 1).toUpperCase()}

{message.subject}

{senderName(message.from)}<{message.from.match(/<([^>]+)>/)?.[1] || message.from}>
发送给 {message.to || '我'} · {formatDate(message.date, true)}
+
{message.html ?
: (message.text || '').split('\n').map((line, index) =>

{line || '\u00a0'}

)}
+ {message.attachments?.length > 0 &&

附件

{message.attachments.map((item) =>
{item.filename}
)}
} +
+
+
+ ) +} + +function Compose({ initial = {}, onClose, onSent, token }) { + const [to, setTo] = useState(initial.to || '') + const [subject, setSubject] = useState(initial.subject || '') + const [text, setText] = useState('') + const [sending, setSending] = useState(false) + const [error, setError] = useState('') + + async function send(event) { + event.preventDefault() + setSending(true) + setError('') + try { + await request('/send', { method: 'POST', body: JSON.stringify({ to, subject, text }) }, token) + onSent() + } catch (err) { + setError(err.message) + } finally { + setSending(false) + } + } + + return
新邮件从 admin@wpyw.site 发送