Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Client;
|
||||
|
||||
public sealed class ApiClient
|
||||
{
|
||||
private readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(12) };
|
||||
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web);
|
||||
private string token = "";
|
||||
|
||||
public string BaseUrl { get; private set; }
|
||||
|
||||
public ApiClient(string baseUrl = "http://127.0.0.1:8787/api")
|
||||
{
|
||||
BaseUrl = NormalizeBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
public void SetBaseUrl(string baseUrl) => BaseUrl = NormalizeBaseUrl(baseUrl);
|
||||
|
||||
public async Task<LoginResponse> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync($"{BaseUrl}/login", new { email, password }, json, cancellationToken);
|
||||
return await ReadOrThrow<LoginResponse>(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MeResponse> GetMeAsync(CancellationToken cancellationToken = default) =>
|
||||
await SendAsync<MeResponse>(HttpMethod.Get, "/me", cancellationToken: cancellationToken);
|
||||
|
||||
public async Task<ConfigResponse> GetConfigAsync(CancellationToken cancellationToken = default) =>
|
||||
await SendAsync<ConfigResponse>(HttpMethod.Get, "/config", cancellationToken: cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<MailSummary>> GetMessagesAsync(string folder, string query = "", CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"/messages?folder={Uri.EscapeDataString(folder)}";
|
||||
if (!string.IsNullOrWhiteSpace(query)) url += $"&q={Uri.EscapeDataString(query)}";
|
||||
var result = await SendAsync<MessageListResponse>(HttpMethod.Get, url, cancellationToken: cancellationToken);
|
||||
return result.Messages;
|
||||
}
|
||||
|
||||
public async Task<MailMessage> GetMessageAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await SendAsync<MessageDetailResponse>(HttpMethod.Get, $"/messages/{Uri.EscapeDataString(id)}", cancellationToken: cancellationToken);
|
||||
return result.Message;
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(string to, string subject, string text, CancellationToken cancellationToken = default) =>
|
||||
await SendAsync<object>(HttpMethod.Post, "/send", new { to, subject, text }, cancellationToken);
|
||||
|
||||
public async Task ChangePasswordAsync(string password, CancellationToken cancellationToken = default) =>
|
||||
await SendAsync<object>(HttpMethod.Post, "/account/password", new { password }, cancellationToken);
|
||||
|
||||
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try { await SendAsync<object>(HttpMethod.Post, "/logout", cancellationToken: cancellationToken); }
|
||||
finally { token = ""; }
|
||||
}
|
||||
|
||||
public void SetToken(string value)
|
||||
{
|
||||
token = value;
|
||||
http.DefaultRequestHeaders.Authorization = string.IsNullOrWhiteSpace(token) ? null : new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
private async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, $"{BaseUrl}{path}");
|
||||
if (body is not null) request.Content = JsonContent.Create(body, options: json);
|
||||
using var response = await http.SendAsync(request, cancellationToken);
|
||||
return await ReadOrThrow<T>(response, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<T> ReadOrThrow<T>(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<T>(json, cancellationToken);
|
||||
return result ?? throw new InvalidOperationException("服务端返回了空响应");
|
||||
}
|
||||
|
||||
var message = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var error = JsonSerializer.Deserialize<Dictionary<string, string>>(message, json);
|
||||
if (error?.TryGetValue("error", out var detail) == true) throw new InvalidOperationException(detail);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
throw new InvalidOperationException($"服务端返回 HTTP {(int)response.StatusCode}");
|
||||
}
|
||||
|
||||
private static string NormalizeBaseUrl(string value)
|
||||
{
|
||||
var url = string.IsNullOrWhiteSpace(value) ? "http://127.0.0.1:8787/api" : value.Trim().TrimEnd('/');
|
||||
return url.EndsWith("/api", StringComparison.OrdinalIgnoreCase) ? url : $"{url}/api";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<Application
|
||||
x:Class="WpywMail.Client.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:WpywMail.Client"
|
||||
RequestedTheme="Dark">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<local:UnreadBackgroundConverter x:Key="UnreadBackgroundConverter" />
|
||||
<local:UnreadWeightConverter x:Key="UnreadWeightConverter" />
|
||||
<SolidColorBrush x:Key="PageBackgroundBrush" Color="#1E1E1E" />
|
||||
<SolidColorBrush x:Key="RailBackgroundBrush" Color="#151515" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#252525" />
|
||||
<SolidColorBrush x:Key="SurfaceElevatedBrush" Color="#2D2D2D" />
|
||||
<SolidColorBrush x:Key="StrokeBrush" Color="#3A3A3A" />
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F2F2F2" />
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#B7B7B7" />
|
||||
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#848484" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#5BA7FF" />
|
||||
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#263D55" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E77878" />
|
||||
|
||||
<Style x:Key="QuietButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="RailButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Width" Value="44" />
|
||||
<Setter Property="Height" Value="44" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FolderButtonStyle" TargetType="Button">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="10,8" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="#101010" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="16,9" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TextBoxStyle" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="#292929" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource StrokeBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="11,8" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PasswordBoxStyle" TargetType="PasswordBox">
|
||||
<Setter Property="Background" Value="#292929" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource StrokeBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="11,8" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace WpywMail.Client;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public static Window? MainWindow { get; private set; }
|
||||
|
||||
public App()
|
||||
{
|
||||
UnhandledException += (_, args) =>
|
||||
{
|
||||
try { File.AppendAllText(Path.Combine(Path.GetTempPath(), "WpywMail.Client-error.log"), $"{DateTime.Now:O}\r\n{args.Exception}\r\n\r\n"); } catch { }
|
||||
};
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
{
|
||||
try { File.AppendAllText(Path.Combine(Path.GetTempPath(), "WpywMail.Client-error.log"), $"{DateTime.Now:O}\r\n{args.ExceptionObject}\r\n\r\n"); } catch { }
|
||||
};
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
MainWindow = new MainWindow();
|
||||
MainWindow.Activate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
|
||||
namespace WpywMail.Client;
|
||||
|
||||
public sealed class UnreadBackgroundConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language) =>
|
||||
value is true ? Application.Current.Resources["AccentSubtleBrush"] : Application.Current.Resources["SurfaceBrush"];
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class UnreadWeightConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language) =>
|
||||
value is true ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<Window
|
||||
x:Class="WpywMail.Client.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Title="Wpyw Mail">
|
||||
<Grid Background="{StaticResource PageBackgroundBrush}">
|
||||
<Grid x:Name="LoginView" Visibility="Visible">
|
||||
<Grid.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#151515" Offset="0" />
|
||||
<GradientStop Color="#20262D" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Grid.Background>
|
||||
<Border Width="420" Padding="34" Background="#252525" BorderBrush="#3A3A3A" BorderThickness="1" CornerRadius="12" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="18">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Wpyw Mail" FontSize="28" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
<TextBlock Text="登录你的邮箱" FontSize="14" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="服务端地址" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox x:Name="ApiUrlBox" Text="http://127.0.0.1:8787/api" Style="{StaticResource TextBoxStyle}" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="邮箱" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox x:Name="EmailBox" Text="[email protected]" Style="{StaticResource TextBoxStyle}" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="密码" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<PasswordBox x:Name="PasswordBox" Style="{StaticResource PasswordBoxStyle}" KeyDown="PasswordBox_KeyDown" />
|
||||
</StackPanel>
|
||||
<Button x:Name="LoginButton" Content="登录" Click="LoginButton_Click" Style="{StaticResource PrimaryButtonStyle}" HorizontalAlignment="Stretch" />
|
||||
<TextBlock x:Name="LoginStatus" TextWrapping="Wrap" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="ShellView" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="52" />
|
||||
<RowDefinition Height="44" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0" Background="#191919" Padding="12,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<Border Width="28" Height="28" CornerRadius="7" Background="{StaticResource AccentBrush}">
|
||||
<TextBlock Text="W" Foreground="#101010" FontWeight="Bold" FontSize="16" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock Text="Wpyw Mail" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
<TextBox Grid.Column="1" x:Name="SearchBox" Width="360" HorizontalAlignment="Center" PlaceholderText="搜索邮件" Style="{StaticResource TextBoxStyle}" TextChanged="SearchBox_TextChanged" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="AccountText" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<Button Content="设置" Click="SettingsButton_Click" Style="{StaticResource QuietButtonStyle}" />
|
||||
<Button Content="退出" Click="LogoutButton_Click" Style="{StaticResource QuietButtonStyle}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Background="#222222" Padding="12,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Content="写信" Click="ComposeButton_Click" Style="{StaticResource PrimaryButtonStyle}" />
|
||||
<Button Content="刷新" Click="RefreshButton_Click" Style="{StaticResource QuietButtonStyle}" />
|
||||
<TextBlock x:Name="ConnectionText" VerticalAlignment="Center" Margin="8,0,0,0" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="58" />
|
||||
<ColumnDefinition Width="220" />
|
||||
<ColumnDefinition Width="360" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid Grid.Column="0" Background="{StaticResource RailBackgroundBrush}">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Top" Spacing="9" Margin="0,14,0,0">
|
||||
<Button Content="✉" Style="{StaticResource RailButtonStyle}" Foreground="{StaticResource AccentBrush}" />
|
||||
<Button Content="✓" Style="{StaticResource RailButtonStyle}" />
|
||||
<Button Content="▣" Style="{StaticResource RailButtonStyle}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="1" Background="#1B1B1B" Padding="12,18">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="文件夹" FontSize="16" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
<Button Grid.Row="1" Content="+ 新建文件夹" Style="{StaticResource QuietButtonStyle}" HorizontalAlignment="Left" Margin="-10,12,0,8" />
|
||||
<StackPanel Grid.Row="2" Spacing="2">
|
||||
<Button Tag="inbox" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}">
|
||||
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="28" /><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions><TextBlock Text="收件箱" Grid.Column="1" /><TextBlock x:Name="InboxCount" Text="" Grid.Column="2" Foreground="{StaticResource AccentBrush}" /></Grid>
|
||||
</Button>
|
||||
<Button Tag="sent" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="已发送" /></Button>
|
||||
<Button Tag="drafts" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="草稿" /></Button>
|
||||
<Button Tag="archive" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="归档" /></Button>
|
||||
<Button Tag="trash" Click="FolderButton_Click" Style="{StaticResource FolderButtonStyle}"><TextBlock Text="垃圾箱" /></Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="2" Background="#202020" BorderBrush="{StaticResource StrokeBrush}" BorderThickness="1,0,1,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="44" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Padding="14,0">
|
||||
<TextBlock x:Name="FolderTitle" Text="收件箱" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
<TextBlock x:Name="FolderSubtitle" Text="" HorizontalAlignment="Right" VerticalAlignment="Center" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||
</Grid>
|
||||
<ListView Grid.Row="1" x:Name="MessageList" SelectionChanged="MessageList_SelectionChanged" SelectionMode="Single" BorderThickness="0" Background="Transparent" Padding="8,0">
|
||||
<ListView.ItemContainerStyle>
|
||||
<Style TargetType="ListViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0,2" />
|
||||
</Style>
|
||||
</ListView.ItemContainerStyle>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Padding="12,10" CornerRadius="7" Background="{Binding Unread, Converter={StaticResource UnreadBackgroundConverter}}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="34" /><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<Border Width="28" Height="28" CornerRadius="14" Background="#3B4D60" VerticalAlignment="Top"><TextBlock Text="{Binding Initials}" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="#E6EEF7" /></Border>
|
||||
<StackPanel Grid.Column="1" Spacing="3" Margin="9,0,5,0">
|
||||
<TextBlock Text="{Binding SenderName}" FontWeight="{Binding Unread, Converter={StaticResource UnreadWeightConverter}}" Foreground="{StaticResource TextPrimaryBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Subject}" Foreground="{StaticResource TextSecondaryBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Preview}" Foreground="{StaticResource TextTertiaryBrush}" FontSize="12" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding DeliveryStatusLabel}" Foreground="{StaticResource AccentBrush}" FontSize="11" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" FontSize="11" Foreground="{StaticResource TextTertiaryBrush}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="3" Background="#242424" Padding="30,26">
|
||||
<Grid x:Name="EmptyReadingPane" Visibility="Visible">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
|
||||
<TextBlock Text="选择一封邮件" FontSize="22" Foreground="{StaticResource TextPrimaryBrush}" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="邮件内容会显示在这里" Foreground="{StaticResource TextTertiaryBrush}" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<ScrollViewer x:Name="ReadingPane" Visibility="Collapsed" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="18">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock x:Name="ReadingSubject" FontSize="26" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<Border Width="30" Height="30" CornerRadius="15" Background="#3B4D60"><TextBlock x:Name="ReadingInitials" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="#E6EEF7" /></Border>
|
||||
<StackPanel><TextBlock x:Name="ReadingFrom" Foreground="{StaticResource TextPrimaryBrush}" /><TextBlock x:Name="ReadingDate" Foreground="{StaticResource TextTertiaryBrush}" FontSize="12" /></StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Border Height="1" Background="{StaticResource StrokeBrush}" />
|
||||
<TextBlock x:Name="ReadingBody" TextWrapping="Wrap" FontSize="15" LineHeight="24" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Windows.System;
|
||||
|
||||
namespace WpywMail.Client;
|
||||
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
private readonly ObservableCollection<MailSummary> messages = [];
|
||||
private ApiClient api = new();
|
||||
private string folder = "inbox";
|
||||
private CancellationTokenSource? searchCancellation;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
MessageList.ItemsSource = messages;
|
||||
}
|
||||
|
||||
private async void LoginButton_Click(object sender, RoutedEventArgs e) => await LoginAsync();
|
||||
|
||||
private async void PasswordBox_KeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (e.Key == VirtualKey.Enter) await LoginAsync();
|
||||
}
|
||||
|
||||
private async Task LoginAsync()
|
||||
{
|
||||
var email = EmailBox.Text.Trim();
|
||||
var password = PasswordBox.Password;
|
||||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
LoginStatus.Text = "请输入邮箱和密码。";
|
||||
return;
|
||||
}
|
||||
|
||||
LoginButton.IsEnabled = false;
|
||||
LoginStatus.Text = "正在连接邮箱服务…";
|
||||
try
|
||||
{
|
||||
api.SetBaseUrl(ApiUrlBox.Text);
|
||||
var result = await api.LoginAsync(email, password);
|
||||
api.SetToken(result.Token);
|
||||
AccountText.Text = result.User.Email;
|
||||
LoginView.Visibility = Visibility.Collapsed;
|
||||
ShellView.Visibility = Visibility.Visible;
|
||||
LoginStatus.Text = "";
|
||||
await RefreshAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoginStatus.Text = ex.Message.Contains("无法连接", StringComparison.OrdinalIgnoreCase)
|
||||
? "无法连接服务端,请检查地址、端口和服务状态。"
|
||||
: ex.Message;
|
||||
}
|
||||
finally { LoginButton.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var me = await api.GetMeAsync();
|
||||
InboxCount.Text = me.Stats.Unread > 0 ? me.Stats.Unread.ToString() : "";
|
||||
await LoadFolderAsync(folder, SearchBox.Text);
|
||||
ConnectionText.Text = "已连接";
|
||||
ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentBrush"];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConnectionText.Text = ex.Message;
|
||||
ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadFolderAsync(string selectedFolder, string query = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await api.GetMessagesAsync(selectedFolder, query);
|
||||
messages.Clear();
|
||||
foreach (var message in result) messages.Add(message);
|
||||
FolderTitle.Text = selectedFolder switch
|
||||
{
|
||||
"sent" => "已发送",
|
||||
"drafts" => "草稿",
|
||||
"archive" => "归档",
|
||||
"trash" => "垃圾箱",
|
||||
_ => "收件箱"
|
||||
};
|
||||
FolderSubtitle.Text = messages.Count == 0 ? "暂无邮件" : $"{messages.Count} 封邮件";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConnectionText.Text = ex.Message;
|
||||
ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"];
|
||||
}
|
||||
}
|
||||
|
||||
private async void FolderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button button || button.Tag is not string selectedFolder) return;
|
||||
folder = selectedFolder;
|
||||
EmptyReadingPane.Visibility = Visibility.Visible;
|
||||
ReadingPane.Visibility = Visibility.Collapsed;
|
||||
await LoadFolderAsync(folder, SearchBox.Text);
|
||||
}
|
||||
|
||||
private async void MessageList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (MessageList.SelectedItem is not MailSummary summary) return;
|
||||
try
|
||||
{
|
||||
var message = await api.GetMessageAsync(summary.Id);
|
||||
ReadingSubject.Text = message.Subject;
|
||||
ReadingInitials.Text = message.Initials;
|
||||
ReadingFrom.Text = message.From;
|
||||
ReadingDate.Text = message.Date.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
||||
ReadingBody.Text = message.Text;
|
||||
EmptyReadingPane.Visibility = Visibility.Collapsed;
|
||||
ReadingPane.Visibility = Visibility.Visible;
|
||||
summary.Unread = false;
|
||||
MessageList.SelectedItem = null;
|
||||
}
|
||||
catch (Exception ex) { ConnectionText.Text = ex.Message; }
|
||||
}
|
||||
|
||||
private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await RefreshAsync();
|
||||
|
||||
private async void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
searchCancellation?.Cancel();
|
||||
searchCancellation = new CancellationTokenSource();
|
||||
var token = searchCancellation.Token;
|
||||
try
|
||||
{
|
||||
await Task.Delay(260, token);
|
||||
await LoadFolderAsync(folder, SearchBox.Text);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private async void LogoutButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await api.LogoutAsync();
|
||||
messages.Clear();
|
||||
ShellView.Visibility = Visibility.Collapsed;
|
||||
LoginView.Visibility = Visibility.Visible;
|
||||
PasswordBox.Password = "";
|
||||
LoginStatus.Text = "已退出登录。";
|
||||
}
|
||||
|
||||
private async void SettingsButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ConfigResponse config;
|
||||
try { config = await api.GetConfigAsync(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConnectionText.Text = ex.Message;
|
||||
ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"];
|
||||
return;
|
||||
}
|
||||
var newPassword = new PasswordBox { PlaceholderText = "新密码(至少 12 位)", MinWidth = 360 };
|
||||
var content = new StackPanel { Spacing = 12, Width = 430 };
|
||||
content.Children.Add(new TextBlock { Text = $"账户\n{config.Account}", Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextSecondaryBrush"] });
|
||||
content.Children.Add(new TextBlock { Text = $"域名:{config.Domain}\n主机名:{config.Hostname}\nSMTP:{config.Protocols.Smtp} 提交端口:{config.Protocols.Submission}", Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextTertiaryBrush"] });
|
||||
content.Children.Add(new Border { Height = 1, Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StrokeBrush"] });
|
||||
content.Children.Add(new TextBlock { Text = "修改密码", FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
|
||||
content.Children.Add(newPassword);
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = "账户与服务",
|
||||
Content = content,
|
||||
PrimaryButtonText = "保存密码",
|
||||
CloseButtonText = "关闭",
|
||||
DefaultButton = ContentDialogButton.Close,
|
||||
XamlRoot = Content.XamlRoot
|
||||
};
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary || string.IsNullOrWhiteSpace(newPassword.Password)) return;
|
||||
if (newPassword.Password.Length < 12)
|
||||
{
|
||||
ConnectionText.Text = "密码至少需要 12 位。";
|
||||
ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"];
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await api.ChangePasswordAsync(newPassword.Password);
|
||||
ConnectionText.Text = "密码已更新。下次登录请使用新密码。";
|
||||
ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentBrush"];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConnectionText.Text = ex.Message;
|
||||
ConnectionText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DangerBrush"];
|
||||
}
|
||||
}
|
||||
|
||||
private async void ComposeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = "写信",
|
||||
PrimaryButtonText = "发送",
|
||||
CloseButtonText = "取消",
|
||||
DefaultButton = ContentDialogButton.Primary,
|
||||
XamlRoot = Content.XamlRoot,
|
||||
Content = new ComposeView()
|
||||
};
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary || dialog.Content is not ComposeView compose) return;
|
||||
if (string.IsNullOrWhiteSpace(compose.ToBox.Text) || string.IsNullOrWhiteSpace(compose.SubjectBox.Text) || string.IsNullOrWhiteSpace(compose.BodyBox.Text))
|
||||
{
|
||||
ConnectionText.Text = "收件人、主题和正文不能为空。";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await api.SendMessageAsync(compose.ToBox.Text, compose.SubjectBox.Text, compose.BodyBox.Text);
|
||||
ConnectionText.Text = "邮件已加入发送队列。";
|
||||
if (folder == "sent") await LoadFolderAsync(folder, SearchBox.Text);
|
||||
}
|
||||
catch (Exception ex) { ConnectionText.Text = ex.Message; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ComposeView : StackPanel
|
||||
{
|
||||
public TextBox ToBox { get; } = new() { PlaceholderText = "收件人,例如 [email protected]" };
|
||||
public TextBox SubjectBox { get; } = new() { PlaceholderText = "主题" };
|
||||
public TextBox BodyBox { get; } = new() { PlaceholderText = "正文", AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinHeight = 180 };
|
||||
|
||||
public ComposeView()
|
||||
{
|
||||
Spacing = 10;
|
||||
Width = 520;
|
||||
Children.Add(ToBox);
|
||||
Children.Add(SubjectBox);
|
||||
Children.Add(BodyBox);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WpywMail.Client;
|
||||
|
||||
public sealed class LoginResponse
|
||||
{
|
||||
public string Token { get; set; } = "";
|
||||
public LoginUser User { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LoginUser
|
||||
{
|
||||
public string Email { get; set; } = "";
|
||||
public string Role { get; set; } = "user";
|
||||
public string Domain { get; set; } = "wpyw.site";
|
||||
}
|
||||
|
||||
public sealed class MessageListResponse
|
||||
{
|
||||
public List<MailSummary> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MessageDetailResponse
|
||||
{
|
||||
public MailMessage Message { get; set; } = new();
|
||||
}
|
||||
|
||||
public class MailSummary
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string From { get; set; } = "";
|
||||
public string To { get; set; } = "";
|
||||
public string Subject { get; set; } = "(无主题)";
|
||||
public DateTimeOffset Date { get; set; }
|
||||
public bool Unread { get; set; }
|
||||
public bool Starred { get; set; }
|
||||
public string DeliveryStatus { get; set; } = "received";
|
||||
public string Preview { get; set; } = "";
|
||||
|
||||
[JsonIgnore]
|
||||
public string SenderName => string.IsNullOrWhiteSpace(From) ? "未知发件人" : From.Split('@')[0];
|
||||
|
||||
[JsonIgnore]
|
||||
public string Initials => string.Concat(SenderName.Split(['.', '-', '_'], StringSplitOptions.RemoveEmptyEntries).Take(2).Select(x => char.ToUpperInvariant(x[0])));
|
||||
|
||||
[JsonIgnore]
|
||||
public string DeliveryStatusLabel => DeliveryStatus switch
|
||||
{
|
||||
"delivered" => "已投递",
|
||||
"failed" => "投递失败",
|
||||
"queued" or "pending" or "processing" => "发送中",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
[JsonIgnore]
|
||||
public string DateLabel => Date.Date == DateTimeOffset.Now.Date ? Date.ToLocalTime().ToString("HH:mm") : Date.ToLocalTime().ToString("MM/dd");
|
||||
}
|
||||
|
||||
public sealed class MailMessage : MailSummary
|
||||
{
|
||||
public string OwnerEmail { get; set; } = "";
|
||||
public string Folder { get; set; } = "inbox";
|
||||
public string Text { get; set; } = "";
|
||||
public string MessageId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class AccountStats
|
||||
{
|
||||
public int Inbox { get; set; }
|
||||
public int Unread { get; set; }
|
||||
public int Sent { get; set; }
|
||||
public int Queue { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeResponse
|
||||
{
|
||||
public LoginUser User { get; set; } = new();
|
||||
public AccountStats Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ConfigResponse
|
||||
{
|
||||
public string Domain { get; set; } = "";
|
||||
public string Hostname { get; set; } = "";
|
||||
public string Account { get; set; } = "";
|
||||
public ProtocolConfig Protocols { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ProtocolConfig
|
||||
{
|
||||
public int Smtp { get; set; }
|
||||
public int Submission { get; set; }
|
||||
public string Api { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Wpyw Mail WinUI 3 客户端
|
||||
|
||||
这是独立于服务端的 Windows 客户端,使用 C# + WinUI 3 + Windows App SDK。
|
||||
|
||||
默认连接本机服务端:`http://127.0.0.1:8787/api`。登录页可以改成远程 API 地址。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```powershell
|
||||
dotnet restore .\client-winui\WpywMail.Client.csproj
|
||||
dotnet build .\client-winui\WpywMail.Client.csproj -c Debug -p:Platform=x64
|
||||
dotnet run --project .\client-winui\WpywMail.Client.csproj -c Debug -p:Platform=x64
|
||||
```
|
||||
|
||||
客户端第一版包含:登录、收件箱/已发送等文件夹、搜索、邮件阅读、写信入队、刷新和退出登录。
|
||||
|
||||
服务端需要先启动 `server-native`,并确保 API 地址可访问。真正部署到公网时,建议为 API 单独配置 HTTPS 入口;不要把 SMTP/IMAP 端口放进 Cloudflare Tunnel 的普通 HTTP 路由。
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||
<RootNamespace>WpywMail.Client</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Platforms>x64</Platforms>
|
||||
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="WpywMail.Client.app" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
Reference in New Issue
Block a user