Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
MAIL_DOMAIN=wpyw.site
|
||||||
|
MAIL_HOSTNAME=mail.wpyw.site
|
||||||
|
MAIL_USER=[email protected]
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
[email protected]
|
||||||
|
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。
|
||||||
@@ -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<LoginResponse> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync($"{BaseUrl}/login", new { email, password }, json, cancellationToken);
|
||||||
|
return await ReadOrThrow<LoginResponse>(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MeResponse> GetMeAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await SendAsync<MeResponse>(HttpMethod.Get, "/me", cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
public async Task<ConfigResponse> GetConfigAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await SendAsync<ConfigResponse>(HttpMethod.Get, "/config", cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MailSummary>> 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<MessageListResponse>(HttpMethod.Get, url, cancellationToken: cancellationToken);
|
||||||
|
return result.Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MailMessage> GetMessageAsync(string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await SendAsync<MessageDetailResponse>(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<object>(HttpMethod.Post, "/send", new { to, subject, text }, cancellationToken);
|
||||||
|
|
||||||
|
public async Task ChangePasswordAsync(string password, CancellationToken cancellationToken = default) =>
|
||||||
|
await SendAsync<object>(HttpMethod.Post, "/account/password", new { password }, cancellationToken);
|
||||||
|
|
||||||
|
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try { await SendAsync<object>(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<T> SendAsync<T>(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<T>(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ReadOrThrow<T>(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<T>(json, cancellationToken);
|
||||||
|
return result ?? throw new InvalidOperationException("服务端返回了空响应");
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var error = JsonSerializer.Deserialize<Dictionary<string, string>>(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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<Application
|
||||||
|
x:Class="WpywMail.Client.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:WpywMail.Client"
|
||||||
|
RequestedTheme="Dark">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<local:UnreadBackgroundConverter x:Key="UnreadBackgroundConverter" />
|
||||||
|
<local:UnreadWeightConverter x:Key="UnreadWeightConverter" />
|
||||||
|
<SolidColorBrush x:Key="PageBackgroundBrush" Color="#1E1E1E" />
|
||||||
|
<SolidColorBrush x:Key="RailBackgroundBrush" Color="#151515" />
|
||||||
|
<SolidColorBrush x:Key="SurfaceBrush" Color="#252525" />
|
||||||
|
<SolidColorBrush x:Key="SurfaceElevatedBrush" Color="#2D2D2D" />
|
||||||
|
<SolidColorBrush x:Key="StrokeBrush" Color="#3A3A3A" />
|
||||||
|
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F2F2F2" />
|
||||||
|
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#B7B7B7" />
|
||||||
|
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#848484" />
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="#5BA7FF" />
|
||||||
|
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#263D55" />
|
||||||
|
<SolidColorBrush x:Key="DangerBrush" Color="#E77878" />
|
||||||
|
|
||||||
|
<Style x:Key="QuietButtonStyle" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="10,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="RailButtonStyle" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Width" Value="44" />
|
||||||
|
<Setter Property="Height" Value="44" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="FolderButtonStyle" TargetType="Button">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="#101010" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="16,9" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="TextBoxStyle" TargetType="TextBox">
|
||||||
|
<Setter Property="Background" Value="#292929" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource StrokeBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="11,8" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PasswordBoxStyle" TargetType="PasswordBox">
|
||||||
|
<Setter Property="Background" Value="#292929" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource StrokeBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="11,8" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<Window
|
||||||
|
x:Class="WpywMail.Client.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="Wpyw Mail">
|
||||||
|
<Grid Background="{StaticResource PageBackgroundBrush}">
|
||||||
|
<Grid x:Name="LoginView" Visibility="Visible">
|
||||||
|
<Grid.Background>
|
||||||
|
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||||
|
<GradientStop Color="#151515" Offset="0" />
|
||||||
|
<GradientStop Color="#20262D" Offset="1" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Grid.Background>
|
||||||
|
<Border Width="420" Padding="34" Background="#252525" BorderBrush="#3A3A3A" BorderThickness="1" CornerRadius="12" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Spacing="18">
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<TextBlock Text="Wpyw Mail" FontSize="28" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||||
|
<TextBlock Text="登录你的邮箱" FontSize="14" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="服务端地址" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox x:Name="ApiUrlBox" Text="http://127.0.0.1:8787/api" Style="{StaticResource TextBoxStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="邮箱" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox x:Name="EmailBox" Text="[email protected]" Style="{StaticResource TextBoxStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="密码" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<PasswordBox x:Name="PasswordBox" Style="{StaticResource PasswordBoxStyle}" KeyDown="PasswordBox_KeyDown" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button x:Name="LoginButton" Content="登录" Click="LoginButton_Click" Style="{StaticResource PrimaryButtonStyle}" HorizontalAlignment="Stretch" />
|
||||||
|
<TextBlock x:Name="LoginStatus" TextWrapping="Wrap" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid x:Name="ShellView" Visibility="Collapsed">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="52" />
|
||||||
|
<RowDefinition Height="44" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Grid Grid.Row="0" Background="#191919" Padding="12,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
|
<Border Width="28" Height="28" CornerRadius="7" Background="{StaticResource AccentBrush}">
|
||||||
|
<TextBlock Text="W" Foreground="#101010" FontWeight="Bold" FontSize="16" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Wpyw Mail" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox Grid.Column="1" x:Name="SearchBox" Width="360" HorizontalAlignment="Center" PlaceholderText="搜索邮件" Style="{StaticResource TextBoxStyle}" TextChanged="SearchBox_TextChanged" />
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="AccountText" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<Button Content="设置" Click="SettingsButton_Click" Style="{StaticResource QuietButtonStyle}" />
|
||||||
|
<Button Content="退出" Click="LogoutButton_Click" Style="{StaticResource QuietButtonStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Background="#222222" Padding="12,0">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<Button Content="写信" Click="ComposeButton_Click" Style="{StaticResource PrimaryButtonStyle}" />
|
||||||
|
<Button Content="刷新" Click="RefreshButton_Click" Style="{StaticResource QuietButtonStyle}" />
|
||||||
|
<TextBlock x:Name="ConnectionText" VerticalAlignment="Center" Margin="8,0,0,0" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="58" />
|
||||||
|
<ColumnDefinition Width="220" />
|
||||||
|
<ColumnDefinition Width="360" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Grid Grid.Column="0" Background="{StaticResource RailBackgroundBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Top" Spacing="9" Margin="0,14,0,0">
|
||||||
|
<Button Content="✉" Style="{StaticResource RailButtonStyle}" Foreground="{StaticResource AccentBrush}" />
|
||||||
|
<Button Content="✓" Style="{StaticResource RailButtonStyle}" />
|
||||||
|
<Button Content="▣" Style="{StaticResource RailButtonStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Column="1" Background="#1B1B1B" Padding="12,18">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Text="文件夹" FontSize="16" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||||
|
<Button Grid.Row="1" Content="+ 新建文件夹" Style="{StaticResource QuietButtonStyle}" HorizontalAlignment="Left" Margin="-10,12,0,8" />
|
||||||
|
<StackPanel Grid.Row="2" Spacing="2">
|
||||||
|
<Button Tag="inbox" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}">
|
||||||
|
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="28" /><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions><TextBlock Text="收件箱" Grid.Column="1" /><TextBlock x:Name="InboxCount" Text="" Grid.Column="2" Foreground="{StaticResource AccentBrush}" /></Grid>
|
||||||
|
</Button>
|
||||||
|
<Button Tag="sent" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="已发送" /></Button>
|
||||||
|
<Button Tag="drafts" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="草稿" /></Button>
|
||||||
|
<Button Tag="archive" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="归档" /></Button>
|
||||||
|
<Button Tag="trash" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="垃圾箱" /></Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Column="2" Background="#202020" BorderBrush="{StaticResource StrokeBrush}" BorderThickness="1,0,1,0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="44" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid Padding="14,0">
|
||||||
|
<TextBlock x:Name="FolderTitle" Text="收件箱" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||||
|
<TextBlock x:Name="FolderSubtitle" Text="" HorizontalAlignment="Right" VerticalAlignment="Center" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||||
|
</Grid>
|
||||||
|
<ListView Grid.Row="1" x:Name="MessageList" SelectionChanged="MessageList_SelectionChanged" SelectionMode="Single" BorderThickness="0" Background="Transparent" Padding="8,0">
|
||||||
|
<ListView.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListViewItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Margin" Value="0,2" />
|
||||||
|
</Style>
|
||||||
|
</ListView.ItemContainerStyle>
|
||||||
|
<ListView.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Padding="12,10" CornerRadius="7" Background="{Binding Unread, Converter={StaticResource UnreadBackgroundConverter}}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions><ColumnDefinition Width="34" /><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||||
|
<Border Width="28" Height="28" CornerRadius="14" Background="#3B4D60" VerticalAlignment="Top"><TextBlock Text="{Binding Initials}" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="#E6EEF7" /></Border>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="3" Margin="9,0,5,0">
|
||||||
|
<TextBlock Text="{Binding SenderName}" FontWeight="{Binding Unread, Converter={StaticResource UnreadWeightConverter}}" Foreground="{StaticResource TextPrimaryBrush}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Text="{Binding Subject}" Foreground="{StaticResource TextSecondaryBrush}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="{Binding Preview}" Foreground="{StaticResource TextTertiaryBrush}" FontSize="12" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Text="{Binding DeliveryStatusLabel}" Foreground="{StaticResource AccentBrush}" FontSize="11" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" FontSize="11" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListView.ItemTemplate>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Column="3" Background="#242424" Padding="30,26">
|
||||||
|
<Grid x:Name="EmptyReadingPane" Visibility="Visible">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
|
||||||
|
<TextBlock Text="选择一封邮件" FontSize="22" Foreground="{StaticResource TextPrimaryBrush}" HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Text="邮件内容会显示在这里" Foreground="{StaticResource TextTertiaryBrush}" HorizontalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<ScrollViewer x:Name="ReadingPane" Visibility="Collapsed" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="18">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<TextBlock x:Name="ReadingSubject" FontSize="26" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" TextWrapping="Wrap" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||||
|
<Border Width="30" Height="30" CornerRadius="15" Background="#3B4D60"><TextBlock x:Name="ReadingInitials" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="#E6EEF7" /></Border>
|
||||||
|
<StackPanel><TextBlock x:Name="ReadingFrom" Foreground="{StaticResource TextPrimaryBrush}" /><TextBlock x:Name="ReadingDate" Foreground="{StaticResource TextTertiaryBrush}" FontSize="12" /></StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<Border Height="1" Background="{StaticResource StrokeBrush}" />
|
||||||
|
<TextBlock x:Name="ReadingBody" TextWrapping="Wrap" FontSize="15" LineHeight="24" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -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<MailSummary> 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 = "收件人,例如 [email protected]" };
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MailSummary> 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; } = "";
|
||||||
|
}
|
||||||
@@ -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 路由。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
|
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||||
|
<RootNamespace>WpywMail.Client</RootNamespace>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWinUI>true</UseWinUI>
|
||||||
|
<EnableMsixTooling>true</EnableMsixTooling>
|
||||||
|
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="WpywMail.Client.app" />
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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 防抖;不使用粒子、弹跳或持续动画。
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0b1526" />
|
||||||
|
<title>wpyw.mail</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<RootNamespace>WpywMail.Installer</RootNamespace>
|
||||||
|
<AssemblyName>wpyw-mail-server-installer</AssemblyName>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Payload.zip" LogicalName="WpywMail.Installer.Payload.zip" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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%
|
||||||
@@ -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 '本安装程序已经预填第一个邮箱:[email protected]。' -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 位,登录 [email protected] 使用)' -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 = '[email protected]'
|
||||||
|
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 '邮箱地址:[email protected]'
|
||||||
|
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 '按回车键退出'
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# wpyw.mail 安装器填写顺序
|
||||||
|
|
||||||
|
安装器是命令行窗口,所有问题都按顺序出现。看到方括号默认值时直接按回车即可。
|
||||||
|
|
||||||
|
## 推荐第一次安装
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 程序安装目录 直接回车
|
||||||
|
2. 邮件数据目录 直接回车;如果有空间更大的 D 盘,可填 D:\WpywMailData
|
||||||
|
3. 邮箱密码 输入至少 12 位密码,给 [email protected] 使用
|
||||||
|
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
|
||||||
|
账号:[email protected]
|
||||||
|
密码:中继服务商提供的密码
|
||||||
|
```
|
||||||
|
|
||||||
|
就在安装器中依次填入这四项。中继账号和邮箱账号不一定相同,以中继服务商给出的信息为准。
|
||||||
|
|
||||||
|
## 证书怎么填
|
||||||
|
|
||||||
|
正式使用时建议准备包含 `mail.wpyw.site` 的 PFX 证书,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\certs\mail.wpyw.site.pfx
|
||||||
|
```
|
||||||
|
|
||||||
|
没有证书时先直接回车可以完成测试安装,但客户端连接时可能提示证书不受信任。安装器会把临时证书保存到邮件数据目录的 `certs` 子目录。
|
||||||
Generated
+3319
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 账号体系的业务层:自助注册、邮箱验证码、失败锁定、密码重置、会话与资料管理。
|
||||||
|
///
|
||||||
|
/// 设计取舍(都在代码里写清楚,方便以后回看):
|
||||||
|
///
|
||||||
|
/// 1. **先建未激活信箱,密码等验证通过才写入**:本地投递只认已存在的用户,所以注册时必须
|
||||||
|
/// 先把信箱行建出来(否则验证码邮件会被判成外发、甚至直接丢弃,用户永远收不到码);
|
||||||
|
/// 但这一行的密码是**随机不可用值**,真正的密码存在验证码记录的 Payload 里,
|
||||||
|
/// 验证通过后才写进用户行并激活 —— 于是「谁能读到验证码,谁才能决定这个账号的密码」。
|
||||||
|
/// 未激活的信箱不能登录 IMAP/SMTP/API(见各处的 FindUser 只取 active=1)。
|
||||||
|
/// ⚠️ 曾经写成「验证通过再建号」,结果是验证码邮件根本送不到,属于致命流程缺陷。
|
||||||
|
/// 2. **验证码只存哈希**:库被拖走也不能直接拿来激活账号或改密码。
|
||||||
|
/// 3. **登录失败信息不区分原因**:对外一律「邮箱或密码不正确」,避免账号枚举;
|
||||||
|
/// 真实原因(不存在 / 密码错 / 已停用 / 已锁定)只写进审计日志。
|
||||||
|
/// 4. **发信走既有出站队列**:验证码邮件复用 Mime.Build + QueueOutbound + 投递队列,
|
||||||
|
/// 不另起一条发送路径(否则重试、DKIM、队列状态都要再实现一遍)。
|
||||||
|
/// </summary>
|
||||||
|
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})注册后免验证码直接开通:验证码邮件只能投进这个信箱,"
|
||||||
|
+ "而它在验证通过前登录不了,会形成死循环;这类地址的授权凭据是邀请码。"
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>该地址的信箱是否就托管在本机上(域名 = 本服务器自己的域)。</summary>
|
||||||
|
public bool IsHostedHere(string? email) => IsHostedDomain(email, config.Domain, config.Hostname);
|
||||||
|
|
||||||
|
/// <summary>纯函数版本:不依赖存储,供 --check-config 等只读场景使用。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 这个地址要不要走邮箱验证码。
|
||||||
|
///
|
||||||
|
/// ⚠️ **本机托管的地址必须跳过**,否则是死循环:验证码邮件投进的就是这个信箱,
|
||||||
|
/// 而它在验证通过前不允许登录(IMAP / Webmail / API 全部进不去)→ 用户永远拿不到验证码。
|
||||||
|
/// 这类地址的授权凭据是**邀请码**(管理员亲自发放),注册即开通。
|
||||||
|
/// 只有邮箱托管在别处(例如 AllowedDomains 里放了 gmail.com)时,邮箱验证才真正有意义。
|
||||||
|
/// </summary>
|
||||||
|
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('.');
|
||||||
|
|
||||||
|
/// <summary>密码强度:长度 + 不能纯数字 + 不能与邮箱相同(够用即可,不搞复杂度表演)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>校验验证码;成功返回 true 并删除该验证码。会处理过期与试错次数。</summary>
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>校验注册验证码:通过后写入密码并激活账号(等价于注册即登录)。</summary>
|
||||||
|
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<PendingRegistration>(
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>重发验证码(注册 / 重置共用)。</summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 登录加固
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 登录失败(Authenticate 返回 null)时该怎么回话,以及审计里写什么原因。
|
||||||
|
///
|
||||||
|
/// 原则:**只在用户真的卡住时多说话**。「注册了但没验证完」的人如果只看到「邮箱或密码不正确」,
|
||||||
|
/// 会一直以为密码错了 —— 而正确的出路是完成验证或用「忘记密码」。
|
||||||
|
/// 其余情形(账号不存在 / 密码错 / 被管理员停用)一律同一句话,不做账号状态探测。
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>返回锁定剩余秒数;0 表示未锁定。</summary>
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 发信
|
||||||
|
|
||||||
|
/// <summary>把验证码邮件投进出站队列(复用既有的 Mime.Build + QueueOutbound + 投递/重试链路)。</summary>
|
||||||
|
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<string>()), "", "", 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));
|
||||||
|
|
||||||
|
/// <summary>注册待验证时暂存的信息(存进验证码记录的 Payload,验证通过后才写进用户表)。</summary>
|
||||||
|
public sealed class PendingRegistration
|
||||||
|
{
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public string PasswordHash { get; set; } = "";
|
||||||
|
public string PasswordSalt { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 管理/客户端 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 全部保持兼容。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiServer
|
||||||
|
{
|
||||||
|
private readonly AppConfig config;
|
||||||
|
private readonly IMailStore store;
|
||||||
|
private readonly AccountService accounts;
|
||||||
|
private readonly HttpListener listener = new();
|
||||||
|
/// <summary>可选的公网监听(只放账号类接口,见 <see cref="IsPublicAccountRoute"/>)。为空表示不开。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 公网监听**只**放行「账号相关」的接口:注册、验证码、找回密码、登录、改密、会话与资料。
|
||||||
|
/// 邮件读写(/api/messages、/api/send、/api/queue、/api/watch、/api/drafts)与管理接口
|
||||||
|
/// (/api/admin/*)**一律不在公网暴露** —— 那些只能从回环/受控网络访问。
|
||||||
|
/// 这是刻意做窄的暴露面:客户端要在公网自助注册与改密码,但读信发信走 IMAP/SMTP。
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>客户端 IP(本机调用时就是回环地址;审计与限流用)。</summary>
|
||||||
|
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=<host:port> 绑定证书)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RegisterRequest>(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<VerifyCodeRequest>(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<Dictionary<string, string>>(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<Dictionary<string, string>>(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<ResetPasswordRequest>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 认证
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 登录。相比 v2.0.x 增加了三件事:
|
||||||
|
/// ① 失败计数与临时锁定(同一账号在窗口内连续失败到阈值即锁定,返回 423 与剩余秒数);
|
||||||
|
/// ② 审计(成功/失败/锁定都记 IP 与 UA,便于排查与限流);
|
||||||
|
/// ③ 成功时更新 lastLoginAt 并签发会话。
|
||||||
|
/// 对外错误信息统一为「邮箱或密码不正确」,真实原因只进审计,避免账号枚举。
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoginAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
|
{
|
||||||
|
var body = await ReadJsonAsync<LoginRequest>(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<Dictionary<string, JsonElement>>(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<SendRequest>(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<Attachment>();
|
||||||
|
var outgoing = new List<OutgoingAttachment>();
|
||||||
|
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<DraftRequest>(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<Dictionary<string, string>>(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<ProfileRequest>(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<Dictionary<string, JsonElement>>(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<Dictionary<string, string>>(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<Dictionary<string, JsonElement>>(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<T?> ReadJsonAsync<T>(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<T>(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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>版本信息(客户端可据此判断兼容性)。</summary>
|
||||||
|
public static class BuildInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 取签名输入字节的潜在缺陷。
|
||||||
|
/// </summary>
|
||||||
|
public const string Version = "2.2.1";
|
||||||
|
public const string Product = "wpyw.mail.native";
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>极简日志:控制台 + 文件(超过阈值自动滚动一次)。</summary>
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发件队列。轮询待发任务 → 读取原始报文 →(可选)DKIM 签名 → 投递 → 记录结果。
|
||||||
|
///
|
||||||
|
/// 相比 v1 的改进:
|
||||||
|
/// 1. 重试策略可配置(4xx 与 5xx 分开处理,指数退避带上限);
|
||||||
|
/// 2. DKIM 在投递时签名,因此每次重试都会带上新的时间戳;
|
||||||
|
/// 3. 彻底失败时给发件人投递一封退信(NDR),不再静默丢失。
|
||||||
|
/// </summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 出站投递。支持两种模式:
|
||||||
|
/// direct —— 查 MX 直接投递(默认)
|
||||||
|
/// relay —— 交给上游 SMTP 中继(可带认证与 STARTTLS)
|
||||||
|
///
|
||||||
|
/// 相比 v1 的关键修正:
|
||||||
|
/// DATA 阶段按「字节」写出(v1 用 Encoding.ASCII 的 StreamWriter,导致所有中文变成 '?'),
|
||||||
|
/// 并显式处理 dot-stuffing 与行尾规范化。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>与某个 SMTP 服务器完成一次投递事务。</summary>
|
||||||
|
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<string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写出 DATA 段。
|
||||||
|
///
|
||||||
|
/// 这里是 v1 中文变 '?' 的根因所在(v1 用 Encoding.ASCII 的 StreamWriter 写),
|
||||||
|
/// 现在改为按字节写出,且除 dot-stuffing 外不改动任何字节——
|
||||||
|
/// 行尾规范化已在签名之前完成(见 SmtpDataEncoder.Normalize),
|
||||||
|
/// 若在此处再改行尾会让 DKIM 正文哈希对不上。
|
||||||
|
/// </summary>
|
||||||
|
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<SmtpReply> 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<SmtpReply> ReadReplyAsync(StreamReader reader, TimeSpan timeout, CancellationToken token)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
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<string> Lines)
|
||||||
|
{
|
||||||
|
/// <summary>取最后一行去掉状态码后的文本,便于写入日志。</summary>
|
||||||
|
public string Detail => Lines.Count == 0 ? "" : (Lines[^1].Length > 4 ? Lines[^1][4..] : Lines[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>STARTTLS 握手失败(机会式 TLS 场景下应回退明文重连)。</summary>
|
||||||
|
private sealed class StartTlsFailedException(string message) : Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SMTP 阶段异常,带状态码与「是否永久失败」判定。</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>极简 DNS MX 查询(不依赖第三方库,直接走 UDP 53)。</summary>
|
||||||
|
internal static class MxResolver
|
||||||
|
{
|
||||||
|
public static async Task<IReadOnlyList<string>> 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<IReadOnlyList<string>> 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<string> 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<string>();
|
||||||
|
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<IPAddress> 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")];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DKIM 签名(RFC 6376),算法 rsa-sha256,规范化 relaxed/relaxed。
|
||||||
|
///
|
||||||
|
/// 私钥不存在时会自动生成 2048 位 RSA 并保存为 PEM,同时把需要配置到 DNS 的
|
||||||
|
/// TXT 记录打印到日志,方便直接复制到 Cloudflare。
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>SubjectPublicKeyInfo 形式的公钥(自检与 DNS 记录生成使用)。</summary>
|
||||||
|
public byte[] PublicKeyBytes => key.ExportSubjectPublicKeyInfo();
|
||||||
|
|
||||||
|
/// <summary>DKIM 公钥所在的 DNS 记录名(不含域后缀)。</summary>
|
||||||
|
public string RecordName => $"{selector}._domainkey";
|
||||||
|
|
||||||
|
/// <summary>DKIM 公钥记录值(TXT)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按配置创建签名器;未启用或初始化失败时返回 null(调用方继续正常发信)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把需要配置到 DNS 的公钥记录打印出来(分段给 TXT 用)。</summary>
|
||||||
|
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<string> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>对整封邮件签名,返回带 DKIM-Signature 头的新报文。失败时原样返回。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 规范化
|
||||||
|
|
||||||
|
/// <summary>relaxed 头规范化:小写名、展开折行、多空白折成一个空格、去首尾空白。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>relaxed 正文规范化:多空白折成一个空格、去行尾空白、去掉末尾空行。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把报文拆成「原始头行(未展开)」与「正文字节」,保持字节忠实。</summary>
|
||||||
|
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..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,975 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 基于文件的存储:users.json / messages.json / queue.json / sessions.json + raw/ 与 attachments/。
|
||||||
|
/// 单机个人邮箱场景下,这种实现的可靠性与可审计性优于引入数据库依赖。
|
||||||
|
///
|
||||||
|
/// 全量写入 + 原子替换(写 .tmp 再 Move),并对所有变更加锁。
|
||||||
|
///
|
||||||
|
/// ⚠️ 性能特征:**任何一次改动都会把全部邮件重新序列化并整文件重写**(O(N)),
|
||||||
|
/// 邮件量上千以后单次「标记已读」也会变得明显昂贵。v2.1 起默认使用
|
||||||
|
/// <see cref="SqliteStore"/>,本实现保留作为可回滚的后端(Storage.Provider=json)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileStore : IMailStore
|
||||||
|
{
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||||
|
private readonly string usersPath;
|
||||||
|
private readonly string messagesPath;
|
||||||
|
private readonly string queuePath;
|
||||||
|
private readonly string sessionsPath;
|
||||||
|
private readonly string verificationPath;
|
||||||
|
private readonly string authEventsPath;
|
||||||
|
private readonly string rawDirectory;
|
||||||
|
private readonly string attachmentDirectory;
|
||||||
|
private readonly AppConfig config;
|
||||||
|
|
||||||
|
private List<MailUser> users = [];
|
||||||
|
private List<MailMessage> messages = [];
|
||||||
|
private List<QueueItem> queue = [];
|
||||||
|
private List<SessionRecord> sessions = [];
|
||||||
|
private List<VerificationCode> codes = [];
|
||||||
|
private List<AuthEvent> authEvents = [];
|
||||||
|
|
||||||
|
/// <summary>审计保留条数(由 ApiServer 按配置注入)。</summary>
|
||||||
|
public int AuditKeep { get; set; } = 2000;
|
||||||
|
|
||||||
|
/// <summary>每次写入都会自增,供长轮询判断「有没有新变化」。</summary>
|
||||||
|
private long version;
|
||||||
|
|
||||||
|
public FileStore(AppConfig config)
|
||||||
|
{
|
||||||
|
this.config = config;
|
||||||
|
Directory.CreateDirectory(config.DataDirectory);
|
||||||
|
rawDirectory = Path.Combine(config.DataDirectory, "raw");
|
||||||
|
attachmentDirectory = Path.Combine(config.DataDirectory, "attachments");
|
||||||
|
Directory.CreateDirectory(rawDirectory);
|
||||||
|
Directory.CreateDirectory(attachmentDirectory);
|
||||||
|
usersPath = Path.Combine(config.DataDirectory, "users.json");
|
||||||
|
messagesPath = Path.Combine(config.DataDirectory, "messages.json");
|
||||||
|
queuePath = Path.Combine(config.DataDirectory, "queue.json");
|
||||||
|
sessionsPath = Path.Combine(config.DataDirectory, "sessions.json");
|
||||||
|
verificationPath = Path.Combine(config.DataDirectory, "verification.json");
|
||||||
|
authEventsPath = Path.Combine(config.DataDirectory, "auth-events.json");
|
||||||
|
Load();
|
||||||
|
EnsureAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long Version { get { lock (gate) return version; } }
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
users = Read<List<MailUser>>(usersPath) ?? [];
|
||||||
|
messages = Read<List<MailMessage>>(messagesPath) ?? [];
|
||||||
|
queue = Read<List<QueueItem>>(queuePath) ?? [];
|
||||||
|
sessions = Read<List<SessionRecord>>(sessionsPath) ?? [];
|
||||||
|
codes = Read<List<VerificationCode>>(verificationPath) ?? [];
|
||||||
|
authEvents = Read<List<AuthEvent>>(authEventsPath) ?? [];
|
||||||
|
|
||||||
|
// 上次异常退出时残留的 processing 状态回退成 retry
|
||||||
|
foreach (var item in queue.Where(x => x.Status == "processing"))
|
||||||
|
{
|
||||||
|
item.Status = "retry";
|
||||||
|
item.NextAttempt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
// 过期的会话直接清掉
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
sessions.RemoveAll(x => x.Expires < now);
|
||||||
|
|
||||||
|
// 给历史邮件补 IMAP UID(同一账号同一文件夹内按时间递增)
|
||||||
|
var assigned = false;
|
||||||
|
foreach (var group in messages.Where(m => m.Uid == 0).GroupBy(m => (m.OwnerEmail.ToLowerInvariant(), m.Folder.ToLowerInvariant())))
|
||||||
|
{
|
||||||
|
var uid = messages.Where(m => m.OwnerEmail.Equals(group.Key.Item1, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& m.Folder.Equals(group.Key.Item2, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(m => m.Uid).DefaultIfEmpty(0).Max();
|
||||||
|
foreach (var message in group.OrderBy(m => m.Date))
|
||||||
|
{
|
||||||
|
message.Uid = ++uid;
|
||||||
|
assigned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (assigned) Write(messagesPath, messages);
|
||||||
|
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? Read<T>(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) return default;
|
||||||
|
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), json); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Error($"[存储] 读取 {Path.GetFileName(path)} 失败,将从空数据继续:{ex.Message}");
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Write<T>(string path, T value)
|
||||||
|
{
|
||||||
|
var temp = path + ".tmp";
|
||||||
|
File.WriteAllText(temp, JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }), new UTF8Encoding(false));
|
||||||
|
File.Move(temp, path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAdmin()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var existing = users.FirstOrDefault(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
// 配置里的密码变了就同步(方便改密码后重启生效)
|
||||||
|
if (!VerifyPassword(config.AdminPassword, existing.PasswordHash, existing.PasswordSalt))
|
||||||
|
{
|
||||||
|
existing.PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||||
|
existing.PasswordHash = HashPassword(config.AdminPassword, existing.PasswordSalt);
|
||||||
|
Write(usersPath, users);
|
||||||
|
AppLog.Info($"[存储] 已按 appsettings.json 更新 {existing.Email} 的密码。");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new MailUser
|
||||||
|
{
|
||||||
|
Email = config.AdminEmail.ToLowerInvariant(),
|
||||||
|
DisplayName = "Administrator",
|
||||||
|
Role = "admin",
|
||||||
|
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||||
|
};
|
||||||
|
user.PasswordHash = HashPassword(config.AdminPassword, user.PasswordSalt);
|
||||||
|
users.Add(user);
|
||||||
|
Write(usersPath, users);
|
||||||
|
AppLog.Info($"[存储] 已创建管理员邮箱:{user.Email}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 用户
|
||||||
|
|
||||||
|
public MailUser? FindUser(string email) =>
|
||||||
|
users.FirstOrDefault(x => x.Active && x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public MailUser? FindUserAnyState(string email) =>
|
||||||
|
users.FirstOrDefault(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public MailUser? Authenticate(string email, string password)
|
||||||
|
{
|
||||||
|
var user = FindUser(email);
|
||||||
|
if (user is null) return null;
|
||||||
|
if (!VerifyPassword(password ?? "", user.PasswordHash, user.PasswordSalt)) return null;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
Write(usersPath, users);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLocalAddress(string email) =>
|
||||||
|
FindUser(email) is not null || users.Any(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public IReadOnlyList<MailUser> ListUsers() => users.OrderBy(x => x.Email).ToArray();
|
||||||
|
|
||||||
|
public MailUser CreateUser(string email, string password, string displayName)
|
||||||
|
{
|
||||||
|
email = (email ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (!email.Contains('@')) throw new InvalidOperationException("邮箱地址不合法。");
|
||||||
|
if (users.Any(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("用户已存在。");
|
||||||
|
var user = new MailUser
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName ?? "",
|
||||||
|
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
user.PasswordHash = HashPassword(password, user.PasswordSalt);
|
||||||
|
lock (gate) { users.Add(user); Write(usersPath, users); version++; }
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>用已算好的哈希建号(注册验证通过时用,避免明文密码再走一遍内存)。</summary>
|
||||||
|
public MailUser CreateUserWithHash(string email, string passwordHash, string passwordSalt, string displayName, bool active = true)
|
||||||
|
{
|
||||||
|
email = (email ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (!email.Contains('@')) throw new InvalidOperationException("邮箱地址不合法。");
|
||||||
|
if (string.IsNullOrEmpty(passwordHash) || string.IsNullOrEmpty(passwordSalt))
|
||||||
|
throw new InvalidOperationException("密码哈希不能为空。");
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var existing = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
// 允许「上次注册没验证完」的账号重新注册:覆盖密码与显示名,保持未激活
|
||||||
|
if (existing.Active) throw new InvalidOperationException("用户已存在。");
|
||||||
|
existing.DisplayName = displayName ?? existing.DisplayName;
|
||||||
|
existing.PasswordHash = passwordHash;
|
||||||
|
existing.PasswordSalt = passwordSalt;
|
||||||
|
Write(usersPath, users);
|
||||||
|
version++;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
var user = new MailUser
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName ?? "",
|
||||||
|
PasswordHash = passwordHash,
|
||||||
|
PasswordSalt = passwordSalt,
|
||||||
|
Active = active,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
users.Add(user);
|
||||||
|
Write(usersPath, users);
|
||||||
|
version++;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ChangePassword(string email, string password)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? throw new InvalidOperationException("用户不存在。");
|
||||||
|
user.PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||||
|
user.PasswordHash = HashPassword(password, user.PasswordSalt);
|
||||||
|
Write(usersPath, users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetUserActive(string email, bool active)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (user is null) return;
|
||||||
|
user.Active = active;
|
||||||
|
Write(usersPath, users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteUser(string email)
|
||||||
|
{
|
||||||
|
var key = (email ?? "").Trim();
|
||||||
|
if (key.Length == 0) return false;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (user is null) return false;
|
||||||
|
users.Remove(user);
|
||||||
|
sessions.RemoveAll(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
codes.RemoveAll(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
queue.RemoveAll(x => x.OwnerEmail.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
Write(usersPath, users);
|
||||||
|
Write(sessionsPath, sessions);
|
||||||
|
Write(verificationPath, codes);
|
||||||
|
Write(queuePath, queue);
|
||||||
|
version++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 会话
|
||||||
|
|
||||||
|
public SessionRecord CreateSession(string email, int days)
|
||||||
|
{
|
||||||
|
var record = new SessionRecord
|
||||||
|
{
|
||||||
|
Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(),
|
||||||
|
Email = email,
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddDays(Math.Max(1, days)),
|
||||||
|
};
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
sessions.RemoveAll(x => x.Expires < DateTimeOffset.UtcNow);
|
||||||
|
sessions.Add(record);
|
||||||
|
Write(sessionsPath, sessions);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionRecord? GetSession(string? token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var record = sessions.FirstOrDefault(x => x.Token.Equals(token, StringComparison.Ordinal));
|
||||||
|
if (record is null) return null;
|
||||||
|
if (record.Expires < DateTimeOffset.UtcNow) { sessions.Remove(record); Write(sessionsPath, sessions); return null; }
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveSession(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token)) return;
|
||||||
|
lock (gate) { sessions.RemoveAll(x => x.Token.Equals(token, StringComparison.Ordinal)); Write(sessionsPath, sessions); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SessionRecord> ListSessions(string email)
|
||||||
|
{
|
||||||
|
var key = (email ?? "").Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return [.. sessions
|
||||||
|
.Where(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) && x.Expires >= DateTimeOffset.UtcNow)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int RemoveSessions(string email, string? keepToken)
|
||||||
|
{
|
||||||
|
var key = (email ?? "").Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var removed = sessions.RemoveAll(x =>
|
||||||
|
x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(string.IsNullOrWhiteSpace(keepToken) || !x.Token.Equals(keepToken, StringComparison.Ordinal)));
|
||||||
|
if (removed > 0) { Write(sessionsPath, sessions); version++; }
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 账号体系(注册 / 验证码 / 审计)
|
||||||
|
|
||||||
|
public void UpdateProfile(string email, string displayName)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (user is null) return;
|
||||||
|
user.DisplayName = displayName ?? "";
|
||||||
|
Write(usersPath, users);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetLastLogin(string email)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(x => x.Email.Equals((email ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (user is null) return;
|
||||||
|
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
Write(usersPath, users);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveVerificationCode(VerificationCode code)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
codes.RemoveAll(x => x.Email.Equals(code.Email, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
x.Purpose.Equals(code.Purpose, StringComparison.OrdinalIgnoreCase));
|
||||||
|
codes.Add(code);
|
||||||
|
Write(verificationPath, codes);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public VerificationCode? FindVerificationCode(string email, string purpose)
|
||||||
|
{
|
||||||
|
var key = (email ?? "").Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return codes.FirstOrDefault(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
x.Purpose.Equals(purpose, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int IncrementVerificationAttempts(string email, string purpose)
|
||||||
|
{
|
||||||
|
var key = (email ?? "").Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var code = codes.FirstOrDefault(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
x.Purpose.Equals(purpose, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (code is null) return 0;
|
||||||
|
code.Attempts++;
|
||||||
|
Write(verificationPath, codes);
|
||||||
|
version++;
|
||||||
|
return code.Attempts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveVerificationCode(string email, string purpose)
|
||||||
|
{
|
||||||
|
var key = (email ?? "").Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var removed = codes.RemoveAll(x => x.Email.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
x.Purpose.Equals(purpose, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (removed > 0) { Write(verificationPath, codes); version++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordAuthEvent(AuthEvent entry)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
authEvents.Add(entry);
|
||||||
|
// 超量就按时间淘汰(保留最新 AuditKeep 条)
|
||||||
|
if (authEvents.Count > Math.Max(100, AuditKeep))
|
||||||
|
{
|
||||||
|
authEvents = [.. authEvents.OrderByDescending(x => x.At).Take(Math.Max(100, AuditKeep))];
|
||||||
|
}
|
||||||
|
Write(authEventsPath, authEvents);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AuthEvent> ListAuthEvents(string? email, string? ip, string? reason, int limit)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
IEnumerable<AuthEvent> q = authEvents;
|
||||||
|
if (!string.IsNullOrWhiteSpace(email)) q = q.Where(x => x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (!string.IsNullOrWhiteSpace(ip)) q = q.Where(x => x.Ip.Equals(ip.Trim(), StringComparison.Ordinal));
|
||||||
|
if (!string.IsNullOrWhiteSpace(reason)) q = q.Where(x => x.Reason.Equals(reason.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
return [.. q.OrderByDescending(x => x.At).Take(Math.Clamp(limit <= 0 ? 200 : limit, 1, 5000))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CountAuthEvents(string? email, string? ip, string? reason, bool? success, int minutes)
|
||||||
|
{
|
||||||
|
var since = DateTimeOffset.UtcNow.AddMinutes(-Math.Max(1, minutes));
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
IEnumerable<AuthEvent> q = authEvents.Where(x => x.At >= since);
|
||||||
|
if (!string.IsNullOrWhiteSpace(email)) q = q.Where(x => x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (!string.IsNullOrWhiteSpace(ip)) q = q.Where(x => x.Ip.Equals(ip.Trim(), StringComparison.Ordinal));
|
||||||
|
if (!string.IsNullOrWhiteSpace(reason)) q = q.Where(x => x.Reason.Equals(reason.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (success.HasValue) q = q.Where(x => x.Success == success.Value);
|
||||||
|
return q.Count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 原始报文与附件
|
||||||
|
|
||||||
|
public string SaveRaw(byte[] raw)
|
||||||
|
{
|
||||||
|
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}.eml";
|
||||||
|
var relative = Path.Combine("raw", name);
|
||||||
|
File.WriteAllBytes(Path.Combine(config.DataDirectory, relative), raw);
|
||||||
|
return relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] ReadRaw(string relativePath) => ReadInside(rawDirectory, relativePath);
|
||||||
|
|
||||||
|
public string SaveAttachment(byte[] data, string suggestedName)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(suggestedName ?? "");
|
||||||
|
if (extension.Length > 16) extension = "";
|
||||||
|
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension}";
|
||||||
|
var relative = Path.Combine("attachments", name);
|
||||||
|
File.WriteAllBytes(Path.Combine(config.DataDirectory, relative), data);
|
||||||
|
return relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] ReadAttachment(string relativePath) => ReadInside(attachmentDirectory, relativePath);
|
||||||
|
|
||||||
|
private byte[] ReadInside(string allowedDirectory, string relativePath)
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(Path.Combine(config.DataDirectory, relativePath ?? ""));
|
||||||
|
var root = Path.GetFullPath(allowedDirectory);
|
||||||
|
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("非法文件路径。");
|
||||||
|
return File.ReadAllBytes(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 邮件
|
||||||
|
|
||||||
|
public MailMessage SaveMessage(MailMessage message, byte[]? raw = null)
|
||||||
|
{
|
||||||
|
if (raw is not null)
|
||||||
|
{
|
||||||
|
message.RawPath = SaveRaw(raw);
|
||||||
|
message.Size = raw.Length;
|
||||||
|
}
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (message.Uid == 0) message.Uid = NextUidLocked(message.OwnerEmail, message.Folder);
|
||||||
|
messages.Add(message);
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>同一账号 + 同一文件夹内的下一个 IMAP UID。</summary>
|
||||||
|
private int NextUidLocked(string owner, string folder) =>
|
||||||
|
messages.Where(m => m.OwnerEmail.Equals(owner ?? "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& m.Folder.Equals(folder ?? "", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(m => m.Uid).DefaultIfEmpty(0).Max() + 1;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- IMAP 支持
|
||||||
|
|
||||||
|
/// <summary>IMAP 用的文件夹列表(固定集合,未使用也返回,便于客户端订阅)。</summary>
|
||||||
|
public static readonly string[] ImapFolders = MailFolders.ImapFolders;
|
||||||
|
|
||||||
|
public static string? NormalizeFolder(string name) => MailFolders.Normalize(name);
|
||||||
|
|
||||||
|
/// <summary>按 UID 升序返回某文件夹的邮件(IMAP 要求的稳定顺序)。</summary>
|
||||||
|
public IReadOnlyList<MailMessage> ListForImap(string owner, string folder)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return messages
|
||||||
|
.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(x => x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(x => x.Uid)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage? GetByUid(string owner, string folder, int uid) =>
|
||||||
|
ListForImap(owner, folder).FirstOrDefault(x => x.Uid == uid);
|
||||||
|
|
||||||
|
/// <summary>IMAP STORE:设置 \Seen / \Flagged。</summary>
|
||||||
|
public bool StoreFlags(string owner, string id, bool? seen, bool? flagged)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var item = GetMessage(owner, id);
|
||||||
|
if (item is null) return false;
|
||||||
|
if (seen.HasValue) item.Unread = !seen.Value;
|
||||||
|
if (flagged.HasValue) item.Starred = flagged.Value;
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
version++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>IMAP EXPUNGE:inbox 等移入垃圾箱;已在垃圾箱则彻底删除。</summary>
|
||||||
|
public bool Expunge(string owner, string id)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var item = GetMessage(owner, id);
|
||||||
|
if (item is null) return false;
|
||||||
|
if (item.Folder.Equals("trash", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
messages.Remove(item);
|
||||||
|
TryDelete(Path.Combine(config.DataDirectory, item.RawPath));
|
||||||
|
foreach (var attachment in item.Attachments) TryDelete(Path.Combine(config.DataDirectory, attachment.StoredAs));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item.Folder = "trash";
|
||||||
|
}
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
version++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>IMAP APPEND:把客户端上传的报文存入指定文件夹。</summary>
|
||||||
|
public MailMessage? Append(string owner, string folder, byte[] raw, bool seen)
|
||||||
|
{
|
||||||
|
var parsed = Mime.Parse(raw);
|
||||||
|
var attachments = new List<Attachment>();
|
||||||
|
foreach (var attachment in parsed.Attachments)
|
||||||
|
{
|
||||||
|
if (attachment.Data.Length == 0) continue;
|
||||||
|
attachments.Add(new Attachment
|
||||||
|
{
|
||||||
|
FileName = attachment.FileName,
|
||||||
|
ContentType = attachment.ContentType,
|
||||||
|
Size = attachment.Data.Length,
|
||||||
|
StoredAs = SaveAttachment(attachment.Data, attachment.FileName),
|
||||||
|
ContentId = attachment.ContentId,
|
||||||
|
Inline = attachment.Inline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SaveMessage(new MailMessage
|
||||||
|
{
|
||||||
|
OwnerEmail = owner,
|
||||||
|
Folder = folder,
|
||||||
|
From = parsed.From,
|
||||||
|
To = parsed.To,
|
||||||
|
Cc = parsed.Cc,
|
||||||
|
Subject = parsed.Subject,
|
||||||
|
Text = parsed.Text,
|
||||||
|
Html = parsed.Html,
|
||||||
|
MessageId = parsed.MessageId,
|
||||||
|
InReplyTo = parsed.InReplyTo,
|
||||||
|
References = parsed.References,
|
||||||
|
Date = parsed.Date ?? DateTimeOffset.UtcNow,
|
||||||
|
ReceivedAt = DateTimeOffset.UtcNow,
|
||||||
|
Unread = !seen,
|
||||||
|
DeliveryStatus = folder == "sent" ? "sent" : "received",
|
||||||
|
Attachments = attachments,
|
||||||
|
}, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CountUnseen(string owner, string folder) =>
|
||||||
|
ListForImap(owner, folder).Count(x => x.Unread);
|
||||||
|
|
||||||
|
public int NextUidFor(string owner, string folder) { lock (gate) return NextUidLocked(owner, folder); }
|
||||||
|
|
||||||
|
/// <summary>把外发报文直接投递给本地收件人(同域发信不必绕 SMTP)。</summary>
|
||||||
|
public MailMessage? DeliverLocal(string recipient, byte[] raw, string sender)
|
||||||
|
{
|
||||||
|
// 必须用 FindUserAnyState:未激活的信箱(注册后待验证)也要能收信,
|
||||||
|
// 否则验证码邮件会被静默丢弃。SqliteStore 用的是同一套语义,两个后端契约必须一致。
|
||||||
|
// —— 这条不一致就是自检里「验证码邮件真的投进了未激活信箱」抓出来的。
|
||||||
|
var user = FindUserAnyState(recipient);
|
||||||
|
if (user is null) return null;
|
||||||
|
|
||||||
|
var parsed = Mime.Parse(raw);
|
||||||
|
var attachments = new List<Attachment>();
|
||||||
|
foreach (var attachment in parsed.Attachments)
|
||||||
|
{
|
||||||
|
if (attachment.Data.Length == 0) continue;
|
||||||
|
attachments.Add(new Attachment
|
||||||
|
{
|
||||||
|
FileName = attachment.FileName,
|
||||||
|
ContentType = attachment.ContentType,
|
||||||
|
Size = attachment.Data.Length,
|
||||||
|
StoredAs = SaveAttachment(attachment.Data, attachment.FileName),
|
||||||
|
ContentId = attachment.ContentId,
|
||||||
|
Inline = attachment.Inline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SaveMessage(new MailMessage
|
||||||
|
{
|
||||||
|
OwnerEmail = user.Email.ToLowerInvariant(),
|
||||||
|
Folder = "inbox",
|
||||||
|
From = parsed.From.Length > 0 ? parsed.From : sender,
|
||||||
|
To = recipient,
|
||||||
|
Cc = parsed.Cc,
|
||||||
|
Subject = parsed.Subject,
|
||||||
|
Text = parsed.Text,
|
||||||
|
Html = parsed.Html,
|
||||||
|
MessageId = parsed.MessageId,
|
||||||
|
InReplyTo = parsed.InReplyTo,
|
||||||
|
References = parsed.References,
|
||||||
|
Date = parsed.Date ?? DateTimeOffset.UtcNow,
|
||||||
|
ReceivedAt = DateTimeOffset.UtcNow,
|
||||||
|
DeliveryStatus = "received",
|
||||||
|
Unread = true,
|
||||||
|
Attachments = attachments,
|
||||||
|
}, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MailMessage> ListMessages(string owner, string folder, string query)
|
||||||
|
{
|
||||||
|
query = (query ?? "").Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return messages
|
||||||
|
.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(x => folder.Length == 0 || x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(x => query.Length == 0 || $"{x.From} {x.To} {x.Cc} {x.Subject} {x.Text}".Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(x => x.Date)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage? GetMessage(string owner, string id) =>
|
||||||
|
messages.FirstOrDefault(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>分页查询。文件实现只能先全表过滤再切片(这正是它随邮件量变慢的原因)。</summary>
|
||||||
|
public (int Total, IReadOnlyList<MailMessage> Messages) ListMessagesPage(
|
||||||
|
string owner, string folder, string query, bool unreadOnly, bool starredOnly, int limit, int offset)
|
||||||
|
{
|
||||||
|
query = (query ?? "").Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var filtered = messages
|
||||||
|
.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(x => folder.Length == 0 || x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(x => !unreadOnly || x.Unread)
|
||||||
|
.Where(x => !starredOnly || x.Starred)
|
||||||
|
.Where(x => query.Length == 0 || $"{x.From} {x.To} {x.Cc} {x.Subject} {x.Text}".Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(x => x.Date)
|
||||||
|
.ToArray();
|
||||||
|
return (filtered.Length,
|
||||||
|
filtered.Skip(Math.Max(0, offset)).Take(Math.Max(1, limit)).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage? GetById(string id) =>
|
||||||
|
messages.FirstOrDefault(x => x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>迁移用:读出全部邮件与队列(返回的是引用,可就地修改后调用 Persist)。</summary>
|
||||||
|
public IReadOnlyList<MailMessage> AllMessages() { lock (gate) return messages.ToArray(); }
|
||||||
|
|
||||||
|
public IReadOnlyList<MailUser> AllUsers() { lock (gate) return users.ToArray(); }
|
||||||
|
|
||||||
|
public void Persist() { lock (gate) { Write(messagesPath, messages); Write(queuePath, queue); Write(usersPath, users); Write(verificationPath, codes); Write(authEventsPath, authEvents); version++; } }
|
||||||
|
|
||||||
|
public bool MarkRead(string owner, string id, bool read)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var item = GetMessage(owner, id);
|
||||||
|
if (item is null) return false;
|
||||||
|
item.Unread = !read;
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SetStar(string owner, string id, bool starred)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var item = GetMessage(owner, id);
|
||||||
|
if (item is null) return false;
|
||||||
|
item.Starred = starred;
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MoveMessage(string owner, string id, string folder)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var item = GetMessage(owner, id);
|
||||||
|
if (item is null) return false;
|
||||||
|
item.Folder = folder;
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
version++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>删除邮件。permanent=false 时只移到垃圾箱。</summary>
|
||||||
|
public bool DeleteMessage(string owner, string id, bool permanent)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var item = GetMessage(owner, id);
|
||||||
|
if (item is null) return false;
|
||||||
|
if (permanent)
|
||||||
|
{
|
||||||
|
messages.Remove(item);
|
||||||
|
TryDelete(Path.Combine(config.DataDirectory, item.RawPath));
|
||||||
|
foreach (var attachment in item.Attachments) TryDelete(Path.Combine(config.DataDirectory, attachment.StoredAs));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item.Folder = "trash";
|
||||||
|
}
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
version++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text,
|
||||||
|
byte[] raw, string cc = "", string html = "", string inReplyTo = "",
|
||||||
|
IReadOnlyList<Attachment>? attachments = null)
|
||||||
|
{
|
||||||
|
var message = new MailMessage
|
||||||
|
{
|
||||||
|
OwnerEmail = owner,
|
||||||
|
Folder = "sent",
|
||||||
|
From = owner,
|
||||||
|
To = string.Join(", ", recipients),
|
||||||
|
Cc = cc ?? "",
|
||||||
|
Subject = subject,
|
||||||
|
Text = text,
|
||||||
|
Html = html ?? "",
|
||||||
|
InReplyTo = inReplyTo ?? "",
|
||||||
|
DeliveryStatus = "queued",
|
||||||
|
Unread = false,
|
||||||
|
Attachments = attachments?.ToList() ?? [],
|
||||||
|
Size = raw.Length,
|
||||||
|
};
|
||||||
|
message.RawPath = SaveRaw(raw);
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (message.Uid == 0) message.Uid = NextUidLocked(owner, "sent");
|
||||||
|
messages.Add(message);
|
||||||
|
foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
queue.Add(new QueueItem { MessageId = message.Id, OwnerEmail = owner, Recipients = [recipient] });
|
||||||
|
}
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
Write(queuePath, queue);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>生成一封本地退信(投递彻底失败时发给发件人自己)。</summary>
|
||||||
|
public MailMessage CreateBounce(string owner, string originalSubject, string[] recipients, string error, string originalRawPath)
|
||||||
|
{
|
||||||
|
var subject = $"退信:无法投递到 {string.Join(", ", recipients)}";
|
||||||
|
var text = $"""
|
||||||
|
你的邮件未能投递成功。
|
||||||
|
|
||||||
|
原始主题:{originalSubject}
|
||||||
|
收件人 :{string.Join(", ", recipients)}
|
||||||
|
失败原因:{error}
|
||||||
|
时间 :{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}
|
||||||
|
|
||||||
|
这封退信由 {config.Hostname} 自动生成。原邮件仍保留在「已发送」中,可稍后重试。
|
||||||
|
""";
|
||||||
|
var raw = Mime.Build(new ComposeRequest(
|
||||||
|
config.AdminEmail, "邮件系统", [owner], [], subject, text), config);
|
||||||
|
|
||||||
|
var message = new MailMessage
|
||||||
|
{
|
||||||
|
OwnerEmail = owner,
|
||||||
|
Folder = "inbox",
|
||||||
|
From = $"{config.Hostname} <{config.AdminEmail}>",
|
||||||
|
To = owner,
|
||||||
|
Subject = subject,
|
||||||
|
Text = text,
|
||||||
|
DeliveryStatus = "received",
|
||||||
|
Unread = true,
|
||||||
|
};
|
||||||
|
message.RawPath = SaveRaw(raw);
|
||||||
|
message.Size = raw.Length;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (message.Uid == 0) message.Uid = NextUidLocked(owner, "inbox");
|
||||||
|
messages.Add(message);
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 队列
|
||||||
|
|
||||||
|
public IReadOnlyList<QueueItem> TakeDueQueue(int limit)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var due = queue
|
||||||
|
.Where(x => (x.Status is "pending" or "retry") && x.NextAttempt <= DateTimeOffset.UtcNow)
|
||||||
|
.OrderBy(x => x.NextAttempt)
|
||||||
|
.Take(limit)
|
||||||
|
.ToArray();
|
||||||
|
foreach (var item in due) item.Status = "processing";
|
||||||
|
if (due.Length > 0) Write(queuePath, queue);
|
||||||
|
return due;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompleteQueue(QueueItem item)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
item.Status = "sent";
|
||||||
|
item.LastAttemptAt = DateTimeOffset.UtcNow;
|
||||||
|
item.LastError = "";
|
||||||
|
var message = GetById(item.MessageId);
|
||||||
|
if (message is not null)
|
||||||
|
{
|
||||||
|
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
|
||||||
|
message.DeliveryStatus = remaining ? "queued" : "sent";
|
||||||
|
if (!remaining) message.LastError = "";
|
||||||
|
}
|
||||||
|
Write(queuePath, queue);
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FailQueue(QueueItem item, Exception error, RetryConfig retry, out bool gaveUp)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
item.Attempts++;
|
||||||
|
item.LastAttemptAt = DateTimeOffset.UtcNow;
|
||||||
|
item.LastError = error.Message;
|
||||||
|
item.LastCode = error is SmtpDeliveryException smtp ? smtp.Code : 0;
|
||||||
|
|
||||||
|
var permanent = error is SmtpDeliveryException { Permanent: true };
|
||||||
|
var maxAttempts = permanent && retry.RetryOnPermanentFailure
|
||||||
|
? Math.Max(1, retry.MaxAttemptsForPermanent)
|
||||||
|
: Math.Max(1, retry.MaxAttempts);
|
||||||
|
|
||||||
|
gaveUp = item.Attempts >= maxAttempts;
|
||||||
|
item.Status = gaveUp ? "failed" : "retry";
|
||||||
|
|
||||||
|
// 指数退避,带上限
|
||||||
|
var delaySeconds = Math.Min(retry.MaxDelaySeconds, retry.InitialDelaySeconds * Math.Pow(2, item.Attempts - 1));
|
||||||
|
item.NextAttempt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
|
||||||
|
|
||||||
|
var message = GetById(item.MessageId);
|
||||||
|
if (message is not null)
|
||||||
|
{
|
||||||
|
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
|
||||||
|
message.DeliveryStatus = gaveUp && !remaining ? "failed" : "queued";
|
||||||
|
message.LastError = error.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Write(queuePath, queue);
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RetryQueueItem(string owner, string queueId)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var item = queue.FirstOrDefault(x => x.Id.Equals(queueId, StringComparison.OrdinalIgnoreCase) && x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (item is null) return;
|
||||||
|
item.Status = "pending";
|
||||||
|
item.Attempts = 0;
|
||||||
|
item.NextAttempt = DateTimeOffset.UtcNow;
|
||||||
|
item.LastError = "";
|
||||||
|
Write(queuePath, queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<QueueItem> ListQueue(string owner) =>
|
||||||
|
queue.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(x => x.CreatedAt).ToArray();
|
||||||
|
|
||||||
|
public object Stats(string owner)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var mine = messages.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
inbox = mine.Count(x => x.Folder == "inbox"),
|
||||||
|
unread = mine.Count(x => x.Folder == "inbox" && x.Unread),
|
||||||
|
starred = mine.Count(x => x.Starred && x.Folder != "trash"),
|
||||||
|
drafts = mine.Count(x => x.Folder == "drafts"),
|
||||||
|
sent = mine.Count(x => x.Folder == "sent"),
|
||||||
|
trash = mine.Count(x => x.Folder == "trash"),
|
||||||
|
queue = queue.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Status is "pending" or "retry" or "processing"),
|
||||||
|
failed = mine.Count(x => x.DeliveryStatus == "failed"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 密码
|
||||||
|
|
||||||
|
private static string HashPassword(string password, string salt) =>
|
||||||
|
Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, Convert.FromBase64String(salt), 120_000, HashAlgorithmName.SHA256, 32));
|
||||||
|
|
||||||
|
private static bool VerifyPassword(string password, string hash, string salt)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(hash), Convert.FromBase64String(HashPassword(password, salt)));
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>纯文件实现没有需要释放的资源(保留以满足统一接口)。</summary>
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件存储的统一接口。
|
||||||
|
///
|
||||||
|
/// 之所以要抽出接口:v2.0.x 只有 <see cref="FileStore"/> 一种实现,任何一次改动
|
||||||
|
/// (哪怕只是把一封邮件标记为已读)都要把**全部邮件**重新序列化并整文件重写,
|
||||||
|
/// 且 UID 分配、搜索、统计全是 O(N) 全表扫描。引入 <see cref="SqliteStore"/> 后
|
||||||
|
/// 上层(API / IMAP / SMTP / 投递队列)不需要知道底下是 JSON 还是 SQLite。
|
||||||
|
/// </summary>
|
||||||
|
public interface IMailStore : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>每次写入自增,供长轮询判断「有没有新变化」。</summary>
|
||||||
|
long Version { get; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 用户
|
||||||
|
MailUser? FindUser(string email);
|
||||||
|
/// <summary>
|
||||||
|
/// 不看过滤 active 的查号。用途:① 投递(信箱先存在、访问才受控);
|
||||||
|
/// ② 登录时区分「密码错」与「账号未激活/已停用」(真实原因只进审计)。
|
||||||
|
/// </summary>
|
||||||
|
MailUser? FindUserAnyState(string email);
|
||||||
|
MailUser? Authenticate(string email, string password);
|
||||||
|
bool IsLocalAddress(string email);
|
||||||
|
IReadOnlyList<MailUser> ListUsers();
|
||||||
|
MailUser CreateUser(string email, string password, string displayName);
|
||||||
|
/// <summary>用已经算好的哈希建号(注册验证通过时用,避免明文密码再走一遍内存)。</summary>
|
||||||
|
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);
|
||||||
|
/// <summary>
|
||||||
|
/// 彻底删除一个账号:用户行 + 它的会话 + 验证码 + 它名下的出站队列。
|
||||||
|
/// **邮件不在这里删** —— 调用方要先 ListMessages + DeleteMessage(permanent:true) 逐封删,
|
||||||
|
/// 那样才会顺带回收无引用的大对象。审计保留(删除动作本身也会写一条审计)。
|
||||||
|
/// </summary>
|
||||||
|
bool DeleteUser(string email);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 会话
|
||||||
|
SessionRecord CreateSession(string email, int days);
|
||||||
|
SessionRecord? GetSession(string? token);
|
||||||
|
void RemoveSession(string token);
|
||||||
|
/// <summary>列出某个账号的全部活跃会话(用于「在哪登录了 / 退出其他设备」)。</summary>
|
||||||
|
IReadOnlyList<SessionRecord> ListSessions(string email);
|
||||||
|
/// <summary>吊销该账号的会话;keepToken 非空时保留它(即「退出其他设备」)。返回吊销数量。</summary>
|
||||||
|
int RemoveSessions(string email, string? keepToken);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 账号体系(注册 / 验证码 / 审计)
|
||||||
|
/// <summary>把用户资料写回存储(目前只有显示名)。</summary>
|
||||||
|
void UpdateProfile(string email, string displayName);
|
||||||
|
/// <summary>记录一次成功登录时间。</summary>
|
||||||
|
void SetLastLogin(string email);
|
||||||
|
|
||||||
|
/// <summary>保存验证码(同一 email+purpose 覆盖旧的)。只存哈希。</summary>
|
||||||
|
void SaveVerificationCode(VerificationCode code);
|
||||||
|
VerificationCode? FindVerificationCode(string email, string purpose);
|
||||||
|
/// <summary>验证码试错次数 +1,返回自增后的次数。</summary>
|
||||||
|
int IncrementVerificationAttempts(string email, string purpose);
|
||||||
|
void RemoveVerificationCode(string email, string purpose);
|
||||||
|
|
||||||
|
/// <summary>写一条认证审计。</summary>
|
||||||
|
void RecordAuthEvent(AuthEvent entry);
|
||||||
|
/// <summary>按条件查审计(都为 null 表示不限制);limit 为 0 时用实现自己的默认上限。</summary>
|
||||||
|
IReadOnlyList<AuthEvent> ListAuthEvents(string? email, string? ip, string? reason, int limit);
|
||||||
|
/// <summary>统计窗口期内的认证事件数量(登录锁定、注册与重发限流都用它)。</summary>
|
||||||
|
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<Attachment>? attachments = null);
|
||||||
|
MailMessage CreateBounce(string owner, string originalSubject, string[] recipients, string error, string originalRawPath);
|
||||||
|
IReadOnlyList<MailMessage> ListMessages(string owner, string folder, string query);
|
||||||
|
/// <summary>分页查询:只取需要的一页(SQLite 直接下推到 SQL,不再把整个邮箱读进内存)。</summary>
|
||||||
|
(int Total, IReadOnlyList<MailMessage> 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<MailMessage> 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<QueueItem> 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<QueueItem> ListQueue(string owner);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 维护
|
||||||
|
IReadOnlyList<MailMessage> AllMessages();
|
||||||
|
IReadOnlyList<MailUser> AllUsers();
|
||||||
|
/// <summary>把内存中的改动落盘。JSON 实现会整文件重写;SQLite 实现是空操作(写入即提交)。</summary>
|
||||||
|
void Persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>存储占用统计。</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>正文文本占用的字节数(text_body + html_body),用于判断「重复存储」的成本。</summary>
|
||||||
|
public long TextBytes { get; set; }
|
||||||
|
/// <summary>数据库空闲页字节数(未 VACUUM 时会被计入文件大小)。</summary>
|
||||||
|
public long FreeBytes { get; set; }
|
||||||
|
/// <summary>数据库实际使用的页字节数(page_count × page_size)。</summary>
|
||||||
|
public long TotalBytes { get; set; }
|
||||||
|
/// <summary>文件高水位(含已回收但未归还操作系统的空间)。</summary>
|
||||||
|
public long PageCount { get; set; }
|
||||||
|
public long PageSize { get; set; }
|
||||||
|
/// <summary>最大一封邮件的正文长度与其主题(排查「空间被谁吃了」)。</summary>
|
||||||
|
public long LargestTextBytes { get; set; }
|
||||||
|
public string LargestTextSubject { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>文件夹常量与名称归一化(IMAP 与 API 共用)。</summary>
|
||||||
|
public static class MailFolders
|
||||||
|
{
|
||||||
|
/// <summary>IMAP 用的文件夹列表(固定集合,未使用也返回,便于客户端订阅)。</summary>
|
||||||
|
public static readonly string[] ImapFolders = ["inbox", "sent", "drafts", "archive", "trash", "spam"];
|
||||||
|
|
||||||
|
/// <summary>把客户端给的各种写法(含中文别名)归一化成内部文件夹名;无法识别返回 null。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 入站邮件身份校验:SPF(RFC 7208)、DKIM 验签(RFC 6376)、DMARC(RFC 7489)。
|
||||||
|
///
|
||||||
|
/// 为什么要有这一层:在此之前谁都能用 `From: [email protected]` 给这台服务器发信,
|
||||||
|
/// 服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有任何区别。
|
||||||
|
///
|
||||||
|
/// 设计取舍:
|
||||||
|
/// 1. **默认只标注不拒收**(`RejectOnDmarcReject=false`):校验实现自己也可能有 bug,
|
||||||
|
/// 拒收是不可逆的,投进垃圾箱是可逆的。DMARC 判失败时按策略投 spam。
|
||||||
|
/// 2. **DNS 查询做成可注入的**(<see cref="IDnsLookup"/>):SPF/DKIM 的判定逻辑必须能
|
||||||
|
/// 用固定记录做确定性自检,否则自检依赖外网、结果不可复现。
|
||||||
|
/// 3. DNS 查询次数按 RFC 限制(SPF 10 次、void 2 次),避免成为放大攻击的靶子。
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>可直接前置到报文里的 <c>Authentication-Results</c> 行(含 CRLF)。</summary>
|
||||||
|
public string HeaderBlock { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DNS 查询抽象:真实实现走 UDP/系统解析器,自检用固定记录的实现。</summary>
|
||||||
|
public interface IDnsLookup
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<string>> TxtAsync(string name, CancellationToken token);
|
||||||
|
Task<IReadOnlyList<string>> AddressesAsync(string name, CancellationToken token);
|
||||||
|
Task<IReadOnlyList<string>> MxAsync(string name, CancellationToken token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>真实 DNS:TXT 自己发 UDP 查询(系统解析器拿不到 TXT),A 用系统解析,MX 复用既有的 MxResolver。</summary>
|
||||||
|
public sealed class UdpDnsLookup : IDnsLookup
|
||||||
|
{
|
||||||
|
private readonly AppConfig config;
|
||||||
|
public UdpDnsLookup(AppConfig config) => this.config = config;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> 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<IReadOnlyList<string>> 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<IReadOnlyList<string>> MxAsync(string name, CancellationToken token)
|
||||||
|
{
|
||||||
|
try { return await MxResolver.ResolveAsync(name, config.DirectDelivery, token); }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<IPAddress> 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<IPAddress>();
|
||||||
|
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<string> Txt, IReadOnlyList<string> 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<string> Txt, IReadOnlyList<string> Cname) ParseTxtResponse(byte[] data, ushort expectedId)
|
||||||
|
{
|
||||||
|
var records = new List<string>();
|
||||||
|
var aliases = new List<string>();
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SPF 求值(RFC 7208 的常用子集:all/include/a/mx/ip4/ip6/exists + 限定符 + redirect)。</summary>
|
||||||
|
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<Result> 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<bool> 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<IReadOnlyList<string>> TxtAsync(string name)
|
||||||
|
{
|
||||||
|
var r = await SafeTxtAsync(name);
|
||||||
|
if (r.Count == 0 && ++voids > 2) return r;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>> SafeTxtAsync(string name)
|
||||||
|
{
|
||||||
|
try { return await dns.TxtAsync(name.TrimEnd('.'), token); }
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>> SafeAddressesAsync(string name)
|
||||||
|
{
|
||||||
|
try { return await dns.AddressesAsync(name.TrimEnd('.'), token); }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>> SafeMxAsync(string name)
|
||||||
|
{
|
||||||
|
try { return await dns.MxAsync(name.TrimEnd('.'), token); }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> 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<string> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SPF 宏的常用子集(%{d} %{s} %{o} %{i} %{h})。</summary>
|
||||||
|
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)..];
|
||||||
|
// 可能是 `[email protected]>` 或 `[email protected] (注释)` —— 截到第一个分隔符
|
||||||
|
var stop = domain.IndexOfAny(['>', ' ', '\t', ')', ',', ';', '"']);
|
||||||
|
if (stop >= 0) domain = domain[..stop];
|
||||||
|
return domain.Trim().TrimEnd('.').ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DKIM 验签(RFC 6376)。**独立按 RFC 实现,不复用签名端代码** ——
|
||||||
|
/// 2026-09-13 那次 DKIM 顺序 bug 的教训就是「自己验自己」会一起错。
|
||||||
|
/// </summary>
|
||||||
|
public static class DkimVerifier
|
||||||
|
{
|
||||||
|
public sealed record Result(string Outcome, string Domain, string Selector, string Detail);
|
||||||
|
|
||||||
|
public static async Task<IReadOnlyList<Result>> VerifyAllAsync(byte[] raw, IDnsLookup dns, CancellationToken token)
|
||||||
|
{
|
||||||
|
var text = Encoding.Latin1.GetString(raw); // 1 字节 ↔ 1 字符,索引即字节偏移
|
||||||
|
var headers = ParseHeaders(text);
|
||||||
|
var results = new List<Result>();
|
||||||
|
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<Result> VerifyOneAsync(string text, List<Header> 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<string?> 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<Header> ParseHeaders(string text)
|
||||||
|
{
|
||||||
|
var list = new List<Header>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把 b= 的值抹掉(验签输入里的 DKIM-Signature 头不能带签名本身)。
|
||||||
|
///
|
||||||
|
/// ⚠ 必须**按标签边界**找 b=:直接 IndexOf("b=") 会被前面的 `bh=` 的 base64 内容误伤
|
||||||
|
/// (base64 以 `b=` 结尾完全合法),一位之差就整封验不过 —— 自检里正是这一条抓出来的。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>返回名为 name 的标签「值」的起始下标(-1 表示没有)。标签必须出现在 `;` 之后或开头。</summary>
|
||||||
|
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<string>();
|
||||||
|
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] + "…";
|
||||||
|
|
||||||
|
/// <summary>解析 `k=v; k=v` 形式的标签(DKIM 签名头与 DNS 公钥记录共用)。</summary>
|
||||||
|
public static Dictionary<string, string> ParseTags(string text)
|
||||||
|
{
|
||||||
|
var tags = new Dictionary<string, string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DMARC 求值(RFC 7489 的常用子集)。</summary>
|
||||||
|
public static class Dmarc
|
||||||
|
{
|
||||||
|
public sealed record Result(string Outcome, string Policy, string Domain, string Detail);
|
||||||
|
|
||||||
|
/// <summary>公共后缀的常用子集(判断「组织域」用;没列到的按最后两段算)。</summary>
|
||||||
|
private static readonly HashSet<string> 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>组织域(relaxed 对齐用):`mail.example.co.uk` → `example.co.uk`。</summary>
|
||||||
|
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<Result> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把三件事串起来:SPF → DKIM → DMARC,产出可写进报文的结论与是否判为垃圾。</summary>
|
||||||
|
public static class InboundAuth
|
||||||
|
{
|
||||||
|
public static async Task<InboundAuthVerdict> 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<string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把校验收到的头前置到报文最前面(不碰原有字节,避免破坏对方 DKIM 签名)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一次性数据迁移(用法:WpywMail.Native.exe --migrate)。
|
||||||
|
///
|
||||||
|
/// 1.x 版本有两个存储层缺陷:
|
||||||
|
/// · Mime.Parse 不解码 RFC 2047 编码字,也不解 base64/QP 正文 ——
|
||||||
|
/// 导致 messages.json 里的主题是「=?utf-8?b?...?=」、正文是 base64 乱码;
|
||||||
|
/// · 换域名后历史邮件的 ownerEmail 仍指向旧域名,登录新账号后看不到。
|
||||||
|
/// 本迁移用新解析器重新解析 raw/*.eml 修好字段,并把旧域名归到当前配置的账号下。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>一个解析出来的附件。</summary>
|
||||||
|
public sealed record ParsedAttachment(string FileName, string ContentType, byte[] Data, string ContentId, bool Inline);
|
||||||
|
|
||||||
|
/// <summary>解析结果。Text / Html 已经解码成可直接展示的字符串。</summary>
|
||||||
|
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<ParsedAttachment> Attachments);
|
||||||
|
|
||||||
|
/// <summary>待发送的附件。</summary>
|
||||||
|
public sealed record OutgoingAttachment(string FileName, string ContentType, byte[] Data, string ContentId = "", bool Inline = false);
|
||||||
|
|
||||||
|
/// <summary>组装一封待发送邮件的入参。</summary>
|
||||||
|
public sealed record ComposeRequest(
|
||||||
|
string From,
|
||||||
|
string? FromDisplay,
|
||||||
|
string[] To,
|
||||||
|
string[] Cc,
|
||||||
|
string Subject,
|
||||||
|
string Text,
|
||||||
|
string? Html = null,
|
||||||
|
IReadOnlyList<OutgoingAttachment>? Attachments = null,
|
||||||
|
string? MessageId = null,
|
||||||
|
string InReplyTo = "",
|
||||||
|
string References = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MIME 组装与解析。
|
||||||
|
///
|
||||||
|
/// 相比 v1 的关键修正:
|
||||||
|
/// 1. 组装:Message-ID 的域来自配置(不再写死),正文与附件一律 base64,
|
||||||
|
/// 非 ASCII 头一律 RFC 2047 编码并按 75 字符上限切分。
|
||||||
|
/// 2. 解析:正确解码 RFC 2047 编码字、Content-Transfer-Encoding(base64/QP)、
|
||||||
|
/// charset(含 GBK/GB18030),并支持 multipart 与附件。
|
||||||
|
/// </summary>
|
||||||
|
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<KeyValuePair<string, string>> 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<ParsedAttachment>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把原始字节切成「头(已展开)」与「正文」。</summary>
|
||||||
|
internal static (List<KeyValuePair<string, string>> 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<KeyValuePair<string, string>>();
|
||||||
|
string? currentName = null;
|
||||||
|
var currentValue = new StringBuilder();
|
||||||
|
void Flush()
|
||||||
|
{
|
||||||
|
if (currentName is not null) headers.Add(new KeyValuePair<string, string>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解码头部块。
|
||||||
|
///
|
||||||
|
/// 头部按标准应当是 ASCII(非 ASCII 必须用 RFC 2047 编码字),但现实中不少客户端
|
||||||
|
/// 直接把裸 UTF-8 写进头里(8bit 头)。这里优先按 UTF-8 严格解码,失败再回退
|
||||||
|
/// Latin-1(保证字节不丢);否则裸 UTF-8 的中文主题会变成 «æµè¯» 这种乱码。
|
||||||
|
/// </summary>
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按 boundary 切分 multipart 正文。返回每个子部分的原始字节。</summary>
|
||||||
|
private static List<byte[]> SplitMultipart(byte[] body, string boundary)
|
||||||
|
{
|
||||||
|
var parts = new List<byte[]>();
|
||||||
|
var delimiter = Encoding.ASCII.GetBytes("--" + boundary);
|
||||||
|
var positions = new List<int>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解码 Content-Transfer-Encoding。</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 头编码
|
||||||
|
|
||||||
|
/// <summary>解码 RFC 2047 编码字;相邻编码字之间的空白会被丢弃。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解码 RFC 2231 参数值(形如 UTF-8''%E4%B8%AD%文)。</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>对外暴露的 RFC 2047 编码入口(IMAP ENVELOPE 等需要把非 ASCII 头值变成 ASCII)。</summary>
|
||||||
|
public static string EncodeHeaderValue(string value) =>
|
||||||
|
EncodeHeader(value ?? "").Replace("\r\n", " ").Replace("\n", " ");
|
||||||
|
|
||||||
|
/// <summary>需要时把文本编码成 RFC 2047 编码字(按 75 字符上限切分)。</summary>
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解析地址列表,抽出纯地址。</summary>
|
||||||
|
public static string[] Addresses(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return [];
|
||||||
|
var result = new List<string>();
|
||||||
|
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<string, string> Parameters) ParseContentType(string value)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, string>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按 charset 解码字节;未知字符集回退 UTF-8。</summary>
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>作为 multipart/mixed 的子部分时,必须带上自己的 Content-Type 头。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>附件名参数:ASCII 回退 + RFC 2231 扩展写法,保证中文文件名不乱码。</summary>
|
||||||
|
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}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>RFC 5322 日期(必须是 ±HHMM 形式的时区)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可选注册 .NET 的代码页编码提供程序(用于 GBK/GB18030)。
|
||||||
|
/// 未引用 System.Text.Encoding.CodePages 包时静默降级为 UTF-8。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>应用配置。对应 appsettings.json 的根对象。</summary>
|
||||||
|
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; } = "";
|
||||||
|
|
||||||
|
/// <summary>direct = 按 MX 直接投递;relay = 走上游 SMTP 中继。</summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>启动时做基本校验,尽早暴露配置错误。</summary>
|
||||||
|
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 之间。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 账号体系配置:自助注册策略、邮箱验证、密码强度、登录锁定、限流。
|
||||||
|
///
|
||||||
|
/// 默认值刻意偏保守:**注册默认 invite(需要邀请码)**,且注册的邮箱域名默认只允许
|
||||||
|
/// 服务器自己的 Domain —— 公网上的邮件服务器一旦开放注册,很快就会变成垃圾邮件跳板。
|
||||||
|
/// 要真正开放,请显式改 Registration=open 并配置 AllowedDomains。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AccountsConfig
|
||||||
|
{
|
||||||
|
/// <summary>open = 任何人可注册;invite = 需要邀请码;closed = 关闭注册(只能管理员建号)。</summary>
|
||||||
|
public string Registration { get; set; } = "invite";
|
||||||
|
|
||||||
|
/// <summary>invite 模式下的邀请码。</summary>
|
||||||
|
public string InviteCode { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>允许注册的邮箱域名(含服务器自身域名)。留空表示只允许 Domain。</summary>
|
||||||
|
public string[] AllowedDomains { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>注册后是否必须用邮箱里的验证码激活(强烈建议 true)。</summary>
|
||||||
|
public bool RequireEmailVerification { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>密码最小长度(同时会检查:不能是纯数字、不能与邮箱相同)。</summary>
|
||||||
|
public int MinPasswordLength { get; set; } = 12;
|
||||||
|
|
||||||
|
/// <summary>验证码有效期(分钟)。</summary>
|
||||||
|
public int CodeMinutes { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>同一个验证码最多尝试几次(超过即作废,需重新获取)。</summary>
|
||||||
|
public int MaxCodeAttempts { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>同一账号在窗口期内连续登录失败多少次后锁定。</summary>
|
||||||
|
public int MaxLoginFailures { get; set; } = 8;
|
||||||
|
|
||||||
|
/// <summary>登录失败统计窗口与锁定时长(分钟)。</summary>
|
||||||
|
public int LockoutMinutes { get; set; } = 15;
|
||||||
|
|
||||||
|
/// <summary>同一 IP 每小时最多发起几次注册 / 重发验证码(防刷)。</summary>
|
||||||
|
public int RegisterPerHourPerIp { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>同一邮箱每小时最多重发几次验证码。</summary>
|
||||||
|
public int ResendPerHourPerEmail { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>审计日志最多保留多少条(超出后按时间淘汰)。</summary>
|
||||||
|
public int AuditLimit { get; set; } = 2000;
|
||||||
|
|
||||||
|
/// <summary>把配置里的域名规则解析成实际允许的域名集合。</summary>
|
||||||
|
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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 存储后端选择。
|
||||||
|
///
|
||||||
|
/// - json :v2.0.x 的原始实现,users/messages/queue/sessions 各一个 JSON 文件,
|
||||||
|
/// **任何一次改动都会整文件重写**,随邮件量增长呈 O(N) 放大。
|
||||||
|
/// - sqlite :SQLite 单文件数据库(元数据 + 索引 + 事务),原始报文仍落在 raw/ 目录。
|
||||||
|
/// 默认值,也是推荐值;改回 json 即可一键回滚(两套数据互不覆盖)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StorageConfig
|
||||||
|
{
|
||||||
|
public string Provider { get; set; } = "sqlite";
|
||||||
|
|
||||||
|
/// <summary>SQLite 数据库文件路径;留空则用 DataDirectory/wpywmail.db。</summary>
|
||||||
|
public string DatabasePath { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>WAL 模式下定期检查点阈值(页数),0 表示交给 SQLite 默认策略。</summary>
|
||||||
|
public int WalAutoCheckpointPages { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否为正文建立 FTS5(trigram)全文索引。
|
||||||
|
/// 打开后搜索从「全表 LIKE 扫描」变成索引命中,代价是**索引本身会额外占用接近正文大小的磁盘**
|
||||||
|
/// (trigram 索引通常与正文同量级)。默认关闭,因为本机磁盘偏紧、而 LIKE 在数千封量级仍是毫秒级。
|
||||||
|
/// </summary>
|
||||||
|
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; } = "";
|
||||||
|
/// <summary>投递时使用的 HELO 名称,留空则用 Hostname。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>失败重投策略。4xx(临时)与 5xx(永久)分开处理。</summary>
|
||||||
|
public sealed class RetryConfig
|
||||||
|
{
|
||||||
|
public int MaxAttempts { get; set; } = 12;
|
||||||
|
public int InitialDelaySeconds { get; set; } = 60;
|
||||||
|
public int MaxDelaySeconds { get; set; } = 3600;
|
||||||
|
/// <summary>5xx 默认也重试若干次:封锁/策略类 5xx 往往是临时的。</summary>
|
||||||
|
public bool RetryOnPermanentFailure { get; set; } = true;
|
||||||
|
public int MaxAttemptsForPermanent { get; set; } = 3;
|
||||||
|
/// <summary>彻底失败时给发件人投递退信(NDR)。</summary>
|
||||||
|
public bool SendBounceNotification { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DKIM 签名配置。私钥不存在时会自动生成并打印需要配置的 DNS 记录。</summary>
|
||||||
|
public sealed class DkimConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string Selector { get; set; } = "mail";
|
||||||
|
/// <summary>留空则用 Domain。</summary>
|
||||||
|
public string SigningDomain { get; set; } = "";
|
||||||
|
/// <summary>留空则放在 DataDirectory/dkim/<selector>.private.pem。</summary>
|
||||||
|
public string PrivateKeyPath { get; set; } = "";
|
||||||
|
public string[] Headers { get; set; } =
|
||||||
|
["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type", "Content-Transfer-Encoding"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 入站邮件身份校验(SPF / DKIM / DMARC)与垃圾邮件判定。
|
||||||
|
///
|
||||||
|
/// 默认策略:**标注 + 投垃圾箱,不拒收** —— 校验实现自身也可能有 bug,拒收不可逆,
|
||||||
|
/// 投进垃圾箱可逆。要严格拒收把 <see cref="RejectOnDmarcReject"/> 打开。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InboundAuthConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
/// <summary>是否往报文里写 Authentication-Results / X-Spam-Score 头(标准做法,保留证据)。</summary>
|
||||||
|
public bool AddAuthenticationResults { get; set; } = true;
|
||||||
|
/// <summary>判定为垃圾时投进 spam 文件夹而不是收件箱。</summary>
|
||||||
|
public bool SpamFolderOnFail { get; set; } = true;
|
||||||
|
/// <summary>DMARC p=reject 且校验失败时直接在 SMTP 阶段 550 拒收。默认关(怕误杀)。</summary>
|
||||||
|
public bool RejectOnDmarcReject { get; set; } = false;
|
||||||
|
/// <summary>DKIM 验签(含 DNS 取公钥)开关;关掉只做 SPF/DMARC 的 SPF 部分。</summary>
|
||||||
|
public bool VerifyDkim { get; set; } = true;
|
||||||
|
/// <summary>判为垃圾的分数阈值(DMARC 失败固定 +4)。</summary>
|
||||||
|
public int SpamScoreThreshold { get; set; } = 3;
|
||||||
|
public int DnsTimeoutSeconds { get; set; } = 5;
|
||||||
|
/// <summary>SPF 的 DNS 查询次数上限(RFC 7208 规定 10)。</summary>
|
||||||
|
public int MaxSpfLookups { get; set; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApiConfig
|
||||||
|
{
|
||||||
|
public int SessionDays { get; set; } = 30;
|
||||||
|
/// <summary>允许的跨域来源;默认 * 便于本机客户端调试,公网使用建议收紧。</summary>
|
||||||
|
public string CorsOrigin { get; set; } = "*";
|
||||||
|
/// <summary>推送新邮件的长轮询上限(秒)。</summary>
|
||||||
|
public int LongPollSeconds { get; set; } = 25;
|
||||||
|
/// <summary>
|
||||||
|
/// 可选的公网 HTTPS 前缀(例如 <c>https://mail.example.com:9443/</c>),只为客户端在公网
|
||||||
|
/// 自助注册 / 找回密码 / 管理会话资料而开。**只放行账号类接口**,邮件读写与管理接口不在这里暴露。
|
||||||
|
/// 留空 = 不开(默认)。HTTPS 前缀必须先绑定证书:<c>netsh http add sslcert hostnameport=mail.example.com:9443 ...</c>
|
||||||
|
/// </summary>
|
||||||
|
public string PublicPrefix { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>IMAP 服务配置(让标准邮件客户端也能接入)。</summary>
|
||||||
|
public sealed class ImapConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
/// <summary>143:明文 + STARTTLS。</summary>
|
||||||
|
public int Port { get; set; } = 143;
|
||||||
|
/// <summary>993:隐式 TLS。设为 0 表示不监听。</summary>
|
||||||
|
public int TlsPort { get; set; } = 993;
|
||||||
|
/// <summary>是否要求先建立 TLS 才允许 LOGIN(推荐 true)。</summary>
|
||||||
|
public bool RequireTlsForLogin { get; set; } = true;
|
||||||
|
/// <summary>允许未加密登录的来源地址(默认仅本机,便于自检/调试)。</summary>
|
||||||
|
public string[] PlaintextLoginAllowFrom { get; set; } = ["127.0.0.1", "::1"];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SmtpConfig
|
||||||
|
{
|
||||||
|
/// <summary>单封邮件最大字节数。</summary>
|
||||||
|
public int MaxMessageBytes { get; set; } = 25 * 1024 * 1024;
|
||||||
|
/// <summary>是否始终广告 STARTTLS(只要加载到证书就广告,含自签名)。</summary>
|
||||||
|
public bool AdvertiseStartTls { get; set; } = true;
|
||||||
|
/// <summary>25 端口也允许 AUTH(默认否;587 端口始终允许)。</summary>
|
||||||
|
public bool AllowAuthOnInbound { get; set; }
|
||||||
|
/// <summary>给收到的邮件补 Received 头。</summary>
|
||||||
|
public bool AddReceivedHeader { get; set; } = true;
|
||||||
|
/// <summary>同一 IP 连续认证失败多少次后临时封禁。</summary>
|
||||||
|
public int AuthFailuresBeforeBan { get; set; } = 8;
|
||||||
|
public int BanMinutes { get; set; } = 15;
|
||||||
|
/// <summary>已认证用户是否必须使用自己的地址作为发件人。</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>相对 DataDirectory 的存储路径,例如 attachments/xxx.bin。</summary>
|
||||||
|
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; } = "";
|
||||||
|
/// <summary>inbox / sent / drafts / archive / trash / spam</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>IMAP UID:在同一文件夹内单调递增且稳定,首次入库时分配。</summary>
|
||||||
|
public int Uid { get; set; }
|
||||||
|
/// <summary>received / queued / sent / failed</summary>
|
||||||
|
public string DeliveryStatus { get; set; } = "received";
|
||||||
|
public string LastError { get; set; } = "";
|
||||||
|
public long Size { get; set; }
|
||||||
|
public List<Attachment> Attachments { get; set; } = [];
|
||||||
|
public bool HasAttachments => Attachments.Count > 0;
|
||||||
|
/// <summary>DKIM 是否签名成功(发件侧)。</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>pending / processing / retry / sent / failed</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────── 账号体系
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮箱验证码(注册激活 / 密码重置共用一个表)。
|
||||||
|
///
|
||||||
|
/// 只存**验证码的哈希**,不存明文 —— 数据库被人拿到也不能直接拿来激活账号或改密码。
|
||||||
|
/// 注册场景下,密码的哈希与显示名先暂存在 Payload 里,验证通过后才真正建号,
|
||||||
|
/// 这样「未验证的注册」不会在用户表里留下垃圾数据。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VerificationCode
|
||||||
|
{
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
/// <summary>register = 注册激活;reset = 重置密码。</summary>
|
||||||
|
public string Purpose { get; set; } = "register";
|
||||||
|
public string CodeHash { get; set; } = "";
|
||||||
|
public string Salt { get; set; } = "";
|
||||||
|
/// <summary>register 时是 JSON:{ displayName, passwordHash, passwordSalt }。</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 认证事件审计:登录成功/失败、注册、验证码发送与校验、密码重置、会话吊销。
|
||||||
|
/// 用途有三个:排查问题、登录锁定判定、按 IP/邮箱做限流。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthEvent
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public string Ip { get; set; } = "";
|
||||||
|
/// <summary>login-ok / login-failed / login-locked / register / register-verify / code-sent / reset-ok / session-revoked</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>附件上传:内容用 base64 传递。</summary>
|
||||||
|
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<AttachmentRequest>? Attachments = null);
|
||||||
|
|
||||||
|
public sealed record DraftRequest(string To, string Subject, string Text);
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
public static async Task<int> 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<AppConfig>(
|
||||||
|
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 <email>(例如 --purge-user [email protected])");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按 Storage.Provider 创建存储后端。sqlite 为默认。</summary>
|
||||||
|
public static IMailStore CreateStore(AppConfig config) =>
|
||||||
|
config.Storage.Provider.Equals("json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? new FileStore(config)
|
||||||
|
: new SqliteStore(config);
|
||||||
|
}
|
||||||
@@ -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: <[email protected]>` | 声明域 ≠ 发信域,垃圾邮件特征 | 取配置 `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、加上手写验签脚本全部"通过"——
|
||||||
|
属于典型的**假通过**。真正把它暴露出来的是**外部独立验证器**(把信发给
|
||||||
|
`[email protected]`,报告里明确写着 `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: [email protected]) 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": "[email protected]",
|
||||||
|
"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.<Domain>
|
||||||
|
"SigningDomain": "", // 留空用 Domain
|
||||||
|
"PrivateKeyPath": "", // 留空 = DataDirectory/dkim/<selector>.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 `<SERVER_IP>` 为例:
|
||||||
|
|
||||||
|
| 类型 | 名称 | 值 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A | `mail` | `<SERVER_IP>` | **必须灰云(DNS only)**,MX 指向的主机不能走代理 |
|
||||||
|
| MX | `@` | `mail.example.com`(优先级 10) | |
|
||||||
|
| TXT | `@` | `v=spf1 ip4:<SERVER_IP> -all` | 注意是**半角**冒号;`-all` 比 `~all` 严格 |
|
||||||
|
| TXT | `mail._domainkey` | 服务启动日志里打印的 `v=DKIM1; k=rsa; p=...` | 一字不能改,Cloudflare 会自动分段 |
|
||||||
|
| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:[email protected]` | 先 `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 <token>`
|
||||||
|
- 编码:请求与响应均为 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=<version>
|
||||||
|
→ 挂起至多 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 <token>`;收到 `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` |
|
||||||
|
| 用户名 | 完整邮箱地址,如 `[email protected]` |
|
||||||
|
| 密码 | `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: [email protected]` 给这台服务器发信**,服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有区别。
|
||||||
|
现在收信时依次做:
|
||||||
|
|
||||||
|
| 步骤 | 实现 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 节)。
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自检:不依赖网络与真实邮箱,验证中文编解码、MIME 往返、附件、DKIM 签名可被验证。
|
||||||
|
/// 用法:WpywMail.Native.exe --selftest
|
||||||
|
/// </summary>
|
||||||
|
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 = "[email protected]",
|
||||||
|
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("[email protected]", "王朋友", ["[email protected]"], [], 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("[email protected]", null, ["[email protected]"], [], "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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 裸 UTF-8 头(没有 RFC 2047 编码字)与 8bit 正文。
|
||||||
|
/// 现实中不少客户端这么发;早期版本会把它解成「[æµè¯]」这种乱码。
|
||||||
|
/// </summary>
|
||||||
|
private static void TestRawUtf8Headers()
|
||||||
|
{
|
||||||
|
var raw = Encoding.UTF8.GetBytes(
|
||||||
|
"From: 张三 <[email protected]>\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("[email protected]", null, ["[email protected]"], [],
|
||||||
|
"带附件", "见附件", 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("[email protected]", null, ["[email protected]"], [],
|
||||||
|
"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 校验");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最关键的一致性测试:签名覆盖的字节必须与传输写出的字节完全一致。
|
||||||
|
/// 用一个「裸 LF 行尾 + 以点开头的行」的恶劣报文走完整链路:
|
||||||
|
/// 规范化 → 签名 → DATA 编码(dot-stuffing)→ 收件端还原 → 验签。
|
||||||
|
/// 若签名后才改行尾,或 dot-stuffing 破坏了正文,这里必然失败。
|
||||||
|
/// </summary>
|
||||||
|
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: [email protected]\n" +
|
||||||
|
"To: [email protected]\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("张三 <[email protected]>, [email protected]; \"李四, 五\" <[email protected]>");
|
||||||
|
Check("地址解析(含引号内逗号)", result.Length == 3 && result[0] == "[email protected]" && result[2] == "[email protected]",
|
||||||
|
string.Join(" | ", result));
|
||||||
|
Check("非法地址被过滤", Mime.Addresses("not-an-address, [email protected]").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: [email protected]\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 校验(独立于签名器的实现)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按 RFC 6376 §3.7 第 2 步重建签名输入并验签。
|
||||||
|
/// rfcOrder=false 时故意用「DKIM-Signature 放最前 + 结尾带 CRLF」的**非规范**顺序,
|
||||||
|
/// 仅用于反向对照:非规范顺序绝不能被判为有效。
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 账号体系自检:注册(含验证码)、登录失败锁定、密码重置、会话管理、资料与审计。
|
||||||
|
///
|
||||||
|
/// 这些用例**不经过 HTTP**,直接打 AccountService + 存储层,所以跑得快、
|
||||||
|
/// 也能覆盖「两套存储后端行为一致」这一点(json 与 sqlite 各跑一遍)。
|
||||||
|
/// 验证码是随机的、只能从邮件正文里拿;这里为了可测,直接从存储里读回
|
||||||
|
/// 哈希校验逻辑无法反推明文 —— 所以改为**注入式**:测试里用一个已知码替换哈希。
|
||||||
|
/// </summary>
|
||||||
|
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 = "[email protected]",
|
||||||
|
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", "[email protected]").Ok, "");
|
||||||
|
Check($"[{provider}] 纯数字密码被拒", !accounts.CheckPassword("123456789012", "[email protected]").Ok, "");
|
||||||
|
Check($"[{provider}] 与邮箱相同的密码被拒", !accounts.CheckPassword("[email protected]", "[email protected]").Ok, "");
|
||||||
|
Check($"[{provider}] 合格密码被接受", accounts.CheckPassword("Str0ng-Pass-2026", "[email protected]").Ok, "");
|
||||||
|
|
||||||
|
// ---- 域名白名单
|
||||||
|
Check($"[{provider}] 域名校验:允许 wpy.email",
|
||||||
|
accounts.Register(new RegisterRequest("[email protected]", "Str0ng-Pass-2026", "新人", "TEST-INVITE"), "10.0.0.1", "test").Ok, "");
|
||||||
|
var badDomain = accounts.Register(new RegisterRequest("[email protected]", "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("[email protected]", "Str0ng-Pass-2026", "", "WRONG"), "10.0.0.3", "test");
|
||||||
|
Check($"[{provider}] 邀请码错误被拒", !badInvite.Ok && badInvite.Status == 403, badInvite.Error);
|
||||||
|
|
||||||
|
// ---- 弱密码
|
||||||
|
var weak = accounts.Register(new RegisterRequest("[email protected]", "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("[email protected]") && !accounts.IsHostedHere("[email protected]"), "");
|
||||||
|
|
||||||
|
// ---- 注册 → 验证码 → 激活(外部托管的邮箱:验证码才有意义)
|
||||||
|
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, "[email protected]");
|
||||||
|
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 = "[email protected]";
|
||||||
|
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 = "[email protected]";
|
||||||
|
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 = "[email protected]";
|
||||||
|
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("[email protected]", "10.0.0.11");
|
||||||
|
Check($"[{provider}] 对不存在的邮箱申请重置也返回成功(防枚举)", unknown.Ok, unknown.Error);
|
||||||
|
|
||||||
|
// ---- 关闭注册
|
||||||
|
config.Accounts.Registration = "closed";
|
||||||
|
var closed = accounts.Register(new RegisterRequest("[email protected]", "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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从出站队列里把那封验证码邮件的原文取出来,再解析出 6 位验证码。</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取该收件人最近一封出站邮件的原始字节(用于把「投递」也纳入自检)。</summary>
|
||||||
|
private static byte[]? ReadQueuedRaw(IMailStore store, string email)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var items = new List<QueueItem>();
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 入站校验(SPF / DKIM / DMARC)自检。
|
||||||
|
///
|
||||||
|
/// 关键设计:**DNS 查询走固定记录的桩实现**,所以断言是确定性的、不依赖外网。
|
||||||
|
/// 而 DKIM 部分特意包含「篡改必须失败」的反向用例 —— 2026-09-13 的 DKIM 事故就是
|
||||||
|
/// 「验签和签名犯了同一个错,于是一直假通过」,任何验签实现都必须能被打假才算数。
|
||||||
|
/// </summary>
|
||||||
|
public static partial class SelfTest
|
||||||
|
{
|
||||||
|
private sealed class StubDns : IDnsLookup
|
||||||
|
{
|
||||||
|
public readonly Dictionary<string, string[]> Txt = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public readonly Dictionary<string, string[]> Addresses = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public readonly Dictionary<string, string[]> Mx = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<string>> TxtAsync(string name, CancellationToken token) =>
|
||||||
|
Task.FromResult<IReadOnlyList<string>>(Txt.TryGetValue(name, out var v) ? v : []);
|
||||||
|
public Task<IReadOnlyList<string>> AddressesAsync(string name, CancellationToken token) =>
|
||||||
|
Task.FromResult<IReadOnlyList<string>>(Addresses.TryGetValue(name, out var v) ? v : []);
|
||||||
|
public Task<IReadOnlyList<string>> MxAsync(string name, CancellationToken token) =>
|
||||||
|
Task.FromResult<IReadOnlyList<string>>(Mx.TryGetValue(name, out var v) ? v : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TestInboundAuth()
|
||||||
|
{
|
||||||
|
var config = new AppConfig
|
||||||
|
{
|
||||||
|
Domain = "wpy.email",
|
||||||
|
Hostname = "mail.example.com",
|
||||||
|
AdminEmail = "[email protected]",
|
||||||
|
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", "[email protected]", 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", "[email protected]", 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", "[email protected]", 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", "[email protected]", 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", "[email protected]", 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", "[email protected]", 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", "[email protected]", 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", "[email protected]", dns, cfg, token).Result;
|
||||||
|
Check("SPF:mx 机制按 MX 主机命中", mxMatch.Outcome == "pass", mxMatch.Outcome);
|
||||||
|
|
||||||
|
var missing = Spf.EvaluateAsync("198.51.100.7", "x", "[email protected]", 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", "[email protected]", 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", "[email protected]", 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 <[email protected]>") == "example.com"
|
||||||
|
&& Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "[email protected]", "h") == "ex.com/ex.com/bob",
|
||||||
|
Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "[email protected]", "h"));
|
||||||
|
|
||||||
|
// ─────────────────────────── DKIM(用本机签名器造真实签名,再打假)
|
||||||
|
var signer = DkimSigner.Create(config);
|
||||||
|
Check("DKIM:签名器可用(自检用)", signer is not null, "未启用则跳过后面的验签");
|
||||||
|
if (signer is not null)
|
||||||
|
{
|
||||||
|
var raw = BuildMessage("[email protected]", "收件人", "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("[email protected]", "x", "无签名", "正文"), dns, token).Result;
|
||||||
|
Check("DKIM:没有签名的报文返回空列表", plain.Count == 0, $"{plain.Count} 个");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── DMARC
|
||||||
|
dns.Txt["_dmarc.strict2.example.com"] = ["v=DMARC1; p=reject; rua=mailto:[email protected]"];
|
||||||
|
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("[email protected]", "我", "正常邮件", "正文内容\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", "[email protected]", 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("[email protected]", "我", "我是老板", "把钱转过来"),
|
||||||
|
"198.51.100.7", "evil.example.net", "[email protected]", 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("[email protected]", "我", "我是老板", "把钱转过来"),
|
||||||
|
"198.51.100.7", "evil.example.net", "[email protected]", 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("[email protected]", "我", "x", "y"),
|
||||||
|
"198.51.100.7", "evil.example.net", "[email protected]", config, token, cleanDns).Result
|
||||||
|
.HeaderBlock.Contains("X-Spam-Reason:"), "");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Fail("入站校验自检", $"抛出异常:{ex}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(config.DataDirectory, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>只带一条 DNS 记录的一次性桩(用于个别用例)。</summary>
|
||||||
|
private sealed class StubDnsWithRecords(params (string Name, string Value)[] records) : IDnsLookup
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<string>> TxtAsync(string name, CancellationToken token) =>
|
||||||
|
Task.FromResult<IReadOnlyList<string>>(records.Where(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(r => r.Value).ToArray());
|
||||||
|
public Task<IReadOnlyList<string>> AddressesAsync(string name, CancellationToken token) => Task.FromResult<IReadOnlyList<string>>([]);
|
||||||
|
public Task<IReadOnlyList<string>> MxAsync(string name, CancellationToken token) => Task.FromResult<IReadOnlyList<string>>([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>在字节层面做替换(用 Latin1 视图,1 字符 = 1 字节,不会动到别的字节)。</summary>
|
||||||
|
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: <[email protected]>\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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 存储层自检:**同一套断言在 json 与 sqlite 两个后端上各跑一遍**,
|
||||||
|
/// 保证两套实现语义一致(这样 Storage.Provider 才能真正做到一键切换/回滚)。
|
||||||
|
/// </summary>
|
||||||
|
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 = "[email protected]",
|
||||||
|
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("[email protected]"), "");
|
||||||
|
|
||||||
|
// ---- 报文原文往返(逐字节)----
|
||||||
|
var raw = Mime.Build(new ComposeRequest("张三 <[email protected]>", "张三", [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 = "[email protected]", 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, ["[email protected]"], "队列自检", "正文", 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("[email protected]", "删除自检", [admin], [],
|
||||||
|
"永久删除自检专用报文 " + Guid.NewGuid().ToString("N")[..8], "这封邮件的字节应当独一无二,删除后原文必须一起消失。"), config);
|
||||||
|
var doomed = store.SaveMessage(new MailMessage
|
||||||
|
{
|
||||||
|
OwnerEmail = admin, Folder = "inbox", From = "[email protected]", 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, "[email protected]");
|
||||||
|
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 = "[email protected]", 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SMTP DATA 段的线上编码与还原。
|
||||||
|
///
|
||||||
|
/// 为什么要单独抽出来:
|
||||||
|
/// DKIM 是对「签名那一刻的确切字节」做的哈希。任何在签名之后改写报文的行为
|
||||||
|
/// (例如把裸 LF 换成 CRLF)都会让接收方算出的正文哈希对不上,签名直接失效。
|
||||||
|
/// 因此约定:**先规范化行尾 → 再签名 → 传输阶段除 dot-stuffing 外不得改动任何字节**。
|
||||||
|
/// 本类同时被发送侧与自检使用,保证两边行为一致。
|
||||||
|
/// </summary>
|
||||||
|
internal static class SmtpDataEncoder
|
||||||
|
{
|
||||||
|
private static readonly byte[] Crlf = [13, 10];
|
||||||
|
|
||||||
|
/// <summary>把报文规范化为统一的 CRLF 行尾形式(不加密、不加终止行)。应在 DKIM 签名之前调用。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成 DATA 段实际要写出的字节:行首的点做 dot-stuffing,结尾补 CRLF 与单独一行的 "."。
|
||||||
|
/// 除 dot-stuffing 外不改动任何字节,以保证与 DKIM 签名一致。
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>接收侧还原:去掉终止行并做 dot-unstuffing(自检用于模拟收件端)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 字节级 SMTP 行读取器。
|
||||||
|
///
|
||||||
|
/// 为什么不用 StreamReader:
|
||||||
|
/// 1. StreamReader 只能返回字符串,8bit 正文会被字符集转换破坏;
|
||||||
|
/// 2. StreamReader 会预读缓冲,若之后改从原始流直接读 DATA,属于正文的字节
|
||||||
|
/// 可能已经被吞进它的缓冲区,造成命令/正文错位。
|
||||||
|
/// 这里让命令与 DATA 共用同一份缓冲区,从根本上避免这两个问题。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>读一行命令(不含 CRLF),连接关闭返回 null。</summary>
|
||||||
|
public async Task<string?> 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("命令行过长,已断开。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从同一缓冲区读取恰好 length 个字节(IMAP 的 literal 需要)。</summary>
|
||||||
|
public async Task<byte[]> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读取 DATA 段直到单独一行的 "."。按字节忠实处理并做 dot-unstuffing,
|
||||||
|
/// 行尾统一为 CRLF。超过 maxBytes 抛 InvalidOperationException。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<byte[]> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SMTP 服务端:
|
||||||
|
/// 25 端口 —— 公网收信(只接受本地收件人,不中继)
|
||||||
|
/// 587 端口 —— 已认证的客户端发信
|
||||||
|
///
|
||||||
|
/// 相比 v1 的关键修正:
|
||||||
|
/// 1. DATA 阶段按字节读取(v1 用 StreamReader 读文本再拼回,破坏 8bit 内容与行尾);
|
||||||
|
/// 2. 只要加载到证书就广告 STARTTLS(v1 只在「非自签名」时才广告,而 AUTH 又要求
|
||||||
|
/// 加密,导致 587 端口完全无法认证的死锁);
|
||||||
|
/// 3. 收信时补 Received 头,并对认证失败做临时封禁。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SmtpServer
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<string, Failure> 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<string>();
|
||||||
|
|
||||||
|
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 <CRLF>.<CRLF>");
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把收到的报文落库;submission 模式则进入发件队列。</summary>
|
||||||
|
private async Task StoreIncomingAsync(byte[] raw, bool submission, string sender, List<string> 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<Attachment>();
|
||||||
|
foreach (var attachment in parsed.Attachments)
|
||||||
|
{
|
||||||
|
if (attachment.Data.Length == 0) continue;
|
||||||
|
attachments.Add(new Attachment
|
||||||
|
{
|
||||||
|
FileName = attachment.FileName,
|
||||||
|
ContentType = attachment.ContentType,
|
||||||
|
Size = attachment.Data.Length,
|
||||||
|
StoredAs = 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<string> 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<string>
|
||||||
|
{
|
||||||
|
$"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<string?> 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);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 存储后端基准测试:同一份负载分别跑 JSON 与 SQLite 两套实现,输出可对比的数字。
|
||||||
|
///
|
||||||
|
/// 关注点(也是 JSON 实现真正的瓶颈):
|
||||||
|
/// 1. 入库:JSON 每存一封都要把**全部邮件**重新序列化并整文件重写(O(N)/次);
|
||||||
|
/// 2. 单条改动(标记已读):同上 —— 这是最容易被放大的操作(IMAP 客户端一打开收件箱就会批量改);
|
||||||
|
/// 3. 列表 / 未读数 / 统计 / 搜索:JSON 全是全表扫描。
|
||||||
|
/// </summary>
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace WpywMail.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 存储后端之间的数据搬迁。
|
||||||
|
///
|
||||||
|
/// 正向(JSON → SQLite):把 users/messages/queue/sessions 全部导入数据库,
|
||||||
|
/// 报文原文与附件读文件后进 blobs 表(gzip + 内容去重),并**逐封校验**
|
||||||
|
/// 「从数据库读回来的字节」与「原文件字节」SHA256 完全一致,之后才允许删除源文件。
|
||||||
|
///
|
||||||
|
/// 反向(SQLite → JSON):把 blobs 还原成 raw/ 与 attachments/ 文件并重写 JSON,
|
||||||
|
/// 用于一键回滚。
|
||||||
|
/// </summary>
|
||||||
|
public static class StorageMigration
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||||
|
|
||||||
|
private static T? ReadJson<T>(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) return default;
|
||||||
|
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), Json); }
|
||||||
|
catch (Exception ex) { AppLog.Error($"[迁移] 读取 {Path.GetFileName(path)} 失败:{ex.Message}"); return default; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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<List<MailUser>>(usersPath) ?? [];
|
||||||
|
var messages = ReadJson<List<MailMessage>>(messagesPath) ?? [];
|
||||||
|
var queue = ReadJson<List<QueueItem>>(queuePath) ?? [];
|
||||||
|
var sessions = ReadJson<List<SessionRecord>>(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<long, string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>合并 WAL 并回收空闲页,把数据库文件压到实际大小。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- 必须关闭 InvariantGlobalization,否则 GBK/GB2312 代码页不可用,非 UTF-8 的中文邮件会解码失败 -->
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
<AssemblyName>WpywMail.Native</AssemblyName>
|
||||||
|
<RootNamespace>WpywMail.Native</RootNamespace>
|
||||||
|
<Version>2.0.1</Version>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 邮件存储后端:SQLite(单文件、WAL、带索引/事务)。随包发布原生库,无需外部安装。 -->
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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": "[email protected]",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
构建 → 自检 → 发布 → 部署到服务器 → 验证,一条命令完成 WpywMail 更新。
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\deploy.ps1 -Server <SERVER_IP> -RemotePassword '***'
|
||||||
|
.\deploy.ps1 -Server <SERVER_IP> -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
|
||||||
@@ -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: <e2e-{}@e2e.local>\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("[email protected]", 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("[email protected]", [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: [email protected]\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:<[email protected]>")
|
||||||
|
code, resp = client.docmd("RCPT TO:<[email protected]>")
|
||||||
|
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:<[email protected]>")
|
||||||
|
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())
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
把 WpywMail 需要的 DKIM / DMARC 记录写入 Cloudflare(一条命令补完 DNS)。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
记录值直接由服务端二进制从 DKIM 私钥推导(--dkim-dns),避免手工复制出错。
|
||||||
|
幂等:已存在的同名记录会被更新而不是重复创建。
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# 在本机执行(会通过 WinRM 读取服务器上的公钥)
|
||||||
|
.\publish-dns.ps1 -Server <SERVER_IP> -Zone wpy.email -ApiToken "<CF Token>"
|
||||||
|
|
||||||
|
.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"
|
||||||
@@ -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())
|
||||||
@@ -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<string, Session> 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<LoginRequest>(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<SendRequest>(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<Dictionary<string, string>>(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<Dictionary<string, string>>(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<T?> ReadJson<T>(HttpListenerRequest request) { using var reader = new StreamReader(request.InputStream, Encoding.UTF8); return JsonSerializer.Deserialize<T>(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);
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SmtpReply> 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<SmtpReply> ReadReplyAsync(StreamReader reader, CancellationToken token)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
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<string> 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<IReadOnlyList<string>> 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<IReadOnlyList<string>> 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<string> 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<string>(); 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<IPAddress> 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")];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MailUser> users = [];
|
||||||
|
private List<MailMessage> messages = [];
|
||||||
|
private List<QueueItem> queue = [];
|
||||||
|
|
||||||
|
public FileStore(AppConfig config)
|
||||||
|
{
|
||||||
|
this.config = config;
|
||||||
|
Directory.CreateDirectory(config.DataDirectory);
|
||||||
|
rawDirectory = Path.Combine(config.DataDirectory, "raw");
|
||||||
|
Directory.CreateDirectory(rawDirectory);
|
||||||
|
usersPath = Path.Combine(config.DataDirectory, "users.json");
|
||||||
|
messagesPath = Path.Combine(config.DataDirectory, "messages.json");
|
||||||
|
queuePath = Path.Combine(config.DataDirectory, "queue.json");
|
||||||
|
Load();
|
||||||
|
EnsureAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
users = Read<List<MailUser>>(usersPath) ?? [];
|
||||||
|
messages = Read<List<MailMessage>>(messagesPath) ?? [];
|
||||||
|
queue = Read<List<QueueItem>>(queuePath) ?? [];
|
||||||
|
foreach (var item in queue.Where(x => x.Status == "processing"))
|
||||||
|
{
|
||||||
|
item.Status = "retry";
|
||||||
|
item.NextAttempt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? Read<T>(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) return default;
|
||||||
|
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), json); }
|
||||||
|
catch { return default; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Write<T>(string path, T value)
|
||||||
|
{
|
||||||
|
var temp = path + ".tmp";
|
||||||
|
File.WriteAllText(temp, JsonSerializer.Serialize(value, json), Encoding.UTF8);
|
||||||
|
File.Move(temp, path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAdmin()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config.AdminPassword))
|
||||||
|
throw new InvalidOperationException("appsettings.json 中必须设置 AdminPassword。");
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (users.Any(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase))) return;
|
||||||
|
users.Add(new MailUser
|
||||||
|
{
|
||||||
|
Email = config.AdminEmail.ToLowerInvariant(),
|
||||||
|
DisplayName = "Administrator",
|
||||||
|
Role = "admin",
|
||||||
|
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||||
|
});
|
||||||
|
users[^1].PasswordHash = HashPassword(config.AdminPassword, users[^1].PasswordSalt);
|
||||||
|
Write(usersPath, users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailUser? FindUser(string email) => users.FirstOrDefault(x => x.Active && x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public MailUser? Authenticate(string email, string password)
|
||||||
|
{
|
||||||
|
var user = FindUser(email);
|
||||||
|
return user is not null && VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLocalAddress(string email) => FindUser(email) is not null;
|
||||||
|
|
||||||
|
public IReadOnlyList<MailUser> ListUsers() => users.Where(x => x.Active).OrderBy(x => x.Email).ToArray();
|
||||||
|
|
||||||
|
public MailUser CreateUser(string email, string password, string displayName)
|
||||||
|
{
|
||||||
|
email = email.Trim().ToLowerInvariant();
|
||||||
|
if (FindUser(email) is not null) throw new InvalidOperationException("用户已存在。");
|
||||||
|
var user = new MailUser
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName,
|
||||||
|
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||||
|
};
|
||||||
|
user.PasswordHash = HashPassword(password, user.PasswordSalt);
|
||||||
|
lock (gate) { users.Add(user); Write(usersPath, users); }
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ChangePassword(string email, string password)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("用户不存在。");
|
||||||
|
user.PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||||
|
user.PasswordHash = HashPassword(password, user.PasswordSalt);
|
||||||
|
Write(usersPath, users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SaveRaw(byte[] raw)
|
||||||
|
{
|
||||||
|
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}.eml";
|
||||||
|
var relative = Path.Combine("raw", name);
|
||||||
|
File.WriteAllBytes(Path.Combine(config.DataDirectory, relative), raw);
|
||||||
|
return relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] ReadRaw(string relativePath)
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(Path.Combine(config.DataDirectory, relativePath));
|
||||||
|
if (!full.StartsWith(Path.GetFullPath(rawDirectory), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("非法文件路径。");
|
||||||
|
return File.ReadAllBytes(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage SaveMessage(MailMessage message, byte[]? raw = null)
|
||||||
|
{
|
||||||
|
if (raw is not null) message.RawPath = SaveRaw(raw);
|
||||||
|
lock (gate) { messages.Add(message); Write(messagesPath, messages); }
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MailMessage> ListMessages(string owner, string folder, string query)
|
||||||
|
{
|
||||||
|
query = query.Trim();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return messages.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(x => x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(x => query.Length == 0 || $"{x.From} {x.To} {x.Subject} {x.Text}".Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(x => x.Date).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage? GetMessage(string owner, string id) => messages.FirstOrDefault(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public void MarkRead(string owner, string id, bool read = true)
|
||||||
|
{
|
||||||
|
lock (gate) { var item = GetMessage(owner, id); if (item is null) return; item.Unread = !read; Write(messagesPath, messages); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text, byte[] raw)
|
||||||
|
{
|
||||||
|
var message = new MailMessage { OwnerEmail = owner, Folder = "sent", From = owner, To = string.Join(", ", recipients), Subject = subject, Text = text, DeliveryStatus = "queued", Unread = false };
|
||||||
|
message.RawPath = SaveRaw(raw);
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
messages.Add(message);
|
||||||
|
foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
queue.Add(new QueueItem { MessageId = message.Id, OwnerEmail = owner, Recipients = [recipient] });
|
||||||
|
}
|
||||||
|
Write(messagesPath, messages);
|
||||||
|
Write(queuePath, queue);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<QueueItem> TakeDueQueue(int limit)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var due = queue.Where(x => x.Status is "pending" or "retry" && x.NextAttempt <= DateTimeOffset.UtcNow).Take(limit).ToArray();
|
||||||
|
foreach (var item in due) item.Status = "processing";
|
||||||
|
Write(queuePath, queue);
|
||||||
|
return due;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailMessage? GetById(string id) => messages.FirstOrDefault(x => x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public void CompleteQueue(QueueItem item)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
item.Status = "sent";
|
||||||
|
var message = GetById(item.MessageId);
|
||||||
|
if (message is not null)
|
||||||
|
{
|
||||||
|
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
|
||||||
|
message.DeliveryStatus = remaining ? "queued" : "sent";
|
||||||
|
}
|
||||||
|
Write(queuePath, queue); Write(messagesPath, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FailQueue(QueueItem item, Exception error)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
item.Attempts++;
|
||||||
|
item.LastError = error.Message;
|
||||||
|
var permanent = error is SmtpDeliveryException smtp && smtp.Permanent;
|
||||||
|
item.Status = permanent || item.Attempts >= 8 ? "failed" : "retry";
|
||||||
|
item.NextAttempt = DateTimeOffset.UtcNow.AddMinutes(Math.Min(60, Math.Pow(2, item.Attempts)));
|
||||||
|
var message = GetById(item.MessageId);
|
||||||
|
if (message is not null)
|
||||||
|
{
|
||||||
|
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
|
||||||
|
message.DeliveryStatus = item.Status == "failed" && !remaining ? "failed" : "queued";
|
||||||
|
}
|
||||||
|
Write(queuePath, queue); Write(messagesPath, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object Stats(string owner)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return new { inbox = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox"), unread = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox" && x.Unread), sent = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "sent"), queue = queue.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Status is "pending" or "retry" or "processing") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashPassword(string password, string salt) => Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, Convert.FromBase64String(salt), 120_000, HashAlgorithmName.SHA256, 32));
|
||||||
|
private static bool VerifyPassword(string password, string hash, string salt) => CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(hash), Convert.FromBase64String(HashPassword(password, salt)));
|
||||||
|
}
|
||||||
@@ -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<string, string>(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();
|
||||||
|
}
|
||||||
@@ -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; } = "[email protected]";
|
||||||
|
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);
|
||||||
@@ -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<AppConfig>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# wpyw.mail Windows 邮箱服务
|
||||||
|
|
||||||
|
这是一个不依赖 Node、npm、数据库或第三方运行库的 Windows 原生服务端。安装包会自动配置:
|
||||||
|
|
||||||
|
- 邮箱:`[email protected]`
|
||||||
|
- 收信: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 盘就直接回车 |
|
||||||
|
| 邮箱密码 | `[email protected]` 的登录密码 | 输入至少 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 <SERVER_IP> 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`。
|
||||||
@@ -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<string>();
|
||||||
|
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 <CRLF>.<CRLF>");
|
||||||
|
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<string?> 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<byte[]> ReadData(StreamReader reader, CancellationToken token)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
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); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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": "[email protected]",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MAIL_DOMAIN=wpyw.site
|
||||||
|
MAIL_HOSTNAME=mail.wpyw.site
|
||||||
|
MAIL_USER=[email protected]
|
||||||
|
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
|
||||||
@@ -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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
主要接口:
|
||||||
|
|
||||||
|
```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 连接。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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))
|
||||||
+197
@@ -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 }
|
||||||
Generated
+1306
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <[email protected]>',
|
||||||
|
to: process.env.MAIL_USER || '[email protected]',
|
||||||
|
subject: '你的 wpyw.site 邮件服务已准备就绪',
|
||||||
|
text: '这是本地演示邮件。正式使用时,来自公网的 SMTP 邮件会自动进入这里。',
|
||||||
|
date: new Date(now - 1000 * 60 * 12).toISOString(),
|
||||||
|
unread: true,
|
||||||
|
}),
|
||||||
|
makeMessage({
|
||||||
|
folder: 'inbox',
|
||||||
|
from: '系统管理员 <[email protected]>',
|
||||||
|
to: process.env.MAIL_USER || '[email protected]',
|
||||||
|
subject: '欢迎使用 wpyw.mail',
|
||||||
|
text: '你可以从左侧开始管理收件箱,或点击右上角写信。',
|
||||||
|
date: new Date(now - 1000 * 60 * 60 * 4).toISOString(),
|
||||||
|
unread: false,
|
||||||
|
}),
|
||||||
|
makeMessage({
|
||||||
|
folder: 'sent',
|
||||||
|
from: process.env.MAIL_USER || '[email protected]',
|
||||||
|
to: '[email protected]',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -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('[email protected]')
|
||||||
|
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 (
|
||||||
|
<main className="login-shell">
|
||||||
|
<section className="login-card">
|
||||||
|
<div className="brand-lockup"><span className="brand-mark"><Mail size={18} /></span><span>wpyw.mail</span></div>
|
||||||
|
<div className="login-copy">
|
||||||
|
<p className="eyebrow">PRIVATE MAIL SERVER</p>
|
||||||
|
<h1>你的邮件,留在自己的服务器上。</h1>
|
||||||
|
<p>通过安全连接访问 wpyw.site 的收发件箱。</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="login-form">
|
||||||
|
<label>邮箱地址<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="username" /></label>
|
||||||
|
<label>密码<input value={password} onChange={(event) => setPassword(event.target.value)} type="password" autoComplete="current-password" placeholder="输入服务器邮箱密码" /></label>
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
<button className="primary-button" disabled={loading}>{loading ? '正在登录…' : '进入邮箱'}<ChevronRight size={17} /></button>
|
||||||
|
</form>
|
||||||
|
<div className="login-foot"><ShieldCheck size={15} /> 邮件服务运行在你的 Windows 服务器上</div>
|
||||||
|
</section>
|
||||||
|
<div className="login-orbit orbit-a" /><div className="login-orbit orbit-b" />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ folder, setFolder, stats, onCompose, onLogout, mobileOpen, onClose }) {
|
||||||
|
return (
|
||||||
|
<aside className={`sidebar ${mobileOpen ? 'mobile-open' : ''}`}>
|
||||||
|
<div className="sidebar-top">
|
||||||
|
<div className="brand-lockup"><span className="brand-mark"><Mail size={18} /></span><span>wpyw.mail</span></div>
|
||||||
|
<button className="icon-button mobile-close" onClick={onClose}><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<button className="compose-button" onClick={onCompose}><Plus size={17} />写信</button>
|
||||||
|
<nav className="folder-nav">
|
||||||
|
<p className="nav-caption">邮箱</p>
|
||||||
|
{folders.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button key={id} onClick={() => { setFolder(id); onClose() }} className={`folder-item ${folder === id ? 'active' : ''}`}>
|
||||||
|
<Icon size={17} /><span>{label}</span>{id === 'inbox' && stats.unread > 0 && <b>{stats.unread}</b>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-spacer" />
|
||||||
|
<div className="server-status"><span className="status-dot" /><div><strong>服务器在线</strong><small>mail.wpyw.site</small></div></div>
|
||||||
|
<div className="sidebar-bottom">
|
||||||
|
<button className="folder-item"><Settings size={17} /><span>设置</span></button>
|
||||||
|
<button className="folder-item" onClick={onLogout}><LogOut size={17} /><span>退出登录</span></button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageRow({ message, selected, onSelect }) {
|
||||||
|
return (
|
||||||
|
<button className={`message-row ${selected ? 'selected' : ''} ${message.unread ? 'unread' : ''}`} onClick={() => onSelect(message.id)}>
|
||||||
|
<span className="sender-avatar">{senderName(message.from).slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span className="message-row-main"><strong>{senderName(message.from)}</strong><span>{message.subject}</span><small>{message.preview || '没有正文预览'}</small></span>
|
||||||
|
<span className="message-row-meta"><time>{formatDate(message.date)}</time>{message.attachments?.length > 0 && <Paperclip size={14} />}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reader({ message, onBack, onCompose }) {
|
||||||
|
if (!message) return <section className="reader empty-reader"><div className="empty-icon"><Mail size={22} /></div><h2>选择一封邮件</h2><p>从左侧收件箱中选择邮件,在这里查看内容。</p></section>
|
||||||
|
return (
|
||||||
|
<section className="reader">
|
||||||
|
<div className="reader-toolbar"><button className="icon-button mobile-back" onClick={onBack}><ArrowLeft size={18} /></button><div className="reader-actions"><button className="icon-button" title="归档"><Archive size={17} /></button><button className="icon-button" title="删除"><Trash2 size={17} /></button><button className="icon-button" title="更多"><MoreHorizontal size={18} /></button></div></div>
|
||||||
|
<article className="message-detail">
|
||||||
|
<div className="message-detail-head"><div className="sender-avatar large">{senderName(message.from).slice(0, 1).toUpperCase()}</div><div><h1>{message.subject}</h1><div className="sender-line"><strong>{senderName(message.from)}</strong><span><{message.from.match(/<([^>]+)>/)?.[1] || message.from}></span></div><div className="recipient-line">发送给 {message.to || '我'} · {formatDate(message.date, true)}</div></div><button className="icon-button"><Star size={18} /></button></div>
|
||||||
|
<div className="message-body">{message.html ? <div dangerouslySetInnerHTML={{ __html: message.html }} /> : (message.text || '').split('\n').map((line, index) => <p key={index}>{line || '\u00a0'}</p>)}</div>
|
||||||
|
{message.attachments?.length > 0 && <div className="attachments"><p>附件</p>{message.attachments.map((item) => <div className="attachment" key={item.storedAs}><Paperclip size={15} />{item.filename}</div>)}</div>}
|
||||||
|
<div className="reply-row"><button className="secondary-button" onClick={() => onCompose({ to: message.from.match(/<([^>]+)>/)?.[1] || message.from, subject: `Re: ${message.subject}` })}><ArrowLeft size={16} />回复</button><button className="secondary-button" onClick={() => onCompose({ to: message.from.match(/<([^>]+)>/)?.[1] || message.from, subject: `Fwd: ${message.subject}` })}>转发</button></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="compose-overlay"><form className="compose-panel" onSubmit={send}><header><div><span className="compose-title">新邮件</span><small>从 admin@wpyw.site 发送</small></div><button type="button" className="icon-button" onClick={onClose}><X size={18} /></button></header><label>收件人<input autoFocus value={to} onChange={(event) => setTo(event.target.value)} placeholder="[email protected]" /></label><label>主题<input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="输入主题" /></label><textarea value={text} onChange={(event) => setText(event.target.value)} placeholder="写下你的内容…" required /><footer>{error && <span className="form-error">{error}</span>}<button type="button" className="secondary-button" onClick={onClose}>取消</button><button className="primary-button small" disabled={sending}><Send size={15} />{sending ? '发送中…' : '发送'}</button></footer></form></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [token, setToken] = useState(() => sessionStorage.getItem('wpyw-token'))
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [folder, setFolder] = useState('inbox')
|
||||||
|
const [messages, setMessages] = useState([])
|
||||||
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [stats, setStats] = useState({ inbox: 0, unread: 0, sent: 0, drafts: 0 })
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [compose, setCompose] = useState(null)
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const selectedSummary = useMemo(() => messages.find((item) => item.id === selectedId), [messages, selectedId])
|
||||||
|
|
||||||
|
async function refresh(nextFolder = folder, nextQuery = query) {
|
||||||
|
if (!token) return
|
||||||
|
const [list, me] = await Promise.all([request(`/messages?folder=${nextFolder}&q=${encodeURIComponent(nextQuery)}`, {}, token), request('/me', {}, token)])
|
||||||
|
setMessages(list.messages)
|
||||||
|
setStats(me.stats)
|
||||||
|
if (!list.messages.some((item) => item.id === selectedId)) {
|
||||||
|
setSelectedId(null)
|
||||||
|
setSelected(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return
|
||||||
|
refresh().catch(() => { sessionStorage.removeItem('wpyw-token'); setToken(null) })
|
||||||
|
}, [token, folder])
|
||||||
|
|
||||||
|
async function selectMessage(id) {
|
||||||
|
setSelectedId(id)
|
||||||
|
const data = await request(`/messages/${id}`, {}, token)
|
||||||
|
setSelected(data.message)
|
||||||
|
setMessages((items) => items.map((item) => item.id === id ? { ...item, unread: false } : item))
|
||||||
|
setStats((value) => ({ ...value, unread: Math.max(0, value.unread - 1) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(data) {
|
||||||
|
sessionStorage.setItem('wpyw-token', data.token)
|
||||||
|
setToken(data.token)
|
||||||
|
setUser(data.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await request('/logout', { method: 'POST' }, token).catch(() => {})
|
||||||
|
sessionStorage.removeItem('wpyw-token')
|
||||||
|
setToken(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) return <Login onLogin={login} />
|
||||||
|
|
||||||
|
return <div className="app-shell"><Sidebar folder={folder} setFolder={setFolder} stats={stats} onCompose={() => setCompose({})} onLogout={logout} mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} /><main className="mail-main"><header className="topbar"><button className="icon-button mobile-menu" onClick={() => setMobileOpen(true)}><Menu size={19} /></button><div className="search-box"><Search size={17} /><input value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && refresh(folder, query)} placeholder="搜索邮件" /></div><div className="topbar-actions"><div className="connection-state"><span className="status-dot" />安全连接</div><div className="account-chip"><CircleUserRound size={18} /><span>{user?.email || '[email protected]'}</span><ChevronDown size={15} /></div></div></header><div className="content-grid"><section className="list-panel"><div className="list-header"><div><p className="eyebrow">MAILBOX</p><h1>{folders.find((item) => item.id === folder)?.label || '收件箱'}</h1></div><button className="icon-button"><MoreHorizontal size={18} /></button></div><div className="list-meta"><span>{messages.length} 封邮件</span><button onClick={() => refresh()}>刷新</button></div><div className="message-list">{messages.length ? messages.map((message) => <MessageRow key={message.id} message={message} selected={selectedId === message.id} onSelect={selectMessage} />) : <div className="list-empty"><div className="empty-icon"><Mail size={20} /></div><strong>这里还没有邮件</strong><span>新邮件到达后会显示在这里。</span></div>}</div></section><Reader message={selected || (selectedId ? selectedSummary : null)} onBack={() => { setSelectedId(null); setSelected(null) }} onCompose={(initial) => setCompose(initial)} /></div></main>{compose && <Compose initial={compose} token={token} onClose={() => setCompose(null)} onSent={() => { setCompose(null); refresh() }} />}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root { font-family: 'DM Sans', 'Noto Sans SC', sans-serif; color: #15243b; background: #f6f8fb; font-synthesis: none; text-rendering: optimizeLegibility; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; }
|
||||||
|
button, input, textarea { font: inherit; }
|
||||||
|
button { border: 0; cursor: pointer; }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: .65; }
|
||||||
|
.app-shell { min-height: 100vh; display: flex; background: #f7f9fc; }
|
||||||
|
.sidebar { width: 238px; padding: 24px 15px 18px; display: flex; flex-direction: column; background: #0b1526; color: #c7d4e7; }
|
||||||
|
.sidebar-top, .topbar, .list-header, .reader-toolbar, .compose-panel header, .compose-panel footer { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.brand-lockup { display: flex; gap: 10px; align-items: center; color: #fff; font-weight: 700; letter-spacing: -.02em; }
|
||||||
|
.brand-mark { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 10px; color: #071322; background: #83e8df; }
|
||||||
|
.compose-button { margin: 38px 0 27px; height: 44px; display: flex; align-items: center; justify-content: center; gap: 9px; border-radius: 12px; color: #071322; background: #83e8df; font-size: 14px; font-weight: 700; box-shadow: 0 8px 24px rgba(131,232,223,.14); }
|
||||||
|
.nav-caption, .eyebrow { margin: 0 10px 10px; color: #7f91aa; font-size: 10px; font-weight: 700; letter-spacing: .14em; }
|
||||||
|
.folder-nav { display: grid; gap: 4px; }
|
||||||
|
.folder-item { width: 100%; min-height: 40px; padding: 0 11px; display: flex; align-items: center; gap: 11px; border-radius: 9px; color: #aebdd1; background: transparent; text-align: left; font-size: 13px; }
|
||||||
|
.folder-item:hover, .folder-item.active { color: #fff; background: #172842; }
|
||||||
|
.folder-item.active { box-shadow: inset 2px 0 #83e8df; }
|
||||||
|
.folder-item b { min-width: 19px; margin-left: auto; padding: 2px 5px; border-radius: 7px; color: #092036; background: #83e8df; font-size: 10px; text-align: center; }
|
||||||
|
.sidebar-spacer { flex: 1; }
|
||||||
|
.server-status { display: flex; align-items: center; gap: 10px; margin: 14px 4px; padding: 13px 11px; border: 1px solid #203452; border-radius: 11px; background: #101f35; }
|
||||||
|
.server-status strong, .server-status small { display: block; }
|
||||||
|
.server-status strong { color: #e9f3ff; font-size: 12px; }
|
||||||
|
.server-status small { margin-top: 3px; color: #7f91aa; font-size: 10px; }
|
||||||
|
.status-dot { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: #53d69d; box-shadow: 0 0 0 4px rgba(83,214,157,.12); }
|
||||||
|
.sidebar-bottom { display: grid; gap: 4px; padding-top: 12px; border-top: 1px solid #1b2a42; }
|
||||||
|
.mail-main { min-width: 0; flex: 1; }
|
||||||
|
.topbar { height: 76px; padding: 0 32px; border-bottom: 1px solid #e5eaf1; background: rgba(255,255,255,.78); }
|
||||||
|
.search-box { width: min(370px, 48vw); height: 38px; display: flex; align-items: center; gap: 10px; padding: 0 13px; border: 1px solid #e2e8f0; border-radius: 10px; color: #91a0b3; background: #f8fafc; }
|
||||||
|
.search-box input { width: 100%; border: 0; outline: 0; color: #15243b; background: transparent; font-size: 12px; }
|
||||||
|
.topbar-actions { display: flex; align-items: center; gap: 23px; }
|
||||||
|
.connection-state { display: flex; align-items: center; gap: 8px; color: #637289; font-size: 11px; }
|
||||||
|
.account-chip { display: flex; align-items: center; gap: 8px; color: #53627a; font-size: 12px; }
|
||||||
|
.content-grid { min-height: calc(100vh - 76px); display: grid; grid-template-columns: minmax(360px, 43%) 1fr; }
|
||||||
|
.list-panel { border-right: 1px solid #e5eaf1; background: #fff; }
|
||||||
|
.list-header { padding: 34px 30px 17px; }
|
||||||
|
.list-header .eyebrow { margin-left: 0; margin-bottom: 8px; color: #8e9caf; }
|
||||||
|
.list-header h1 { margin: 0; color: #12233b; font-size: 24px; letter-spacing: -.04em; }
|
||||||
|
.list-meta { display: flex; align-items: center; justify-content: space-between; padding: 0 30px 18px; color: #9aa7b9; font-size: 11px; }
|
||||||
|
.list-meta button { padding: 0; color: #3274ae; background: transparent; font-size: 11px; }
|
||||||
|
.message-list { border-top: 1px solid #eef1f5; }
|
||||||
|
.message-row { width: 100%; min-height: 94px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; gap: 12px; align-items: start; padding: 18px 30px; border-bottom: 1px solid #eef1f5; background: #fff; color: #14243c; text-align: left; }
|
||||||
|
.message-row:hover { background: #f8fbfe; }
|
||||||
|
.message-row.selected { background: #eef9fa; box-shadow: inset 3px 0 #56cfd0; }
|
||||||
|
.message-row.unread strong, .message-row.unread .message-row-main > span { font-weight: 700; }
|
||||||
|
.sender-avatar { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; color: #1c6a77; background: #d8f2ef; font-size: 12px; font-weight: 700; }
|
||||||
|
.sender-avatar.large { width: 42px; height: 42px; font-size: 14px; }
|
||||||
|
.message-row-main { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.message-row-main strong { overflow: hidden; color: #22314a; font-size: 12px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.message-row-main > span { overflow: hidden; color: #34435a; font-size: 12px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.message-row-main small { overflow: hidden; color: #9aa7b9; font-size: 11px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.message-row-meta { display: flex; align-items: center; gap: 9px; color: #9aa7b9; font-size: 10px; }
|
||||||
|
.reader { min-width: 0; background: #fbfcfe; }
|
||||||
|
.reader-toolbar { padding: 26px 37px 16px; }
|
||||||
|
.reader-actions { display: flex; gap: 5px; margin-left: auto; }
|
||||||
|
.icon-button { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 8px; color: #8291a4; background: transparent; }
|
||||||
|
.icon-button:hover { color: #284764; background: #eef3f7; }
|
||||||
|
.message-detail { max-width: 760px; margin: 25px auto; padding: 0 44px 60px; }
|
||||||
|
.message-detail-head { display: grid; grid-template-columns: 42px 1fr 32px; gap: 13px; align-items: start; padding-bottom: 26px; border-bottom: 1px solid #e7ecf2; }
|
||||||
|
.message-detail h1 { margin: 1px 0 10px; color: #14243b; font-size: 21px; letter-spacing: -.03em; }
|
||||||
|
.sender-line { display: flex; gap: 7px; align-items: center; color: #263850; font-size: 12px; }
|
||||||
|
.sender-line span, .recipient-line { color: #94a0b0; font-size: 11px; }
|
||||||
|
.recipient-line { margin-top: 5px; }
|
||||||
|
.message-body { padding: 31px 0; color: #48596f; font-size: 13px; line-height: 1.9; }
|
||||||
|
.message-body p { margin: 0 0 12px; }
|
||||||
|
.attachments { padding: 17px 0 23px; border-top: 1px solid #e7ecf2; }
|
||||||
|
.attachments p { margin: 0 0 10px; color: #7c8a9e; font-size: 11px; font-weight: 600; }
|
||||||
|
.attachment { width: fit-content; display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #e0e7ee; border-radius: 7px; color: #52718e; background: #fff; font-size: 11px; }
|
||||||
|
.reply-row { display: flex; gap: 9px; }
|
||||||
|
.secondary-button, .primary-button { height: 37px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border-radius: 8px; font-size: 12px; font-weight: 600; }
|
||||||
|
.secondary-button { color: #47617e; border: 1px solid #dfe6ee; background: #fff; }
|
||||||
|
.secondary-button:hover { background: #f2f6fa; }
|
||||||
|
.primary-button { color: #082036; background: #83e8df; }
|
||||||
|
.primary-button:hover { background: #6adfd6; }
|
||||||
|
.primary-button.small { height: 35px; }
|
||||||
|
.empty-reader, .list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; color: #8d9aad; text-align: center; }
|
||||||
|
.empty-reader h2, .list-empty strong { margin: 16px 0 7px; color: #506178; font-size: 15px; }
|
||||||
|
.empty-reader p, .list-empty span { margin: 0; font-size: 12px; }
|
||||||
|
.empty-icon { width: 46px; height: 46px; display: grid; place-items: center; border-radius: 14px; color: #5e9a9a; background: #e4f5f3; }
|
||||||
|
.compose-overlay { position: fixed; inset: 0; display: grid; place-items: end; padding: 24px; background: rgba(5,17,31,.22); z-index: 5; }
|
||||||
|
.compose-panel { width: min(590px, 100%); display: grid; gap: 13px; padding: 19px; border: 1px solid #dce5ee; border-radius: 14px; background: #fff; box-shadow: 0 22px 70px rgba(12,35,60,.22); }
|
||||||
|
.compose-panel header { padding-bottom: 9px; border-bottom: 1px solid #edf1f5; }
|
||||||
|
.compose-title { display: block; color: #172a43; font-size: 14px; font-weight: 700; }
|
||||||
|
.compose-panel header small { display: block; margin-top: 3px; color: #93a0b1; font-size: 10px; }
|
||||||
|
.compose-panel label, .login-form label { display: grid; gap: 6px; color: #718197; font-size: 11px; font-weight: 600; }
|
||||||
|
.compose-panel input, .compose-panel textarea, .login-form input { width: 100%; border: 1px solid #dce5ed; border-radius: 8px; outline: 0; color: #22344d; background: #fbfcfe; font-size: 12px; }
|
||||||
|
.compose-panel input, .login-form input { height: 38px; padding: 0 11px; }
|
||||||
|
.compose-panel textarea { min-height: 210px; padding: 12px; resize: vertical; line-height: 1.7; }
|
||||||
|
.compose-panel input:focus, .compose-panel textarea:focus, .login-form input:focus { border-color: #65cac8; box-shadow: 0 0 0 3px rgba(101,202,200,.12); }
|
||||||
|
.compose-panel footer { justify-content: flex-end; gap: 9px; padding-top: 4px; }
|
||||||
|
.form-error { color: #c34b54; font-size: 11px; }
|
||||||
|
.mobile-menu, .mobile-close, .mobile-back { display: none; }
|
||||||
|
.login-shell { min-height: 100vh; position: relative; display: grid; place-items: center; overflow: hidden; background: #0b1526; }
|
||||||
|
.login-card { width: min(420px, calc(100% - 36px)); position: relative; z-index: 1; padding: 42px; border: 1px solid #203452; border-radius: 20px; background: rgba(15,30,51,.85); box-shadow: 0 30px 90px rgba(0,0,0,.28); }
|
||||||
|
.login-copy { margin: 65px 0 29px; }
|
||||||
|
.login-copy .eyebrow { margin: 0 0 12px; color: #74c7c6; }
|
||||||
|
.login-copy h1 { margin: 0 0 13px; color: #f3f8ff; font-size: 29px; line-height: 1.25; letter-spacing: -.05em; }
|
||||||
|
.login-copy p:not(.eyebrow) { margin: 0; color: #9cafc4; font-size: 13px; line-height: 1.7; }
|
||||||
|
.login-form { display: grid; gap: 17px; }
|
||||||
|
.login-form label { color: #9cafc4; }
|
||||||
|
.login-form input { border-color: #29405d; color: #e8f2ff; background: #0b1b31; }
|
||||||
|
.login-form input::placeholder { color: #5c718c; }
|
||||||
|
.login-form .primary-button { margin-top: 5px; height: 43px; }
|
||||||
|
.login-foot { display: flex; align-items: center; gap: 7px; margin-top: 27px; color: #7590ae; font-size: 10px; }
|
||||||
|
.orbit-a, .orbit-b { position: absolute; border: 1px solid rgba(131,232,223,.12); border-radius: 50%; }
|
||||||
|
.orbit-a { width: 720px; height: 720px; top: -370px; right: -190px; }
|
||||||
|
.orbit-b { width: 980px; height: 980px; bottom: -710px; left: -310px; border-color: rgba(91,133,199,.13); }
|
||||||
|
@media (max-width: 900px) { .sidebar { position: fixed; inset: 0 auto 0 0; z-index: 10; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 15px 0 35px rgba(4,14,29,.18); } .sidebar.mobile-open { transform: translateX(0); } .mobile-menu, .mobile-close, .mobile-back { display: grid; } .topbar { padding: 0 18px; gap: 14px; } .topbar-actions { gap: 12px; } .connection-state { display: none; } .content-grid { grid-template-columns: 1fr; } .reader { display: none; } .reader:has(.message-detail) { display: block; position: fixed; inset: 76px 0 0; z-index: 4; overflow: auto; } .reader-toolbar { padding: 15px 18px; } .message-detail { margin: 8px auto; padding: 0 22px 45px; } .list-panel { border-right: 0; } .message-row { padding-inline: 18px; } .list-header { padding-inline: 18px; } .list-meta { padding-inline: 18px; } .account-chip span { display: none; } }
|
||||||
|
@media (max-width: 520px) { .login-card { padding: 30px 24px; } .login-copy { margin-top: 48px; } .login-copy h1 { font-size: 25px; } .topbar { height: 66px; } .content-grid { min-height: calc(100vh - 66px); } .search-box { width: 100%; } .topbar-actions { display: none; } .reader:has(.message-detail) { inset: 66px 0 0; } .message-detail-head { grid-template-columns: 38px 1fr 28px; gap: 10px; } .sender-avatar.large { width: 38px; height: 38px; } .message-detail h1 { font-size: 18px; } .compose-overlay { padding: 0; align-items: end; } .compose-panel { border-radius: 14px 14px 0 0; } }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:8787',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user