using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace WpywMail.Native; /// /// 管理/客户端 API(默认只监听 127.0.0.1,供 Webmail 与桌面客户端经反向代理访问)。 /// /// 认证:POST /api/login 换取 token,之后带 Authorization: Bearer <token>。 /// 会话落盘(sessions.json),因此重启服务不会把已登录的客户端踢掉。 /// /// 端点一览见 README.md;v1 的 /api/login、/api/messages、/api/send、/api/config、 /// /api/me、/api/logout、/api/account/password、/api/admin/users 全部保持兼容。 /// public sealed class ApiServer { private readonly AppConfig config; private readonly IMailStore store; private readonly AccountService accounts; private readonly HttpListener listener = new(); /// 可选的公网监听(只放账号类接口,见 )。为空表示不开。 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); } } /// /// 公网监听**只**放行「账号相关」的接口:注册、验证码、找回密码、登录、改密、会话与资料。 /// 邮件读写(/api/messages、/api/send、/api/queue、/api/watch、/api/drafts)与管理接口 /// (/api/admin/*)**一律不在公网暴露** —— 那些只能从回环/受控网络访问。 /// 这是刻意做窄的暴露面:客户端要在公网自助注册与改密码,但读信发信走 IMAP/SMTP。 /// 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, }; /// 客户端 IP(本机调用时就是回环地址;审计与限流用)。 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= 绑定证书)"); } } 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(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(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>(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>(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(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); } } // ---------------------------------------------------------------- 认证 /// /// 登录。相比 v2.0.x 增加了三件事: /// ① 失败计数与临时锁定(同一账号在窗口内连续失败到阈值即锁定,返回 423 与剩余秒数); /// ② 审计(成功/失败/锁定都记 IP 与 UA,便于排查与限流); /// ③ 成功时更新 lastLoginAt 并签发会话。 /// 对外错误信息统一为「邮箱或密码不正确」,真实原因只进审计,避免账号枚举。 /// private async Task LoginAsync(HttpListenerRequest request, HttpListenerResponse response) { var body = await ReadJsonAsync(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>(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(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(); var outgoing = new List(); 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(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>(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(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>(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>(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>(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 ReadJsonAsync(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(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(); } } } /// 版本信息(客户端可据此判断兼容性)。 public static class BuildInfo { /// /// 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 取签名输入字节的潜在缺陷。 /// public const string Version = "2.2.1"; public const string Product = "wpyw.mail.native"; }