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
+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>