Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
@@ -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 <token>。
|
||||
/// 会话落盘(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";
|
||||
}
|
||||
Reference in New Issue
Block a user