Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public sealed class ApiServer
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly FileStore store;
|
||||
private readonly HttpListener listener = new();
|
||||
private readonly ConcurrentDictionary<string, Session> sessions = new();
|
||||
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public ApiServer(AppConfig config, FileStore store) { this.config = config; this.store = store; listener.Prefixes.Add(config.HttpPrefix); }
|
||||
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
listener.Start();
|
||||
AppLog.Info($"[接口] 已监听:{config.HttpPrefix}");
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var context = await listener.GetContextAsync().WaitAsync(token);
|
||||
_ = Task.Run(() => Handle(context), token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
private async Task Handle(HttpListenerContext context)
|
||||
{
|
||||
var request = context.Request; var response = context.Response;
|
||||
response.Headers["Access-Control-Allow-Origin"] = "*";
|
||||
response.Headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type";
|
||||
response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, OPTIONS";
|
||||
if (request.HttpMethod == "OPTIONS") { response.StatusCode = 204; response.Close(); return; }
|
||||
try
|
||||
{
|
||||
var path = request.Url?.AbsolutePath.TrimEnd('/') ?? "";
|
||||
if (path == "/api/health") { await Reply(response, new { ok = true, service = "wpyw.mail.native", hostname = config.Hostname, domain = config.Domain }); return; }
|
||||
if (path == "/api/login" && request.HttpMethod == "POST") { await Login(request, response); return; }
|
||||
var user = Authenticate(request);
|
||||
if (user is null) { await Reply(response, new { error = "登录已失效" }, 401); return; }
|
||||
if (path == "/api/me") { await Reply(response, new { user = new { email = user.Email, role = user.Role }, stats = store.Stats(user.Email) }); return; }
|
||||
if (path == "/api/config") { await Reply(response, new { domain = config.Domain, hostname = config.Hostname, account = user.Email, protocols = new { smtp = config.SmtpPort, submission = config.SubmissionPort, api = config.HttpPrefix } }); return; }
|
||||
if (path == "/api/logout" && request.HttpMethod == "POST") { RemoveSession(request); await Reply(response, new { ok = true }); return; }
|
||||
if (path == "/api/messages" && request.HttpMethod == "GET") { await ListMessages(request, response, user); return; }
|
||||
if (path.StartsWith("/api/messages/", StringComparison.OrdinalIgnoreCase)) { await MessageDetail(request, response, user, path[14..]); return; }
|
||||
if (path == "/api/send" && request.HttpMethod == "POST") { await SendMessage(request, response, user); return; }
|
||||
if (path == "/api/account/password" && request.HttpMethod == "POST") { await ChangePassword(request, response, user); return; }
|
||||
if (path == "/api/admin/users" && user.Role == "admin") { await AdminUsers(request, response); return; }
|
||||
await Reply(response, new { error = "接口不存在" }, 404);
|
||||
}
|
||||
catch (Exception ex) { AppLog.Error($"[接口] {request.HttpMethod} {request.Url}:{ex.Message}"); await Reply(response, new { error = ex.Message }, 500); }
|
||||
}
|
||||
|
||||
private async Task Login(HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
var body = await ReadJson<LoginRequest>(request) ?? new LoginRequest("", "");
|
||||
var user = store.Authenticate(body.Email, body.Password);
|
||||
if (user is null) { await Reply(response, new { error = "邮箱或密码不正确" }, 401); return; }
|
||||
var token = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||
sessions[token] = new Session(user.Email, DateTimeOffset.UtcNow.AddDays(7));
|
||||
await Reply(response, new { token, user = new { email = user.Email, role = user.Role, domain = config.Domain } });
|
||||
}
|
||||
|
||||
private MailUser? Authenticate(HttpListenerRequest request)
|
||||
{
|
||||
var token = request.Headers["Authorization"]?.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase).Trim();
|
||||
if (string.IsNullOrWhiteSpace(token) || !sessions.TryGetValue(token, out var session) || session.Expires < DateTimeOffset.UtcNow) return null;
|
||||
return store.FindUser(session.Email);
|
||||
}
|
||||
|
||||
private void RemoveSession(HttpListenerRequest request)
|
||||
{
|
||||
var token = request.Headers["Authorization"]?.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(token)) sessions.TryRemove(token, out _);
|
||||
}
|
||||
|
||||
private async Task ListMessages(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var folder = request.QueryString["folder"] ?? "inbox"; var query = request.QueryString["q"] ?? "";
|
||||
var result = store.ListMessages(user.Email, folder, query).Select(x => new { x.Id, x.From, x.To, x.Subject, x.Date, x.Unread, x.Starred, x.DeliveryStatus, preview = x.Text.Replace("\r", " ").Replace("\n", " ")[..Math.Min(140, x.Text.Length)] });
|
||||
await Reply(response, new { messages = result });
|
||||
}
|
||||
|
||||
private async Task MessageDetail(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string id)
|
||||
{
|
||||
var message = store.GetMessage(user.Email, id);
|
||||
if (message is null) { await Reply(response, new { error = "邮件不存在" }, 404); return; }
|
||||
store.MarkRead(user.Email, id);
|
||||
await Reply(response, new { message });
|
||||
}
|
||||
|
||||
private async Task SendMessage(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var body = await ReadJson<SendRequest>(request) ?? new SendRequest("", "", "");
|
||||
var recipients = Mime.Addresses(body.To);
|
||||
if (recipients.Length == 0 || string.IsNullOrWhiteSpace(body.Subject) || string.IsNullOrWhiteSpace(body.Text)) { await Reply(response, new { error = "收件人、主题和正文不能为空" }, 400); return; }
|
||||
var raw = Mime.Build(user.Email, recipients, body.Subject, body.Text);
|
||||
var message = store.QueueOutbound(user.Email, recipients, body.Subject, body.Text, raw);
|
||||
await Reply(response, new { queued = true, messageId = message.Id }, 202);
|
||||
}
|
||||
|
||||
private async Task ChangePassword(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
|
||||
{
|
||||
var body = await ReadJson<Dictionary<string, string>>(request) ?? new();
|
||||
if (!body.TryGetValue("password", out var password) || password.Length < 12) { await Reply(response, new { error = "密码至少需要 12 个字符" }, 400); return; }
|
||||
store.ChangePassword(user.Email, password); await Reply(response, new { ok = true });
|
||||
}
|
||||
|
||||
private async Task AdminUsers(HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
if (request.HttpMethod == "GET") { await Reply(response, new { users = store.ListUsers().Select(x => new { x.Email, x.DisplayName, x.Role, x.Active }) }); return; }
|
||||
if (request.HttpMethod == "POST")
|
||||
{
|
||||
var body = await ReadJson<Dictionary<string, string>>(request) ?? new();
|
||||
if (!body.TryGetValue("email", out var email) || !body.TryGetValue("password", out var password) || password.Length < 12) { await Reply(response, new { error = "邮箱和至少 12 位密码是必需的" }, 400); return; }
|
||||
var user = store.CreateUser(email, password, body.GetValueOrDefault("displayName", "")); await Reply(response, new { user = new { user.Email, user.DisplayName, user.Role } }, 201); return;
|
||||
}
|
||||
await Reply(response, new { error = "不支持的请求方法" }, 405);
|
||||
}
|
||||
|
||||
private static async Task<T?> ReadJson<T>(HttpListenerRequest request) { using var reader = new StreamReader(request.InputStream, Encoding.UTF8); return JsonSerializer.Deserialize<T>(await reader.ReadToEndAsync(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); }
|
||||
private async Task Reply(HttpListenerResponse response, object value, int status = 200) { response.StatusCode = status; response.ContentType = "application/json; charset=utf-8"; var bytes = JsonSerializer.SerializeToUtf8Bytes(value, json); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); response.Close(); }
|
||||
private sealed record Session(string Email, DateTimeOffset Expires);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public static class AppLog
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static string path = "";
|
||||
|
||||
public static void Configure(AppConfig config)
|
||||
{
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
path = Path.Combine(config.DataDirectory, "service.log");
|
||||
Info("日志系统已启动。");
|
||||
}
|
||||
|
||||
public static void Info(string message) => Write("信息", message);
|
||||
public static void Warn(string message) => Write("警告", message);
|
||||
public static void Error(string message) => Write("错误", message);
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
var line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz} [{level}] {message}";
|
||||
Console.WriteLine(line);
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
try
|
||||
{
|
||||
lock (Gate) File.AppendAllText(path, line + Environment.NewLine, new UTF8Encoding(false));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public sealed class DeliveryQueue
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly FileStore store;
|
||||
private readonly DirectSmtpDelivery direct;
|
||||
|
||||
public DeliveryQueue(AppConfig config, FileStore store)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
direct = new DirectSmtpDelivery(config, store);
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
AppLog.Info($"[发送] 投递模式:{(config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) ? "SMTP 中继" : "按 MX 直接投递")}");
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
foreach (var job in store.TakeDueQueue(10))
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = store.GetById(job.MessageId) ?? throw new InvalidOperationException("发送队列中的邮件不存在。");
|
||||
await Deliver(message, job.Recipients, token);
|
||||
store.CompleteQueue(job);
|
||||
AppLog.Info($"[发送] 投递成功:{string.Join(", ", job.Recipients)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
store.FailQueue(job, ex);
|
||||
AppLog.Error($"[发送队列] {job.MessageId} → {string.Join(", ", job.Recipients)}:{ex.Message}");
|
||||
}
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), token).ContinueWith(_ => { });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Deliver(MailMessage message, string[] recipients, CancellationToken token)
|
||||
{
|
||||
if (config.DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await DeliverThroughRelay(message, recipients, token);
|
||||
return;
|
||||
}
|
||||
|
||||
await direct.DeliverAsync(message, recipients, token);
|
||||
}
|
||||
|
||||
private async Task DeliverThroughRelay(MailMessage message, string[] recipients, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.Relay.Host)) throw new InvalidOperationException("DeliveryMode=relay 时必须配置 Relay.Host。");
|
||||
using var client = new SmtpClient(config.Relay.Host, config.Relay.Port)
|
||||
{
|
||||
EnableSsl = config.Relay.EnableSsl,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network,
|
||||
Timeout = 60_000
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(config.Relay.User)) client.Credentials = new NetworkCredential(config.Relay.User, config.Relay.Password);
|
||||
using var mail = new System.Net.Mail.MailMessage
|
||||
{
|
||||
From = new MailAddress(message.From),
|
||||
Subject = message.Subject,
|
||||
Body = message.Text,
|
||||
BodyEncoding = System.Text.Encoding.UTF8,
|
||||
SubjectEncoding = System.Text.Encoding.UTF8
|
||||
};
|
||||
foreach (var recipient in recipients) mail.To.Add(recipient);
|
||||
await client.SendMailAsync(mail, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public sealed class DirectSmtpDelivery
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly FileStore store;
|
||||
|
||||
public DirectSmtpDelivery(AppConfig config, FileStore store)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public async Task DeliverAsync(MailMessage message, string[] recipients, CancellationToken token)
|
||||
{
|
||||
if (recipients.Length == 0) throw new InvalidOperationException("没有可投递的收件人。");
|
||||
var raw = store.ReadRaw(message.RawPath);
|
||||
foreach (var group in recipients.Where(IsValidAddress).GroupBy(GetDomain, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
await DeliverDomainAsync(message.From, group.Key, group.ToArray(), raw, token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeliverDomainAsync(string sender, string domain, string[] recipients, byte[] raw, CancellationToken token)
|
||||
{
|
||||
var mxHosts = await MxResolver.ResolveAsync(domain, config.DirectDelivery, token);
|
||||
if (mxHosts.Count == 0) throw new InvalidOperationException($"找不到 {domain} 的 MX 记录。");
|
||||
|
||||
Exception? last = null;
|
||||
foreach (var mxHost in mxHosts)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendToMxAsync(mxHost, sender, recipients, raw, token, tryStartTls: true);
|
||||
return;
|
||||
}
|
||||
catch (StartTlsUnavailableException) when (!config.DirectDelivery.RequireStartTls)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendToMxAsync(mxHost, sender, recipients, raw, token, tryStartTls: false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException or AuthenticationException)
|
||||
{
|
||||
last = ex;
|
||||
AppLog.Warn($"[发送] MX {mxHost} 明文重试失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or InvalidOperationException or AuthenticationException)
|
||||
{
|
||||
last = ex;
|
||||
AppLog.Warn($"[发送] MX {mxHost} 失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"无法投递到 {domain}:{last?.Message ?? "所有 MX 服务器均失败"}");
|
||||
}
|
||||
|
||||
private async Task SendToMxAsync(string mxHost, string sender, string[] recipients, byte[] raw, CancellationToken token, bool tryStartTls)
|
||||
{
|
||||
using var client = new TcpClient { NoDelay = true };
|
||||
await client.ConnectAsync(mxHost, 25, token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.ConnectionTimeoutSeconds), token);
|
||||
Stream stream = client.GetStream();
|
||||
StreamReader reader = NewReader(stream);
|
||||
StreamWriter writer = NewWriter(stream);
|
||||
|
||||
try
|
||||
{
|
||||
Expect(await ReadReplyAsync(reader, token), "连接欢迎语", 220);
|
||||
var hello = await CommandAsync(reader, writer, $"EHLO {config.Hostname}", token);
|
||||
if (hello.Code < 200 || hello.Code >= 300)
|
||||
{
|
||||
Expect(await CommandAsync(reader, writer, $"HELO {config.Hostname}", token), "HELO", 250);
|
||||
}
|
||||
else if (tryStartTls && config.DirectDelivery.OpportunisticStartTls && HasCapability(hello, "STARTTLS"))
|
||||
{
|
||||
var startTls = await CommandAsync(reader, writer, "STARTTLS", token);
|
||||
if (startTls.Code == 220)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false, ValidateCertificate);
|
||||
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
|
||||
{
|
||||
TargetHost = mxHost,
|
||||
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
|
||||
}, token).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
|
||||
stream = ssl;
|
||||
reader = NewReader(stream);
|
||||
writer = NewWriter(stream);
|
||||
hello = await CommandAsync(reader, writer, $"EHLO {config.Hostname}", token);
|
||||
}
|
||||
catch (AuthenticationException) when (!config.DirectDelivery.RequireStartTls)
|
||||
{
|
||||
throw new StartTlsUnavailableException("对方 STARTTLS 证书验证失败。");
|
||||
}
|
||||
}
|
||||
else if (config.DirectDelivery.RequireStartTls)
|
||||
{
|
||||
throw new InvalidOperationException("对方 SMTP 不接受 STARTTLS。");
|
||||
}
|
||||
}
|
||||
else if (config.DirectDelivery.RequireStartTls)
|
||||
{
|
||||
throw new InvalidOperationException("对方 SMTP 未提供 STARTTLS。");
|
||||
}
|
||||
|
||||
Expect(await CommandAsync(reader, writer, $"MAIL FROM:<{NormalizeAddress(sender)}>", token), "MAIL FROM", 250);
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
Expect(await CommandAsync(reader, writer, $"RCPT TO:<{NormalizeAddress(recipient)}>", token), $"RCPT TO {recipient}", 250, 251);
|
||||
}
|
||||
|
||||
Expect(await CommandAsync(reader, writer, "DATA", token), "DATA", 354);
|
||||
await WriteDataAsync(writer, raw, token);
|
||||
Expect(await ReadReplyAsync(reader, token), "邮件正文", 250);
|
||||
await TryQuitAsync(reader, writer, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await stream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteDataAsync(StreamWriter writer, byte[] raw, CancellationToken token)
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(raw).Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
var lines = text.Split('\n');
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
if (index == lines.Length - 1 && lines[index].Length == 0) continue;
|
||||
var line = lines[index];
|
||||
if (line.StartsWith('.')) line = "." + line;
|
||||
await writer.WriteLineAsync(line).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
|
||||
}
|
||||
await writer.WriteLineAsync(".").WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
|
||||
}
|
||||
|
||||
private async Task<SmtpReply> CommandAsync(StreamReader reader, StreamWriter writer, string command, CancellationToken token)
|
||||
{
|
||||
await writer.WriteLineAsync(command).WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token);
|
||||
return await ReadReplyAsync(reader, token);
|
||||
}
|
||||
|
||||
private async Task<SmtpReply> ReadReplyAsync(StreamReader reader, CancellationToken token)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var first = await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token)
|
||||
?? throw new IOException("SMTP 连接提前关闭。");
|
||||
lines.Add(first);
|
||||
if (first.Length < 3 || !int.TryParse(first[..3], out var code)) throw new InvalidOperationException($"SMTP 返回无效响应:{first}");
|
||||
if (first.Length > 3 && first[3] == '-')
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(config.DirectDelivery.CommandTimeoutSeconds), token)
|
||||
?? throw new IOException("SMTP 多行响应提前结束。");
|
||||
lines.Add(line);
|
||||
if (line.StartsWith($"{code:D3} ", StringComparison.Ordinal)) break;
|
||||
}
|
||||
}
|
||||
return new SmtpReply(code, lines);
|
||||
}
|
||||
|
||||
private static bool HasCapability(SmtpReply reply, string capability) =>
|
||||
reply.Lines.Any(x => x.Length > 4 && x[4..].StartsWith(capability, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static void Expect(SmtpReply reply, string step, params int[] expected)
|
||||
{
|
||||
if (!expected.Contains(reply.Code)) throw new SmtpDeliveryException(step, reply.Code, reply.Lines.LastOrDefault() ?? "");
|
||||
}
|
||||
|
||||
private static async Task TryQuitAsync(StreamReader reader, StreamWriter writer, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await writer.WriteLineAsync("QUIT").WaitAsync(TimeSpan.FromSeconds(5), token);
|
||||
await reader.ReadLineAsync(token).AsTask().WaitAsync(TimeSpan.FromSeconds(5), token);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static StreamReader NewReader(Stream stream) => new(stream, Encoding.ASCII, false, 8192, true);
|
||||
private static StreamWriter NewWriter(Stream stream) => new(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = "\r\n" };
|
||||
private static bool ValidateCertificate(object sender, System.Security.Cryptography.X509Certificates.X509Certificate? certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, SslPolicyErrors errors) => errors == SslPolicyErrors.None;
|
||||
private static bool IsValidAddress(string value) => value.Contains('@') && value.IndexOf('@') > 0 && value.IndexOf('@') < value.Length - 1;
|
||||
private static string GetDomain(string value) => value[(value.LastIndexOf('@') + 1)..].Trim().TrimEnd('.').ToLowerInvariant();
|
||||
private static string NormalizeAddress(string value) => value.Trim().Trim('<', '>');
|
||||
private sealed record SmtpReply(int Code, IReadOnlyList<string> Lines);
|
||||
private sealed class StartTlsUnavailableException(string message) : Exception(message);
|
||||
}
|
||||
|
||||
public sealed class SmtpDeliveryException(string step, int code, string detail) : Exception($"{step} 失败:{code} {detail}")
|
||||
{
|
||||
public int Code { get; } = code;
|
||||
public bool Permanent => Code >= 500 && Code <= 599;
|
||||
}
|
||||
|
||||
internal static class MxResolver
|
||||
{
|
||||
public static async Task<IReadOnlyList<string>> ResolveAsync(string domain, DirectDeliveryConfig config, CancellationToken token)
|
||||
{
|
||||
var servers = GetDnsServers(config.DnsServer);
|
||||
foreach (var server in servers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var records = await QueryAsync(domain, server, config.DnsTimeoutSeconds, token);
|
||||
if (records.Count > 0) return records;
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException)
|
||||
{
|
||||
AppLog.Warn($"[DNS] 查询 {domain} 的 MX 失败({server}):{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(domain, token);
|
||||
return addresses.Select(x => x.ToString()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>> QueryAsync(string domain, IPAddress server, int timeoutSeconds, CancellationToken token)
|
||||
{
|
||||
using var udp = new UdpClient(server.AddressFamily);
|
||||
var query = BuildQuery(domain, out var id);
|
||||
await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53));
|
||||
var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)), token);
|
||||
return ParseResponse(result.Buffer, id);
|
||||
}
|
||||
|
||||
private static byte[] BuildQuery(string domain, out ushort id)
|
||||
{
|
||||
id = (ushort)Random.Shared.Next(1, ushort.MaxValue);
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true);
|
||||
writer.Write(ToNetwork(id)); writer.Write(ToNetwork((ushort)0x0100)); writer.Write(ToNetwork((ushort)1)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0));
|
||||
foreach (var label in domain.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(label);
|
||||
writer.Write((byte)bytes.Length); writer.Write(bytes);
|
||||
}
|
||||
writer.Write((byte)0); writer.Write(ToNetwork((ushort)15)); writer.Write(ToNetwork((ushort)1));
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseResponse(byte[] data, ushort expectedId)
|
||||
{
|
||||
if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return [];
|
||||
var flags = ReadUInt16(data, 2);
|
||||
if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return [];
|
||||
var questions = ReadUInt16(data, 4); var answers = ReadUInt16(data, 6); var authority = ReadUInt16(data, 8); var additional = ReadUInt16(data, 10);
|
||||
var offset = 12;
|
||||
for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; }
|
||||
var records = new List<(ushort Preference, string Host)>();
|
||||
for (var i = 0; i < answers + authority + additional && offset < data.Length; i++)
|
||||
{
|
||||
ReadName(data, ref offset);
|
||||
if (offset + 10 > data.Length) break;
|
||||
var type = ReadUInt16(data, offset); var cls = ReadUInt16(data, offset + 2); var length = ReadUInt16(data, offset + 8); offset += 10;
|
||||
if (offset + length > data.Length) break;
|
||||
if (type == 15 && cls == 1 && length >= 3)
|
||||
{
|
||||
var preference = ReadUInt16(data, offset); var nameOffset = offset + 2; var host = ReadName(data, ref nameOffset);
|
||||
if (!string.IsNullOrWhiteSpace(host)) records.Add((preference, host.TrimEnd('.')));
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return records.OrderBy(x => x.Preference).Select(x => x.Host).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static string ReadName(byte[] data, ref int offset)
|
||||
{
|
||||
var labels = new List<string>(); var cursor = offset; var jumped = false; var next = offset;
|
||||
while (cursor < data.Length)
|
||||
{
|
||||
var length = data[cursor++];
|
||||
if (length == 0) { if (!jumped) next = cursor; break; }
|
||||
if ((length & 0xC0) == 0xC0)
|
||||
{
|
||||
if (cursor >= data.Length) throw new InvalidOperationException("DNS 名称指针无效。");
|
||||
var pointer = ((length & 0x3F) << 8) | data[cursor++];
|
||||
if (!jumped) next = cursor; cursor = pointer; jumped = true; continue;
|
||||
}
|
||||
if (length > 63 || cursor + length > data.Length) throw new InvalidOperationException("DNS 名称长度无效。");
|
||||
labels.Add(Encoding.ASCII.GetString(data, cursor, length)); cursor += length;
|
||||
}
|
||||
offset = next;
|
||||
return string.Join('.', labels);
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]);
|
||||
private static ushort ToNetwork(ushort value) => (ushort)((value << 8) | (value >> 8));
|
||||
|
||||
private static IReadOnlyList<IPAddress> GetDnsServers(string configured)
|
||||
{
|
||||
if (IPAddress.TryParse(configured, out var parsed)) return [parsed];
|
||||
var system = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(x => x.OperationalStatus == OperationalStatus.Up)
|
||||
.SelectMany(x => x.GetIPProperties().DnsAddresses)
|
||||
.Where(x => x.AddressFamily == AddressFamily.InterNetwork || (x.AddressFamily == AddressFamily.InterNetworkV6 && !x.IsIPv6SiteLocal))
|
||||
.Distinct()
|
||||
.OrderBy(x => x.AddressFamily == AddressFamily.InterNetwork ? 0 : 1)
|
||||
.ToArray();
|
||||
return system.Length > 0 ? system : [IPAddress.Parse("223.5.5.5"), IPAddress.Parse("1.1.1.1")];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public sealed class FileStore
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
private readonly string usersPath;
|
||||
private readonly string messagesPath;
|
||||
private readonly string queuePath;
|
||||
private readonly string rawDirectory;
|
||||
private readonly AppConfig config;
|
||||
private List<MailUser> users = [];
|
||||
private List<MailMessage> messages = [];
|
||||
private List<QueueItem> queue = [];
|
||||
|
||||
public FileStore(AppConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
Directory.CreateDirectory(config.DataDirectory);
|
||||
rawDirectory = Path.Combine(config.DataDirectory, "raw");
|
||||
Directory.CreateDirectory(rawDirectory);
|
||||
usersPath = Path.Combine(config.DataDirectory, "users.json");
|
||||
messagesPath = Path.Combine(config.DataDirectory, "messages.json");
|
||||
queuePath = Path.Combine(config.DataDirectory, "queue.json");
|
||||
Load();
|
||||
EnsureAdmin();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
users = Read<List<MailUser>>(usersPath) ?? [];
|
||||
messages = Read<List<MailMessage>>(messagesPath) ?? [];
|
||||
queue = Read<List<QueueItem>>(queuePath) ?? [];
|
||||
foreach (var item in queue.Where(x => x.Status == "processing"))
|
||||
{
|
||||
item.Status = "retry";
|
||||
item.NextAttempt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private T? Read<T>(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return default;
|
||||
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), json); }
|
||||
catch { return default; }
|
||||
}
|
||||
|
||||
private void Write<T>(string path, T value)
|
||||
{
|
||||
var temp = path + ".tmp";
|
||||
File.WriteAllText(temp, JsonSerializer.Serialize(value, json), Encoding.UTF8);
|
||||
File.Move(temp, path, true);
|
||||
}
|
||||
|
||||
private void EnsureAdmin()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.AdminPassword))
|
||||
throw new InvalidOperationException("appsettings.json 中必须设置 AdminPassword。");
|
||||
lock (gate)
|
||||
{
|
||||
if (users.Any(x => x.Email.Equals(config.AdminEmail, StringComparison.OrdinalIgnoreCase))) return;
|
||||
users.Add(new MailUser
|
||||
{
|
||||
Email = config.AdminEmail.ToLowerInvariant(),
|
||||
DisplayName = "Administrator",
|
||||
Role = "admin",
|
||||
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||
});
|
||||
users[^1].PasswordHash = HashPassword(config.AdminPassword, users[^1].PasswordSalt);
|
||||
Write(usersPath, users);
|
||||
}
|
||||
}
|
||||
|
||||
public MailUser? FindUser(string email) => users.FirstOrDefault(x => x.Active && x.Email.Equals(email.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public MailUser? Authenticate(string email, string password)
|
||||
{
|
||||
var user = FindUser(email);
|
||||
return user is not null && VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
|
||||
}
|
||||
|
||||
public bool IsLocalAddress(string email) => FindUser(email) is not null;
|
||||
|
||||
public IReadOnlyList<MailUser> ListUsers() => users.Where(x => x.Active).OrderBy(x => x.Email).ToArray();
|
||||
|
||||
public MailUser CreateUser(string email, string password, string displayName)
|
||||
{
|
||||
email = email.Trim().ToLowerInvariant();
|
||||
if (FindUser(email) is not null) throw new InvalidOperationException("用户已存在。");
|
||||
var user = new MailUser
|
||||
{
|
||||
Email = email,
|
||||
DisplayName = displayName,
|
||||
PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
|
||||
};
|
||||
user.PasswordHash = HashPassword(password, user.PasswordSalt);
|
||||
lock (gate) { users.Add(user); Write(usersPath, users); }
|
||||
return user;
|
||||
}
|
||||
|
||||
public void ChangePassword(string email, string password)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var user = users.FirstOrDefault(x => x.Email.Equals(email, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("用户不存在。");
|
||||
user.PasswordSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||
user.PasswordHash = HashPassword(password, user.PasswordSalt);
|
||||
Write(usersPath, users);
|
||||
}
|
||||
}
|
||||
|
||||
public string SaveRaw(byte[] raw)
|
||||
{
|
||||
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}.eml";
|
||||
var relative = Path.Combine("raw", name);
|
||||
File.WriteAllBytes(Path.Combine(config.DataDirectory, relative), raw);
|
||||
return relative;
|
||||
}
|
||||
|
||||
public byte[] ReadRaw(string relativePath)
|
||||
{
|
||||
var full = Path.GetFullPath(Path.Combine(config.DataDirectory, relativePath));
|
||||
if (!full.StartsWith(Path.GetFullPath(rawDirectory), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("非法文件路径。");
|
||||
return File.ReadAllBytes(full);
|
||||
}
|
||||
|
||||
public MailMessage SaveMessage(MailMessage message, byte[]? raw = null)
|
||||
{
|
||||
if (raw is not null) message.RawPath = SaveRaw(raw);
|
||||
lock (gate) { messages.Add(message); Write(messagesPath, messages); }
|
||||
return message;
|
||||
}
|
||||
|
||||
public IReadOnlyList<MailMessage> ListMessages(string owner, string folder, string query)
|
||||
{
|
||||
query = query.Trim();
|
||||
lock (gate)
|
||||
{
|
||||
return messages.Where(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(x => x.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(x => query.Length == 0 || $"{x.From} {x.To} {x.Subject} {x.Text}".Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(x => x.Date).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public MailMessage? GetMessage(string owner, string id) => messages.FirstOrDefault(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public void MarkRead(string owner, string id, bool read = true)
|
||||
{
|
||||
lock (gate) { var item = GetMessage(owner, id); if (item is null) return; item.Unread = !read; Write(messagesPath, messages); }
|
||||
}
|
||||
|
||||
public MailMessage QueueOutbound(string owner, string[] recipients, string subject, string text, byte[] raw)
|
||||
{
|
||||
var message = new MailMessage { OwnerEmail = owner, Folder = "sent", From = owner, To = string.Join(", ", recipients), Subject = subject, Text = text, DeliveryStatus = "queued", Unread = false };
|
||||
message.RawPath = SaveRaw(raw);
|
||||
lock (gate)
|
||||
{
|
||||
messages.Add(message);
|
||||
foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
queue.Add(new QueueItem { MessageId = message.Id, OwnerEmail = owner, Recipients = [recipient] });
|
||||
}
|
||||
Write(messagesPath, messages);
|
||||
Write(queuePath, queue);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
public IReadOnlyList<QueueItem> TakeDueQueue(int limit)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var due = queue.Where(x => x.Status is "pending" or "retry" && x.NextAttempt <= DateTimeOffset.UtcNow).Take(limit).ToArray();
|
||||
foreach (var item in due) item.Status = "processing";
|
||||
Write(queuePath, queue);
|
||||
return due;
|
||||
}
|
||||
}
|
||||
|
||||
public MailMessage? GetById(string id) => messages.FirstOrDefault(x => x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public void CompleteQueue(QueueItem item)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
item.Status = "sent";
|
||||
var message = GetById(item.MessageId);
|
||||
if (message is not null)
|
||||
{
|
||||
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
|
||||
message.DeliveryStatus = remaining ? "queued" : "sent";
|
||||
}
|
||||
Write(queuePath, queue); Write(messagesPath, messages);
|
||||
}
|
||||
}
|
||||
|
||||
public void FailQueue(QueueItem item, Exception error)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
item.Attempts++;
|
||||
item.LastError = error.Message;
|
||||
var permanent = error is SmtpDeliveryException smtp && smtp.Permanent;
|
||||
item.Status = permanent || item.Attempts >= 8 ? "failed" : "retry";
|
||||
item.NextAttempt = DateTimeOffset.UtcNow.AddMinutes(Math.Min(60, Math.Pow(2, item.Attempts)));
|
||||
var message = GetById(item.MessageId);
|
||||
if (message is not null)
|
||||
{
|
||||
var remaining = queue.Any(x => x.MessageId == item.MessageId && x.Id != item.Id && x.Status is "pending" or "retry" or "processing");
|
||||
message.DeliveryStatus = item.Status == "failed" && !remaining ? "failed" : "queued";
|
||||
}
|
||||
Write(queuePath, queue); Write(messagesPath, messages);
|
||||
}
|
||||
}
|
||||
|
||||
public object Stats(string owner)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return new { inbox = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox"), unread = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "inbox" && x.Unread), sent = messages.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Folder == "sent"), queue = queue.Count(x => x.OwnerEmail.Equals(owner, StringComparison.OrdinalIgnoreCase) && x.Status is "pending" or "retry" or "processing") };
|
||||
}
|
||||
}
|
||||
|
||||
private static string HashPassword(string password, string salt) => Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, Convert.FromBase64String(salt), 120_000, HashAlgorithmName.SHA256, 32));
|
||||
private static bool VerifyPassword(string password, string hash, string salt) => CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(hash), Convert.FromBase64String(HashPassword(password, salt)));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public sealed record ParsedMime(string From, string To, string Subject, string MessageId, string Text);
|
||||
|
||||
public static class Mime
|
||||
{
|
||||
public static ParsedMime Parse(byte[] raw)
|
||||
{
|
||||
var value = Encoding.UTF8.GetString(raw).Replace("\r\n", "\n");
|
||||
var split = value.IndexOf("\n\n", StringComparison.Ordinal);
|
||||
var headerText = split >= 0 ? value[..split] : value;
|
||||
var body = split >= 0 ? value[(split + 2)..] : "";
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? current = null;
|
||||
foreach (var line in headerText.Split('\n'))
|
||||
{
|
||||
if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) headers[current] += " " + line.Trim();
|
||||
else { var colon = line.IndexOf(':'); if (colon > 0) { current = line[..colon]; headers[current] = line[(colon + 1)..].Trim(); } }
|
||||
}
|
||||
return new ParsedMime(headers.GetValueOrDefault("From", ""), headers.GetValueOrDefault("To", ""), headers.GetValueOrDefault("Subject", "(无主题)"), headers.GetValueOrDefault("Message-ID", ""), body.TrimEnd());
|
||||
}
|
||||
|
||||
public static byte[] Build(string from, string[] to, string subject, string text)
|
||||
{
|
||||
var body = WrapBase64(Encoding.UTF8.GetBytes(text));
|
||||
var value = $"From: {from}\r\nTo: {string.Join(", ", to)}\r\nSubject: {EncodeHeader(subject)}\r\nDate: {DateTimeOffset.UtcNow:R}\r\nMessage-ID: <{Guid.NewGuid():N}@wpyw.site>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{body}\r\n";
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
|
||||
private static string EncodeHeader(string value)
|
||||
{
|
||||
if (value.All(ch => ch <= 0x7F)) return value;
|
||||
var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
|
||||
return $"=?UTF-8?B?{encoded}?=";
|
||||
}
|
||||
|
||||
private static string WrapBase64(byte[] bytes)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(bytes);
|
||||
return string.Join("\r\n", Enumerable.Range(0, (encoded.Length + 75) / 76)
|
||||
.Select(index => encoded.Substring(index * 76, Math.Min(76, encoded.Length - index * 76))));
|
||||
}
|
||||
|
||||
public static string[] Addresses(string value) => value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(x => x.Contains('<') ? x[(x.IndexOf('<') + 1)..x.IndexOf('>')] : x).Where(x => x.Contains('@')).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public sealed class AppConfig
|
||||
{
|
||||
public string Domain { get; set; } = "wpyw.site";
|
||||
public string Hostname { get; set; } = "mail.wpyw.site";
|
||||
public string HttpPrefix { get; set; } = "http://127.0.0.1:8787/";
|
||||
public int SmtpPort { get; set; } = 25;
|
||||
public int SubmissionPort { get; set; } = 587;
|
||||
public string DataDirectory { get; set; } = @"H:\MailData";
|
||||
public string AdminEmail { get; set; } = "[email protected]";
|
||||
public string AdminPassword { get; set; } = "";
|
||||
public string TlsCertificatePath { get; set; } = "";
|
||||
public string TlsCertificatePassword { get; set; } = "";
|
||||
public string DeliveryMode { get; set; } = "direct";
|
||||
public DirectDeliveryConfig DirectDelivery { get; set; } = new();
|
||||
public RelayConfig Relay { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class DirectDeliveryConfig
|
||||
{
|
||||
public int ConnectionTimeoutSeconds { get; set; } = 30;
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
public int DnsTimeoutSeconds { get; set; } = 5;
|
||||
public bool OpportunisticStartTls { get; set; } = true;
|
||||
public bool RequireStartTls { get; set; }
|
||||
public string DnsServer { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class RelayConfig
|
||||
{
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 587;
|
||||
public string User { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public bool EnableSsl { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class MailUser
|
||||
{
|
||||
public string Email { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
public string PasswordSalt { get; set; } = "";
|
||||
public bool Active { get; set; } = true;
|
||||
public string Role { get; set; } = "user";
|
||||
}
|
||||
|
||||
public sealed class MailMessage
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string OwnerEmail { get; set; } = "";
|
||||
public string Folder { get; set; } = "inbox";
|
||||
public string From { get; set; } = "";
|
||||
public string To { get; set; } = "";
|
||||
public string Subject { get; set; } = "(无主题)";
|
||||
public string Text { get; set; } = "";
|
||||
public string RawPath { get; set; } = "";
|
||||
public string MessageId { get; set; } = "";
|
||||
public DateTimeOffset Date { get; set; } = DateTimeOffset.UtcNow;
|
||||
public bool Unread { get; set; } = true;
|
||||
public bool Starred { get; set; }
|
||||
public string DeliveryStatus { get; set; } = "received";
|
||||
}
|
||||
|
||||
public sealed class QueueItem
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string MessageId { get; set; } = "";
|
||||
public string OwnerEmail { get; set; } = "";
|
||||
public string[] Recipients { get; set; } = [];
|
||||
public int Attempts { get; set; }
|
||||
public DateTimeOffset NextAttempt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string Status { get; set; } = "pending";
|
||||
public string LastError { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(string Email, string Password);
|
||||
public sealed record SendRequest(string To, string Subject, string Text);
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static async Task Main()
|
||||
{
|
||||
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
if (!File.Exists(settingsPath)) throw new FileNotFoundException("请将 appsettings.example.json 复制为 appsettings.json 并填写密码。", settingsPath);
|
||||
var config = JsonSerializer.Deserialize<AppConfig>(await File.ReadAllTextAsync(settingsPath), new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? throw new InvalidOperationException("无法读取 appsettings.json");
|
||||
if (config.AdminPassword.Length < 12 || config.AdminPassword.Contains("replace-with", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请在 appsettings.json 设置至少 12 位 AdminPassword。");
|
||||
AppLog.Configure(config);
|
||||
var store = new FileStore(config);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cancellation.Cancel(); };
|
||||
var api = new ApiServer(config, store);
|
||||
var smtp = new SmtpServer(config, store);
|
||||
var queue = new DeliveryQueue(config, store);
|
||||
AppLog.Info($"中文邮箱服务正在启动,域名:{config.Domain},主机名:{config.Hostname}");
|
||||
await Task.WhenAll(api.RunAsync(cancellation.Token), smtp.RunAsync(cancellation.Token), queue.RunAsync(cancellation.Token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# wpyw.mail Windows 邮箱服务
|
||||
|
||||
这是一个不依赖 Node、npm、数据库或第三方运行库的 Windows 原生服务端。安装包会自动配置:
|
||||
|
||||
- 邮箱:`[email protected]`
|
||||
- 收信:SMTP 25
|
||||
- 客户端发信:SMTP Submission 587
|
||||
- 客户端接口:本机 `127.0.0.1:8787`
|
||||
- 文件存储、收件箱、已发送、发送队列和失败重试
|
||||
- STARTTLS(需要 `mail.wpyw.site` 的 PFX 证书)
|
||||
|
||||
## 一、最简单的安装方式
|
||||
|
||||
1. 在 Windows Server 2022 上以管理员身份运行 `wpyw-mail-server-installer.exe`。
|
||||
2. 安装器出现中文问题时,按下面的规则填写:
|
||||
|
||||
| 安装器问题 | 应填写什么 | 第一次安装建议 |
|
||||
| --- | --- | --- |
|
||||
| 程序安装目录 | 服务程序放在哪里 | 直接回车 |
|
||||
| 邮件数据目录 | 邮件和账户数据长期保存在哪里 | 磁盘空间充足时填 `D:\WpywMailData`,没有 D 盘就直接回车 |
|
||||
| 邮箱密码 | `[email protected]` 的登录密码 | 输入至少 12 位强密码,输入时屏幕不会显示 |
|
||||
| SMTP 外发中继服务器 | 用来把邮件发到公网的 SMTP 服务器 | 没有就直接回车,使用 MX 直投 |
|
||||
| SMTP 外发中继端口 | 中继服务器端口 | 只有填写中继服务器后才出现,默认 587 |
|
||||
| SMTP 中继账号 | 中继账号 | 只有填写中继服务器后才出现 |
|
||||
| SMTP 中继密码 | 中继密码 | 只有填写中继账号后才出现 |
|
||||
| PFX 证书路径 | `mail.wpyw.site` 的证书文件路径 | 没有就直接回车,安装器会生成临时证书 |
|
||||
| PFX 证书密码 | PFX 文件密码 | 没密码就直接回车 |
|
||||
|
||||
3. 安装器会创建 Windows 防火墙规则、注册开机启动任务并启动服务。
|
||||
4. 安装完成后,记录安装器显示的邮箱地址和数据目录。
|
||||
|
||||
## 二、第一次安装可以直接这样填
|
||||
|
||||
如果你暂时没有 SMTP 中继,也没有正式 PFX 证书:
|
||||
|
||||
```text
|
||||
程序安装目录:直接回车
|
||||
邮件数据目录:直接回车
|
||||
邮箱密码:输入你自己设置的至少 12 位密码
|
||||
SMTP 外发中继服务器:直接回车
|
||||
SMTP 外发中继端口:不会出现
|
||||
SMTP 中继账号:不会出现
|
||||
SMTP 中继密码:不会出现
|
||||
PFX 证书路径:直接回车
|
||||
PFX 证书密码:不会出现,或直接回车
|
||||
```
|
||||
|
||||
不填写 SMTP 中继时,服务端会使用 MX 直投:查询收件人域名的 MX 记录,再连接对方 25 端口发送。你已经确认服务器可以连接 QQ MX 的 25 端口。
|
||||
|
||||
## 三、Cloudflare DNS 保持这样
|
||||
|
||||
```text
|
||||
mail.wpyw.site A <SERVER_IP> DNS only
|
||||
wpyw.site MX mail.wpyw.site DNS only
|
||||
```
|
||||
|
||||
网站或未来 WinUI 客户端的 Web/API 路由可以通过 Cloudflare Tunnel 指向:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8787/
|
||||
```
|
||||
|
||||
SMTP 25 和 587 不要放到普通 HTTP Tunnel 路由里;它们应直接连接 `mail.wpyw.site`,并在服务器和云主机防火墙中开放。
|
||||
|
||||
## 四、当前版本的边界
|
||||
|
||||
当前服务端还没有 IMAP/POP3、DKIM 签名和完整反垃圾系统,因此第一版 WinUI 客户端通过 REST API 工作,Outlook/手机暂时不能直接用 IMAP 登录。MX 直投能工作不代表所有服务商都会接受邮件;正式公网使用还应补齐 DKIM、DMARC、反向 DNS 和退信处理。
|
||||
|
||||
## 五、手工启动和查看
|
||||
|
||||
安装器默认注册的任务名是 `WpywMail`。管理员 PowerShell 中可以查看:
|
||||
|
||||
```powershell
|
||||
Get-ScheduledTask -TaskName WpywMail
|
||||
Get-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口'
|
||||
```
|
||||
|
||||
服务数据在安装时填写的数据目录中;不要删除其中的 `users.json`、`messages.json` 和 `queue.json`。
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace WpywMail.Native;
|
||||
|
||||
public sealed class SmtpServer
|
||||
{
|
||||
private readonly AppConfig config;
|
||||
private readonly FileStore store;
|
||||
private readonly X509Certificate2? certificate;
|
||||
private readonly bool advertiseStartTls;
|
||||
|
||||
public SmtpServer(AppConfig config, FileStore store)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
if (File.Exists(config.TlsCertificatePath)) certificate = new X509Certificate2(config.TlsCertificatePath, config.TlsCertificatePassword);
|
||||
advertiseStartTls = certificate is not null && !certificate.Subject.Equals(certificate.Issuer, StringComparison.OrdinalIgnoreCase);
|
||||
if (certificate is null) AppLog.Warn("[SMTP] 未找到 TLS 证书;正式公开使用前请配置 TlsCertificatePath。");
|
||||
else if (!advertiseStartTls) AppLog.Warn("[SMTP] 当前 TLS 证书是自签名证书,公网收信暂不发布 STARTTLS,避免远端因证书不受信而退信。");
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var inbound = new TcpListener(IPAddress.Any, config.SmtpPort);
|
||||
var submission = new TcpListener(IPAddress.Any, config.SubmissionPort);
|
||||
inbound.Start(); submission.Start();
|
||||
AppLog.Info($"[SMTP] 收信端口已监听:{config.SmtpPort}");
|
||||
AppLog.Info($"[SMTP] 客户端发信端口已监听:{config.SubmissionPort}");
|
||||
await Task.WhenAll(AcceptLoop(inbound, false, cancellationToken), AcceptLoop(submission, true, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task AcceptLoop(TcpListener listener, bool submission, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(token);
|
||||
_ = Task.Run(() => HandleClient(client, submission, token), token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
private async Task HandleClient(TcpClient client, bool submission, CancellationToken token)
|
||||
{
|
||||
await using var rawStream = client.GetStream();
|
||||
Stream stream = rawStream;
|
||||
var reader = NewReader(stream);
|
||||
var writer = NewWriter(stream);
|
||||
var tls = false;
|
||||
string? authenticatedUser = null;
|
||||
string? sender = null;
|
||||
var recipients = new List<string>();
|
||||
var remote = client.Client.RemoteEndPoint?.ToString() ?? "未知地址";
|
||||
try
|
||||
{
|
||||
AppLog.Info($"[SMTP] 收到连接:{remote},模式={(submission ? "客户端发信" : "公网收信")}");
|
||||
await Send(writer, $"220 {config.Hostname} ESMTP WpywMail");
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(token);
|
||||
if (line is null) break;
|
||||
var command = line.Trim();
|
||||
var upper = command.ToUpperInvariant();
|
||||
if (upper.StartsWith("EHLO") || upper.StartsWith("HELO"))
|
||||
{
|
||||
await SendMulti(writer, $"250-{config.Hostname}", "250-SIZE 26214400", "250-8BITMIME", "250-PIPELINING", advertiseStartTls && !tls ? "250-STARTTLS" : "250 AUTH LOGIN PLAIN");
|
||||
if (advertiseStartTls && !tls) await Send(writer, "250 AUTH LOGIN PLAIN");
|
||||
}
|
||||
else if (upper == "STARTTLS")
|
||||
{
|
||||
if (certificate is null || (!submission && !advertiseStartTls)) { await Send(writer, "454 TLS unavailable"); continue; }
|
||||
await Send(writer, "220 Ready to start TLS");
|
||||
var ssl = new SslStream(stream, false);
|
||||
await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 }, token);
|
||||
stream = ssl; reader = NewReader(stream); writer = NewWriter(stream); tls = true; authenticatedUser = null;
|
||||
}
|
||||
else if (upper.StartsWith("AUTH"))
|
||||
{
|
||||
if (submission && !tls) { await Send(writer, "538 Encryption required for authentication"); continue; }
|
||||
authenticatedUser = await Authenticate(command, reader, writer, token);
|
||||
await Send(writer, authenticatedUser is null ? "535 Authentication failed" : "235 Authentication successful");
|
||||
}
|
||||
else if (upper == "RSET")
|
||||
{
|
||||
sender = null; recipients.Clear(); await Send(writer, "250 Reset");
|
||||
}
|
||||
else if (upper.StartsWith("MAIL FROM:"))
|
||||
{
|
||||
sender = ExtractAddress(command); recipients.Clear();
|
||||
if (submission && authenticatedUser is null) { AppLog.Warn($"[SMTP] {remote} 未认证就尝试发信:{sender}"); await Send(writer, "530 Authentication required"); }
|
||||
else if (submission && !sender.Equals(authenticatedUser, StringComparison.OrdinalIgnoreCase)) { AppLog.Warn($"[SMTP] {remote} 发件人不匹配:{sender}"); await Send(writer, "553 Sender must match authenticated mailbox"); }
|
||||
else { AppLog.Info($"[SMTP] {remote} MAIL FROM:{sender}"); await Send(writer, "250 Sender accepted"); }
|
||||
}
|
||||
else if (upper.StartsWith("RCPT TO:"))
|
||||
{
|
||||
var recipient = ExtractAddress(command);
|
||||
if (sender is null) { AppLog.Warn($"[SMTP] {remote} 未先发送 MAIL FROM 就发送 RCPT TO:{recipient}"); await Send(writer, "503 Need MAIL FROM first"); }
|
||||
else if (!submission && !store.IsLocalAddress(recipient)) { AppLog.Warn($"[SMTP] {remote} 非本地收件人被拒绝:{recipient}"); await Send(writer, "550 Relay denied"); }
|
||||
else { recipients.Add(recipient); AppLog.Info($"[SMTP] {remote} RCPT TO:{recipient}"); await Send(writer, "250 Recipient accepted"); }
|
||||
}
|
||||
else if (upper == "DATA")
|
||||
{
|
||||
if (sender is null || recipients.Count == 0) { await Send(writer, "503 Need sender and recipient"); continue; }
|
||||
await Send(writer, "354 End data with <CRLF>.<CRLF>");
|
||||
var raw = await ReadData(reader, token);
|
||||
var parsed = Mime.Parse(raw);
|
||||
if (submission)
|
||||
{
|
||||
store.QueueOutbound(authenticatedUser!, recipients.ToArray(), parsed.Subject, parsed.Text, raw);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var recipient in recipients.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (store.FindUser(recipient) is not null)
|
||||
store.SaveMessage(new MailMessage { OwnerEmail = recipient.ToLowerInvariant(), Folder = "inbox", From = parsed.From.Length > 0 ? parsed.From : sender, To = recipient, Subject = parsed.Subject, Text = parsed.Text, MessageId = parsed.MessageId, Date = DateTimeOffset.UtcNow, DeliveryStatus = "received" }, raw);
|
||||
}
|
||||
AppLog.Info($"[SMTP] 已接收邮件:{sender} → {string.Join(", ", recipients)},主题:{parsed.Subject}");
|
||||
}
|
||||
await Send(writer, "250 Message queued"); sender = null; recipients.Clear();
|
||||
}
|
||||
else if (upper == "NOOP") await Send(writer, "250 OK");
|
||||
else if (upper == "QUIT") { await Send(writer, "221 Bye"); break; }
|
||||
else await Send(writer, "502 Command not implemented");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) { }
|
||||
catch (Exception ex) { AppLog.Error($"[SMTP] {remote} 会话错误:{ex.Message}"); }
|
||||
finally { client.Dispose(); }
|
||||
}
|
||||
|
||||
private async Task<string?> Authenticate(string command, StreamReader reader, StreamWriter writer, CancellationToken token)
|
||||
{
|
||||
var parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
string? email = null; string? password = null;
|
||||
if (parts.Length >= 3 && parts[1].Equals("PLAIN", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var bytes = Convert.FromBase64String(parts[2]);
|
||||
var values = Encoding.UTF8.GetString(bytes).Split('\0');
|
||||
email = values.Length > 1 ? values[1] : null; password = values.Length > 2 ? values[2] : null;
|
||||
}
|
||||
else if (parts.Length >= 2 && parts[1].Equals("LOGIN", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await Send(writer, "334 VXNlcm5hbWU6"); email = Encoding.UTF8.GetString(Convert.FromBase64String(await reader.ReadLineAsync(token) ?? ""));
|
||||
await Send(writer, "334 UGFzc3dvcmQ6"); password = Encoding.UTF8.GetString(Convert.FromBase64String(await reader.ReadLineAsync(token) ?? ""));
|
||||
}
|
||||
else { await Send(writer, "504 Authentication mechanism not supported"); return null; }
|
||||
return store.Authenticate(email ?? "", password ?? "")?.Email;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadData(StreamReader reader, CancellationToken token)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
while (true)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(token) ?? ".";
|
||||
if (line == ".") break;
|
||||
lines.Add(line.StartsWith("..") ? line[1..] : line);
|
||||
if (lines.Sum(x => x.Length) > 25 * 1024 * 1024) throw new InvalidOperationException("Message too large");
|
||||
}
|
||||
return Encoding.UTF8.GetBytes(string.Join("\r\n", lines) + "\r\n");
|
||||
}
|
||||
|
||||
private static string ExtractAddress(string command)
|
||||
{
|
||||
var start = command.IndexOf('<'); var end = command.IndexOf('>', start + 1);
|
||||
return start >= 0 && end > start ? command[(start + 1)..end].Trim().ToLowerInvariant() : command[(command.IndexOf(':') + 1)..].Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static StreamReader NewReader(Stream stream) => new(stream, Encoding.UTF8, false, 8192, true);
|
||||
private static StreamWriter NewWriter(Stream stream) => new(stream, new UTF8Encoding(false), 8192, true) { AutoFlush = true, NewLine = "\r\n" };
|
||||
private static Task Send(StreamWriter writer, string value) => writer.WriteLineAsync(value);
|
||||
private static async Task SendMulti(StreamWriter writer, params string[] lines) { foreach (var line in lines) await Send(writer, line); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"Domain": "wpyw.site",
|
||||
"Hostname": "mail.wpyw.site",
|
||||
"HttpPrefix": "http://127.0.0.1:8787/",
|
||||
"SmtpPort": 25,
|
||||
"SubmissionPort": 587,
|
||||
"DataDirectory": "H:\\MailData",
|
||||
"AdminEmail": "[email protected]",
|
||||
"AdminPassword": "replace-with-a-long-password",
|
||||
"TlsCertificatePath": "H:\\MailData\\certs\\mail.wpyw.site.pfx",
|
||||
"TlsCertificatePassword": "replace-with-certificate-password",
|
||||
"DeliveryMode": "direct",
|
||||
"DirectDelivery": {
|
||||
"ConnectionTimeoutSeconds": 30,
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"DnsTimeoutSeconds": 5,
|
||||
"OpportunisticStartTls": true,
|
||||
"RequireStartTls": false,
|
||||
"DnsServer": ""
|
||||
},
|
||||
"Relay": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"User": "",
|
||||
"Password": "",
|
||||
"EnableSsl": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
New-NetFirewallRule -DisplayName 'wpyw.mail SMTP 邮件端口' -Direction Inbound -Protocol TCP -LocalPort 25,587 -Action Allow
|
||||
# API 8787 默认只监听本机,再通过 Cloudflare Tunnel 暴露 Web/API。
|
||||
# 如需让独立客户端直连,请先评估安全策略后再单独开放 8787。
|
||||
@@ -0,0 +1,17 @@
|
||||
param(
|
||||
[string]$InstallDirectory = 'C:\WpywMail',
|
||||
[string]$TaskName = 'WpywMail'
|
||||
)
|
||||
|
||||
$exe = Join-Path $InstallDirectory 'WpywMail.Native.exe'
|
||||
if (-not (Test-Path -LiteralPath $exe)) {
|
||||
throw "找不到 $exe,请先把 self-contained publish 目录复制到服务器。"
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $exe -WorkingDirectory $InstallDirectory
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Write-Host "已注册并启动任务:$TaskName"
|
||||
Reference in New Issue
Block a user