Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端

This commit is contained in:
WpyQwq
2026-09-19 11:19:40 +08:00
commit b8814a7615
84 changed files with 21197 additions and 0 deletions
+20
View File
@@ -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
+60
View File
@@ -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。
+97
View File
@@ -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";
}
}
+81
View File
@@ -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>
+27
View File
@@ -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();
}
}
+20
View File
@@ -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();
}
+174
View File
@@ -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>
+244
View File
@@ -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);
}
}
+94
View File
@@ -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; } = "";
}
+17
View File
@@ -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 路由。
+21
View File
@@ -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>
+16
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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>
+17
View File
@@ -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>
+47
View File
@@ -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;
}
}
}
+24
View File
@@ -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"
+9
View File
@@ -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%
+139
View File
@@ -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 '按回车键退出'
+44
View File
@@ -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` 子目录。
+3319
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+563
View File
@@ -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; } = "";
}
}
+877
View File
@@ -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 &lt;token&gt;。
/// 会话落盘(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";
}
+54
View File
@@ -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 { }
}
}
+94
View File
@@ -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}");
}
}
}
+438
View File
@@ -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")];
}
}
+242
View File
@@ -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..]);
}
}
+975
View File
@@ -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() { }
}
+160
View File
@@ -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
+464
View File
@@ -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();
}
}
+462
View File
@@ -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;
}
}
+134
View File
@@ -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;
}
}
+713
View File
@@ -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;
}
}
}
+409
View File
@@ -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/&lt;selector&gt;.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);
+249
View File
@@ -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);
}
+556
View File
@@ -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 节)。
+401
View File
@@ -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);
}
+293
View File
@@ -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; }
}
}
+293
View File
@@ -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);
}
}
+179
View File
@@ -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; }
}
}
+97
View File
@@ -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;
}
+116
View File
@@ -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();
}
}
+509
View File
@@ -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
+218
View File
@@ -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 },
};
}
+321
View File
@@ -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;
}
}
+19
View File
@@ -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>
+67
View File
@@ -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
}
}
+3
View File
@@ -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。
+17
View File
@@ -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
+93
View File
@@ -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
+519
View File
@@ -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())
+100
View File
@@ -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())
+131
View File
@@ -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);
}
+32
View File
@@ -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 { }
}
}
+75
View File
@@ -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);
}
}
+317
View File
@@ -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")];
}
}
+234
View File
@@ -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)));
}
+47
View File
@@ -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();
}
+81
View File
@@ -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);
+23
View File
@@ -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));
}
}
+78
View File
@@ -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`。
+181
View File
@@ -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); }
}
+10
View File
@@ -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>
+28
View File
@@ -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
}
}
+3
View File
@@ -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。
+17
View File
@@ -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"
+21
View File
@@ -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
+92
View File
@@ -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 连接。
+1
View File
@@ -0,0 +1 @@
+102
View File
@@ -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
View File
@@ -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 }
+1306
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
}
}
+143
View File
@@ -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
View File
@@ -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>&lt;{message.from.match(/<([^>]+)>/)?.[1] || message.from}&gt;</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
+10
View File
@@ -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
View File
@@ -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; } }
+15
View File
@@ -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',
},
})