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

This commit is contained in:
WpyQwq
2026-09-19 11:19:40 +08:00
commit b8814a7615
84 changed files with 21197 additions and 0 deletions
+877
View File
@@ -0,0 +1,877 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace WpywMail.Native;
/// <summary>
/// 管理/客户端 API(默认只监听 127.0.0.1,供 Webmail 与桌面客户端经反向代理访问)。
///
/// 认证:POST /api/login 换取 token,之后带 Authorization: Bearer &lt;token&gt;。
/// 会话落盘(sessions.json),因此重启服务不会把已登录的客户端踢掉。
///
/// 端点一览见 README.md;v1 的 /api/login、/api/messages、/api/send、/api/config、
/// /api/me、/api/logout、/api/account/password、/api/admin/users 全部保持兼容。
/// </summary>
public sealed class ApiServer
{
private readonly AppConfig config;
private readonly IMailStore store;
private readonly AccountService accounts;
private readonly HttpListener listener = new();
/// <summary>可选的公网监听(只放账号类接口,见 <see cref="IsPublicAccountRoute"/>)。为空表示不开。</summary>
private readonly HttpListener? publicListener = null;
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public ApiServer(AppConfig config, IMailStore store)
{
this.config = config;
this.store = store;
this.accounts = new AccountService(config, store);
listener.Prefixes.Add(config.HttpPrefix);
// 公网监听(需要在 Windows 里先给这个前缀绑定证书:netsh http add sslcert hostnameport=...)
if (!string.IsNullOrWhiteSpace(config.Api.PublicPrefix))
{
publicListener = new HttpListener();
publicListener.Prefixes.Add(config.Api.PublicPrefix);
}
}
/// <summary>
/// 公网监听**只**放行「账号相关」的接口:注册、验证码、找回密码、登录、改密、会话与资料。
/// 邮件读写(/api/messages、/api/send、/api/queue、/api/watch、/api/drafts)与管理接口
/// (/api/admin/*)**一律不在公网暴露** —— 那些只能从回环/受控网络访问。
/// 这是刻意做窄的暴露面:客户端要在公网自助注册与改密码,但读信发信走 IMAP/SMTP。
/// </summary>
public static bool IsPublicAccountRoute(string path, string method) => method switch
{
"GET" => path is "/api/health" or "/api/version" or "/api/auth/policy"
or "/api/me" or "/api/account/sessions" or "/api/account/audit",
"POST" => path is "/api/login" or "/api/logout" or "/api/register" or "/api/register/verify"
or "/api/register/resend" or "/api/auth/forgot" or "/api/auth/reset"
or "/api/account/password" or "/api/account/sessions/revoke",
"PATCH" => path is "/api/account/profile",
_ => false,
};
/// <summary>客户端 IP(本机调用时就是回环地址;审计与限流用)。</summary>
private static string ClientIp(HttpListenerRequest request) =>
request.RemoteEndPoint?.Address?.ToString() ?? "";
private static string UserAgent(HttpListenerRequest request) =>
request.UserAgent ?? "";
public async Task RunAsync(CancellationToken token)
{
listener.Start();
AppLog.Info($"[接口] 已监听:{config.HttpPrefix}");
// 公网监听走同一套处理器,靠 IsPublicAccountRoute 把路径收窄到账号类接口
if (publicListener is not null)
{
try
{
publicListener.Start();
AppLog.Info($"[接口] 公网账号入口已监听:{config.Api.PublicPrefix}"
+ "(只放注册/验证/找回密码/登录/改密/会话资料;邮件与管理接口仍只在回环)");
_ = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
var context = await publicListener.GetContextAsync().WaitAsync(token);
_ = Task.Run(() => SafeHandleAsync(context, isPublic: true), token);
}
}, token);
}
catch (Exception ex)
{
AppLog.Error($"[接口] 公网账号入口启动失败:{ex.Message}"
+ "(HTTPS 前缀需要先用 netsh http add sslcert hostnameport=<host:port> 绑定证书)");
}
}
try
{
while (!token.IsCancellationRequested)
{
var context = await listener.GetContextAsync().WaitAsync(token);
_ = Task.Run(() => SafeHandleAsync(context), token);
}
}
catch (OperationCanceledException) { }
catch (Exception ex) { AppLog.Error($"[接口] 监听异常:{ex.Message}"); }
finally
{
listener.Stop();
try { publicListener?.Stop(); } catch { }
}
}
private async Task SafeHandleAsync(HttpListenerContext context, bool isPublic = false)
{
try { await HandleAsync(context, isPublic); }
catch (Exception ex)
{
AppLog.Error($"[接口] 未处理异常:{ex.Message}");
try { await Reply(context.Response, new { error = "服务器内部错误" }, 500); } catch { }
}
}
private async Task HandleAsync(HttpListenerContext context, bool isPublic = false)
{
var request = context.Request;
var response = context.Response;
response.Headers["Access-Control-Allow-Origin"] = config.Api.CorsOrigin;
response.Headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type";
response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, DELETE, OPTIONS";
response.Headers["Access-Control-Expose-Headers"] = "Content-Disposition";
if (request.HttpMethod == "OPTIONS") { response.StatusCode = 204; response.Close(); return; }
var path = (request.Url?.AbsolutePath ?? "/").TrimEnd('/');
var method = request.HttpMethod.ToUpperInvariant();
// 公网入口:白名单之外的路径一律当作「不存在」,不给探测者任何信息
if (isPublic && !IsPublicAccountRoute(path, method))
{
AppLog.Warn($"[接口] 公网入口拒绝:{method} {request.Url?.PathAndQuery}(来自 {ClientIp(request)})");
await Reply(response, new { error = "接口不存在" }, 404);
return;
}
AppLog.Info($"[接口] {method} {request.Url?.PathAndQuery}");
// ---- 无需认证 ----
if (path is "/api/health" && method == "GET")
{
await Reply(response, new { ok = true, service = "wpyw.mail.native", version = BuildInfo.Version, domain = config.Domain, hostname = config.Hostname });
return;
}
if (path is "/api/version" && method == "GET")
{
await Reply(response, new { version = BuildInfo.Version, domain = config.Domain, hostname = config.Hostname, dkim = config.Dkim.Enabled });
return;
}
if (path is "/api/login" && method == "POST")
{
await LoginAsync(request, response);
return;
}
// ---- 账号体系:无需认证 ----
if (path is "/api/auth/policy" && method == "GET")
{
await Reply(response, accounts.PolicyView());
return;
}
if (path is "/api/register" && method == "POST")
{
var body = await ReadJsonAsync<RegisterRequest>(request);
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
var result = accounts.Register(body, ClientIp(request), UserAgent(request));
await Reply(response, result.Ok
? new { ok = true, verificationRequired = result.VerificationRequired, session = result.Session,
email = body.Email?.Trim().ToLowerInvariant(),
expiresInMinutes = result.VerificationRequired ? config.Accounts.CodeMinutes : (int?)null }
: new { error = result.Error }, result.Status);
return;
}
if (path is "/api/register/verify" && method == "POST")
{
var body = await ReadJsonAsync<VerifyCodeRequest>(request);
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
var (ok, status, error, newSession) = accounts.VerifyRegistration(body, ClientIp(request), UserAgent(request));
await Reply(response, ok ? new { ok = true, session = newSession } : new { error }, status);
return;
}
if (path is "/api/register/resend" && method == "POST")
{
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
var email = body.GetValueOrDefault("email") ?? "";
var purpose = body.TryGetValue("purpose", out var p) && !string.IsNullOrWhiteSpace(p) ? p : "register";
var (ok, status, error) = accounts.ResendCode(email, purpose, ClientIp(request));
await Reply(response, ok ? new { ok = true } : new { error }, status);
return;
}
if (path is "/api/auth/forgot" && method == "POST")
{
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
var email = body.GetValueOrDefault("email") ?? "";
var (ok, error) = accounts.RequestReset(email, ClientIp(request));
// 成功时也统一返回 ok:不暴露「这个邮箱是否存在」
await Reply(response, ok ? new { ok = true, expiresInMinutes = config.Accounts.CodeMinutes } : new { error },
ok ? 200 : 429);
return;
}
if (path is "/api/auth/reset" && method == "POST")
{
var body = await ReadJsonAsync<ResetPasswordRequest>(request);
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
var (ok, status, error) = accounts.ResetPassword(body, ClientIp(request));
await Reply(response, ok ? new { ok = true } : new { error }, status);
return;
}
// ---- 以下都需要认证 ----
var token = ExtractToken(request);
var session = store.GetSession(token);
var user = session is null ? null : store.FindUser(session.Email);
if (user is null)
{
await Reply(response, new { error = "登录已失效,请重新登录" }, 401);
return;
}
try
{
switch (path)
{
case "/api/logout" when method == "POST":
store.RemoveSession(token!);
await Reply(response, new { ok = true });
return;
case "/api/me" when method == "GET":
await Reply(response, new { user = Project(user), stats = store.Stats(user.Email) });
return;
case "/api/config" when method == "GET":
await Reply(response, new
{
domain = config.Domain,
hostname = config.Hostname,
account = user.Email,
protocols = new { smtp = config.SmtpPort, submission = config.SubmissionPort, api = config.HttpPrefix, tls = config.Smtp.AdvertiseStartTls },
features = new { dkim = config.Dkim.Enabled, attachments = true, watch = true, drafts = true },
});
return;
case "/api/messages" when method == "GET":
await ListMessagesAsync(request, response, user);
return;
case "/api/send" when method == "POST":
await SendAsync(request, response, user);
return;
case "/api/drafts" when method == "POST":
await SaveDraftAsync(request, response, user);
return;
case "/api/queue" when method == "GET":
await Reply(response, new
{
queue = store.ListQueue(user.Email).Select(x => new
{
x.Id, x.MessageId, x.Recipients, x.Attempts, x.Status, x.LastError, x.LastCode,
nextAttempt = x.NextAttempt, createdAt = x.CreatedAt, lastAttemptAt = x.LastAttemptAt,
}),
});
return;
case "/api/watch" when method == "GET":
await WatchAsync(request, response, user);
return;
case "/api/account/password" when method == "POST":
await ChangePasswordAsync(request, response, user);
return;
case "/api/account/profile" when method == "PATCH":
await UpdateProfileAsync(request, response, user);
return;
case "/api/account/sessions" when method == "GET":
await Reply(response, accounts.SessionsView(user.Email, token));
return;
case "/api/account/sessions/revoke" when method == "POST":
await RevokeSessionsAsync(request, response, user, token);
return;
case "/api/account/audit" when method == "GET":
{
var limit = int.TryParse(request.QueryString["limit"], out var l) ? l : 50;
await Reply(response, new
{
events = store.ListAuthEvents(user.Email, null, null, limit).Select(ProjectAudit),
});
return;
}
case "/api/admin/audit" when method == "GET":
{
if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; }
var limit = int.TryParse(request.QueryString["limit"], out var l) ? l : 100;
var email = request.QueryString["email"];
var ipFilter = request.QueryString["ip"];
var reason = request.QueryString["reason"];
await Reply(response, new
{
events = store.ListAuthEvents(email, ipFilter, reason, limit).Select(ProjectAudit),
});
return;
}
case "/api/admin/users":
if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; }
await AdminUsersAsync(request, response, method);
return;
}
// /api/messages/{id}...
if (path.StartsWith("/api/messages/", StringComparison.OrdinalIgnoreCase))
{
await MessageRouteAsync(request, response, user, path["/api/messages/".Length..], method);
return;
}
if (path.StartsWith("/api/queue/", StringComparison.OrdinalIgnoreCase))
{
var id = path["/api/queue/".Length..];
if (id.EndsWith("/retry", StringComparison.OrdinalIgnoreCase) && method == "POST")
{
store.RetryQueueItem(user.Email, id[..^"/retry".Length]);
await Reply(response, new { ok = true });
return;
}
}
if (path.StartsWith("/api/admin/users/", StringComparison.OrdinalIgnoreCase))
{
if (user.Role != "admin") { await Reply(response, new { error = "需要管理员权限" }, 403); return; }
await AdminUserPatchAsync(request, response, Uri.UnescapeDataString(path["/api/admin/users/".Length..]), method);
return;
}
await Reply(response, new { error = "接口不存在" }, 404);
}
catch (InvalidOperationException ex)
{
await Reply(response, new { error = ex.Message }, 400);
}
catch (Exception ex)
{
AppLog.Error($"[接口] {method} {path}:{ex.Message}");
await Reply(response, new { error = ex.Message }, 500);
}
}
// ---------------------------------------------------------------- 认证
/// <summary>
/// 登录。相比 v2.0.x 增加了三件事:
/// ① 失败计数与临时锁定(同一账号在窗口内连续失败到阈值即锁定,返回 423 与剩余秒数);
/// ② 审计(成功/失败/锁定都记 IP 与 UA,便于排查与限流);
/// ③ 成功时更新 lastLoginAt 并签发会话。
/// 对外错误信息统一为「邮箱或密码不正确」,真实原因只进审计,避免账号枚举。
/// </summary>
private async Task LoginAsync(HttpListenerRequest request, HttpListenerResponse response)
{
var body = await ReadJsonAsync<LoginRequest>(request);
var ip = ClientIp(request);
var agent = UserAgent(request);
if (body is null || string.IsNullOrWhiteSpace(body.Email) || string.IsNullOrEmpty(body.Password))
{
await Reply(response, new { error = "请提供 email 与 password" }, 400);
return;
}
var email = body.Email.Trim().ToLowerInvariant();
var locked = accounts.LockRemainingSeconds(email);
if (locked > 0)
{
accounts.Record(email, ip, "login-locked", false, $"remaining={locked}s", agent);
AppLog.Warn($"[接口] 登录被拒(锁定中):{email},剩余 {locked}s");
await Reply(response, new { error = $"失败次数过多,账号已临时锁定,请 {Math.Ceiling(locked / 60.0)} 分钟后再试", retryAfterSeconds = locked }, 423);
return;
}
var user = store.Authenticate(email, body.Password);
if (user is null)
{
// 失败原因只进审计;对外是否多说一句,由 AccountService 判断(见 LoginFailureHint)
var hint = accounts.LoginFailureHint(email);
accounts.Record(email, ip, "login-failed", false, hint.AuditReason, agent);
var failures = store.CountAuthEvents(email, null, "login-failed", false, config.Accounts.LockoutMinutes);
AppLog.Warn($"[接口] 登录失败:{email}({hint.AuditReason},{failures}/{config.Accounts.MaxLoginFailures})");
await Reply(response, new { error = hint.Error, pendingVerification = hint.PendingVerification }, hint.Status);
return;
}
var session = accounts.NewSession(user, ip, agent);
await Reply(response, session!);
}
private static string? ExtractToken(HttpListenerRequest request)
{
var header = request.Headers["Authorization"];
if (string.IsNullOrWhiteSpace(header)) return null;
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : header.Trim();
}
// ---------------------------------------------------------------- 邮件列表与详情
private static object Project(MailUser user) => new
{
email = user.Email,
displayName = user.DisplayName,
role = user.Role,
active = user.Active,
createdAt = user.CreatedAt,
lastLoginAt = user.LastLoginAt,
domain = user.Email.Split('@').LastOrDefault(),
};
private async Task ListMessagesAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var query = request.QueryString;
var folder = query["folder"] ?? "inbox";
var search = query["q"] ?? "";
var unreadOnly = query["unread"] is "1" or "true";
var starredOnly = query["starred"] is "1" or "true";
var limit = int.TryParse(query["limit"], out var parsedLimit) ? Math.Clamp(parsedLimit, 1, 500) : 100;
var offset = int.TryParse(query["offset"], out var parsedOffset) ? Math.Max(0, parsedOffset) : 0;
var (total, items) = store.ListMessagesPage(user.Email, folder, search, unreadOnly, starredOnly, limit, offset);
var page = items.Select(Summary).ToArray();
await Reply(response, new { total, offset, limit, messages = page });
}
private static object Summary(MailMessage message) => new
{
message.Id,
message.Folder,
message.From,
message.To,
message.Cc,
message.Subject,
message.Date,
message.ReceivedAt,
message.Unread,
message.Starred,
message.DeliveryStatus,
message.LastError,
message.Size,
attachmentCount = message.Attachments.Count,
hasAttachments = message.Attachments.Count > 0,
preview = Preview(message.Text.Length > 0 ? message.Text : StripTags(message.Html)),
};
private static string Preview(string text)
{
var flat = (text ?? "").Replace("\r", " ").Replace("\n", " ").Trim();
return flat.Length <= 140 ? flat : flat[..140] + "…";
}
private static string StripTags(string html)
{
if (string.IsNullOrEmpty(html)) return "";
var builder = new StringBuilder(html.Length);
var inside = false;
foreach (var c in html)
{
if (c == '<') { inside = true; continue; }
if (c == '>') { inside = false; continue; }
if (!inside) builder.Append(c);
}
return builder.ToString();
}
private async Task MessageRouteAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string rest, string method)
{
var segments = rest.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) { await Reply(response, new { error = "缺少邮件 id" }, 400); return; }
var id = Uri.UnescapeDataString(segments[0]);
var message = store.GetMessage(user.Email, id);
if (message is null) { await Reply(response, new { error = "邮件不存在" }, 404); return; }
// /api/messages/{id}/raw 或 /api/messages/{id}/attachments/{index}
if (segments.Length >= 2)
{
if (segments[1].Equals("raw", StringComparison.OrdinalIgnoreCase) && method == "GET")
{
var bytes = store.ReadRaw(message.RawPath);
await ReplyBinary(response, bytes, "message/rfc822", $"{Sanitize(message.Subject)}.eml");
return;
}
if (segments[1].Equals("attachments", StringComparison.OrdinalIgnoreCase) && method == "GET")
{
if (segments.Length < 3 || !int.TryParse(segments[2], out var index) ||
index < 0 || index >= message.Attachments.Count)
{
await Reply(response, new { error = "附件不存在" }, 404);
return;
}
var attachment = message.Attachments[index];
var data = store.ReadAttachment(attachment.StoredAs);
await ReplyBinary(response, data, attachment.ContentType, Sanitize(attachment.FileName));
return;
}
await Reply(response, new { error = "接口不存在" }, 404);
return;
}
switch (method)
{
case "GET":
{
var markRead = !string.Equals(request.QueryString["markRead"], "false", StringComparison.OrdinalIgnoreCase);
if (markRead && message.Unread) store.MarkRead(user.Email, id, true);
await Reply(response, new { message = Detail(message, markRead) });
return;
}
case "PATCH":
{
var body = await ReadJsonAsync<Dictionary<string, JsonElement>>(request) ?? [];
if (body.TryGetValue("unread", out var unread) && unread.ValueKind is JsonValueKind.True or JsonValueKind.False)
store.MarkRead(user.Email, id, !unread.GetBoolean());
if (body.TryGetValue("read", out var read) && read.ValueKind is JsonValueKind.True or JsonValueKind.False)
store.MarkRead(user.Email, id, read.GetBoolean());
if (body.TryGetValue("starred", out var starred) && starred.ValueKind is JsonValueKind.True or JsonValueKind.False)
store.SetStar(user.Email, id, starred.GetBoolean());
if (body.TryGetValue("folder", out var folder) && folder.ValueKind == JsonValueKind.String)
store.MoveMessage(user.Email, id, folder.GetString() ?? "inbox");
var updated = store.GetMessage(user.Email, id)!;
await Reply(response, new { message = Summary(updated) });
return;
}
case "DELETE":
{
var permanent = string.Equals(request.QueryString["permanent"], "true", StringComparison.OrdinalIgnoreCase);
store.DeleteMessage(user.Email, id, permanent);
await Reply(response, new { ok = true, permanent });
return;
}
default:
await Reply(response, new { error = "不支持的请求方法" }, 405);
return;
}
}
private static object Detail(MailMessage message, bool markedRead) => new
{
message.Id,
message.Folder,
message.From,
message.To,
message.Cc,
message.Subject,
message.Text,
message.Html,
message.MessageId,
message.InReplyTo,
message.References,
message.Date,
message.ReceivedAt,
unread = markedRead ? false : message.Unread,
message.Starred,
message.DeliveryStatus,
message.LastError,
message.Size,
message.DkimSigned,
attachments = message.Attachments.Select((a, index) => new
{
index,
a.FileName,
a.ContentType,
a.Size,
a.Inline,
url = $"/api/messages/{message.Id}/attachments/{index}",
}),
rawUrl = $"/api/messages/{message.Id}/raw",
};
private static string Sanitize(string name)
{
var cleaned = new string((name ?? "message").Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray()).Trim();
return cleaned.Length == 0 ? "message" : cleaned;
}
// ---------------------------------------------------------------- 发信
private async Task SendAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var body = await ReadJsonAsync<SendRequest>(request);
if (body is null) { await Reply(response, new { error = "请求体不是合法 JSON" }, 400); return; }
var recipients = Mime.Addresses(body.To ?? "");
var cc = Mime.Addresses(body.Cc ?? "");
if (recipients.Length == 0) { await Reply(response, new { error = "收件人不能为空" }, 400); return; }
if (string.IsNullOrWhiteSpace(body.Text) && string.IsNullOrWhiteSpace(body.Html))
{
await Reply(response, new { error = "正文不能为空" }, 400);
return;
}
var subject = string.IsNullOrWhiteSpace(body.Subject) ? "(无主题)" : body.Subject!;
var stored = new List<Attachment>();
var outgoing = new List<OutgoingAttachment>();
foreach (var attachment in body.Attachments ?? [])
{
if (string.IsNullOrWhiteSpace(attachment.Base64)) continue;
byte[] data;
try { data = Convert.FromBase64String(attachment.Base64); }
catch { await Reply(response, new { error = $"附件 {attachment.FileName} 不是合法 base64" }, 400); return; }
var contentType = string.IsNullOrWhiteSpace(attachment.ContentType) ? "application/octet-stream" : attachment.ContentType!;
var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment.bin" : attachment.FileName!;
stored.Add(new Attachment
{
FileName = fileName,
ContentType = contentType,
Size = data.Length,
StoredAs = store.SaveAttachment(data, fileName),
});
outgoing.Add(new OutgoingAttachment(fileName, contentType, data));
}
var raw = Mime.Build(new ComposeRequest(
user.Email,
user.DisplayName,
recipients,
cc,
subject,
body.Text ?? "",
body.Html,
outgoing,
MessageId: null,
InReplyTo: body.InReplyTo ?? "",
References: body.InReplyTo ?? ""), config);
var message = store.QueueOutbound(user.Email, recipients, subject, body.Text ?? "", raw,
string.Join(", ", cc), body.Html ?? "", body.InReplyTo ?? "", stored);
AppLog.Info($"[接口] 已入队发信:{user.Email} → {string.Join(", ", recipients)},主题:{subject}");
await Reply(response, new { queued = true, messageId = message.Id, recipients, cc }, 202);
}
private async Task SaveDraftAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var body = await ReadJsonAsync<DraftRequest>(request) ?? new DraftRequest("", "", "");
var message = new MailMessage
{
OwnerEmail = user.Email,
Folder = "drafts",
From = user.Email,
To = body.To ?? "",
Subject = string.IsNullOrWhiteSpace(body.Subject) ? "(无主题)" : body.Subject!,
Text = body.Text ?? "",
Unread = false,
DeliveryStatus = "draft",
};
store.SaveMessage(message);
await Reply(response, new { message = Summary(message) }, 201);
}
// ---------------------------------------------------------------- 长轮询
private async Task WatchAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var since = long.TryParse(request.QueryString["since"], out var parsed) ? parsed : store.Version;
var deadline = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(config.Api.LongPollSeconds, 1, 120));
var stats = store.Stats(user.Email);
while (DateTimeOffset.UtcNow < deadline && store.Version == since)
{
await Task.Delay(500);
}
await Reply(response, new
{
version = store.Version,
changed = store.Version != since,
stats = store.Stats(user.Email),
});
}
// ---------------------------------------------------------------- 账号与管理员
private async Task ChangePasswordAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
var check = accounts.CheckPassword(body.GetValueOrDefault("password"), user.Email);
if (!check.Ok)
{
await Reply(response, new { error = check.Error }, 400);
return;
}
var password = body["password"];
if (body.TryGetValue("currentPassword", out var current) && store.Authenticate(user.Email, current) is null)
{
await Reply(response, new { error = "当前密码不正确" }, 403);
return;
}
store.ChangePassword(user.Email, password);
// 改密后把其他设备踢下线(当前这个 token 保留,避免自己也被踢)
var revoked = store.RemoveSessions(user.Email, ExtractToken(request));
accounts.Record(user.Email, ClientIp(request), "password-changed", true, $"sessions revoked={revoked}", UserAgent(request));
AppLog.Info($"[接口] {user.Email} 已修改密码,吊销其他会话 {revoked} 个。");
await Reply(response, new { ok = true, revokedSessions = revoked });
}
private async Task UpdateProfileAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user)
{
var body = await ReadJsonAsync<ProfileRequest>(request);
if (body is null || body.DisplayName is null)
{
await Reply(response, new { error = "没有可更新的字段(目前支持 displayName)" }, 400);
return;
}
var name = body.DisplayName.Trim();
if (name.Length > 64) { await Reply(response, new { error = "显示名最多 64 个字符" }, 400); return; }
accounts.UpdateProfile(user.Email, name);
accounts.Record(user.Email, ClientIp(request), "profile-updated", true, name, UserAgent(request));
var fresh = store.FindUser(user.Email);
await Reply(response, new { ok = true, user = Project(fresh ?? user) });
}
private async Task RevokeSessionsAsync(HttpListenerRequest request, HttpListenerResponse response, MailUser user, string? currentToken)
{
var body = await ReadJsonAsync<Dictionary<string, JsonElement>>(request) ?? [];
var all = body.TryGetValue("all", out var a) && a.ValueKind is JsonValueKind.True;
if (all)
{
var removed = accounts.RevokeSessions(user.Email, null);
accounts.Record(user.Email, ClientIp(request), "session-revoked", true, $"all={removed}", UserAgent(request));
await Reply(response, new { ok = true, revoked = removed, selfRevoked = true });
return;
}
if (body.TryGetValue("token", out var t) && t.ValueKind == JsonValueKind.String)
{
var target = t.GetString() ?? "";
if (string.IsNullOrWhiteSpace(target)) { await Reply(response, new { error = "token 不能为空" }, 400); return; }
if (target == currentToken)
{
// 允许「把我自己这个会话也吊销」= 等价于登出
store.RemoveSession(target);
await Reply(response, new { ok = true, revoked = 1, selfRevoked = true });
return;
}
// 只允许吊销属于该账号的会话,避免越权
var mine = store.ListSessions(user.Email).Any(s => s.Token == target);
if (!mine) { await Reply(response, new { error = "该会话不属于当前账号" }, 403); return; }
store.RemoveSession(target);
accounts.Record(user.Email, ClientIp(request), "session-revoked", true, "one", UserAgent(request));
await Reply(response, new { ok = true, revoked = 1, selfRevoked = false });
return;
}
// 默认行为:退出其他设备(保留当前)
var count = accounts.RevokeSessions(user.Email, currentToken);
accounts.Record(user.Email, ClientIp(request), "session-revoked", true, $"others={count}", UserAgent(request));
await Reply(response, new { ok = true, revoked = count, selfRevoked = false });
}
private static object ProjectAudit(AuthEvent e) => new
{
e.Id, e.Email, e.Ip, e.Reason, e.Success, e.Detail, e.UserAgent, e.At,
};
private async Task AdminUsersAsync(HttpListenerRequest request, HttpListenerResponse response, string method)
{
if (method == "GET")
{
await Reply(response, new
{
users = store.ListUsers().Select(x => new { x.Email, x.DisplayName, x.Role, x.Active, x.CreatedAt, x.LastLoginAt }),
});
return;
}
if (method == "POST")
{
var body = await ReadJsonAsync<Dictionary<string, string>>(request) ?? [];
if (!body.TryGetValue("email", out var email) || !body.TryGetValue("password", out var password) || password.Length < 12)
{
await Reply(response, new { error = "需要 email 与至少 12 位 password" }, 400);
return;
}
var created = store.CreateUser(email, password, body.GetValueOrDefault("displayName", "") ?? "");
await Reply(response, new { user = new { created.Email, created.DisplayName, created.Role, created.Active } }, 201);
return;
}
await Reply(response, new { error = "不支持的请求方法" }, 405);
}
private async Task AdminUserPatchAsync(HttpListenerRequest request, HttpListenerResponse response, string email, string method)
{
if (method != "PATCH") { await Reply(response, new { error = "不支持的请求方法" }, 405); return; }
var body = await ReadJsonAsync<Dictionary<string, JsonElement>>(request) ?? [];
if (body.TryGetValue("active", out var active) && active.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
store.SetUserActive(email, active.GetBoolean());
await Reply(response, new { ok = true, email, active = active.GetBoolean() });
return;
}
if (body.TryGetValue("password", out var password) && password.ValueKind == JsonValueKind.String)
{
var value = password.GetString() ?? "";
if (value.Length < 12) { await Reply(response, new { error = "密码至少需要 12 个字符" }, 400); return; }
store.ChangePassword(email, value);
await Reply(response, new { ok = true, email });
return;
}
await Reply(response, new { error = "没有可更新的字段" }, 400);
}
// ---------------------------------------------------------------- HTTP 工具
private static async Task<T?> ReadJsonAsync<T>(HttpListenerRequest request)
{
if (!request.HasEntityBody) return default;
using var reader = new StreamReader(request.InputStream, Encoding.UTF8);
var text = await reader.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(text)) return default;
try { return JsonSerializer.Deserialize<T>(text, new JsonSerializerOptions(JsonSerializerDefaults.Web)); }
catch (JsonException) { return default; }
}
private Task Reply(HttpListenerResponse response, object value, int status = 200)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, json);
response.StatusCode = status;
response.ContentType = "application/json; charset=utf-8";
response.ContentLength64 = bytes.Length;
return WriteAndCloseAsync(response, bytes);
}
private static async Task ReplyBinary(HttpListenerResponse response, byte[] data, string contentType, string fileName)
{
response.StatusCode = 200;
response.ContentType = contentType;
response.ContentLength64 = data.Length;
response.Headers["Content-Disposition"] = $"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
await WriteAndCloseAsync(response, data);
}
private static async Task WriteAndCloseAsync(HttpListenerResponse response, byte[] data)
{
try
{
await response.OutputStream.WriteAsync(data);
response.OutputStream.Flush();
}
catch (Exception) { /* 客户端提前断开 */ }
finally { response.Close(); }
}
}
/// <summary>版本信息(客户端可据此判断兼容性)。</summary>
public static class BuildInfo
{
/// <summary>
/// 2.0.1:修正 DKIM 签名输入顺序(RFC 6376 §3.7)。2.0.0 把 DKIM-Signature 头放在
/// 签名输入的最前面并多带一个结尾 CRLF,导致所有合规验证器(Gmail/Outlook/port25)判 dkim=fail。
///
/// 2.2.0:账号体系(自助注册 / 登录加固 / 邮箱验证码 / 找回密码 / 会话与资料管理 / 认证审计)。
/// 两条关键设计见 README 第 6.2.2 节:本机托管的邮箱注册免验证(否则验证码邮件投不进
/// 还没开通的信箱,死循环);未激活账号先建信箱行、密码只存在验证码记录里。
/// 2.2.1:入站 SPF/DKIM/DMARC 校验 + 垃圾判定(默认标注并把失败件投垃圾箱,不拒收);
/// 新增 --verify-inbound 维护命令;顺带修掉签名端用 ASCII 取签名输入字节的潜在缺陷。
/// </summary>
public const string Version = "2.2.1";
public const string Product = "wpyw.mail.native";
}