using System.Text.Json.Serialization; namespace WpywMail.Native; /// 应用配置。对应 appsettings.json 的根对象。 public sealed class AppConfig { public string Domain { get; set; } = ""; public string Hostname { get; set; } = ""; public string HttpPrefix { get; set; } = "http://127.0.0.1:8787/"; public int SmtpPort { get; set; } = 25; public int SubmissionPort { get; set; } = 587; public string DataDirectory { get; set; } = ""; public string AdminEmail { get; set; } = ""; public string AdminPassword { get; set; } = ""; public string TlsCertificatePath { get; set; } = ""; public string TlsCertificatePassword { get; set; } = ""; /// direct = 按 MX 直接投递;relay = 走上游 SMTP 中继。 public string DeliveryMode { get; set; } = "direct"; public DirectDeliveryConfig DirectDelivery { get; set; } = new(); public RelayConfig Relay { get; set; } = new(); public RetryConfig Retry { get; set; } = new(); public DkimConfig Dkim { get; set; } = new(); public ApiConfig Api { get; set; } = new(); public ImapConfig Imap { get; set; } = new(); public SmtpConfig Smtp { get; set; } = new(); public StorageConfig Storage { get; set; } = new(); public AccountsConfig Accounts { get; set; } = new(); public InboundAuthConfig InboundAuth { get; set; } = new(); /// 启动时做基本校验,尽早暴露配置错误。 public void Validate() { if (string.IsNullOrWhiteSpace(Domain)) throw new InvalidOperationException("appsettings.json 必须设置 Domain。"); if (string.IsNullOrWhiteSpace(Hostname)) throw new InvalidOperationException("appsettings.json 必须设置 Hostname。"); if (string.IsNullOrWhiteSpace(AdminEmail) || !AdminEmail.Contains('@')) throw new InvalidOperationException("AdminEmail 必须是完整的邮箱地址。"); if (!AdminEmail.EndsWith("@" + Domain, StringComparison.OrdinalIgnoreCase)) AppLog.Warn($"[配置] AdminEmail({AdminEmail})不在 Domain({Domain})之下,请确认这是有意的。"); if (AdminPassword.Length < 12) throw new InvalidOperationException("请在 appsettings.json 设置至少 12 位 AdminPassword。"); if (AdminPassword.Contains("replace-with", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AdminPassword 还是示例值,请改成真实密码。"); if (string.IsNullOrWhiteSpace(DataDirectory)) throw new InvalidOperationException("appsettings.json 必须设置 DataDirectory。"); if (!DeliveryMode.Equals("direct", StringComparison.OrdinalIgnoreCase) && !DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("DeliveryMode 只能是 direct 或 relay。"); if (DeliveryMode.Equals("relay", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(Relay.Host)) throw new InvalidOperationException("DeliveryMode=relay 时必须设置 Relay.Host。"); if (SmtpPort is < 1 or > 65535 || SubmissionPort is < 1 or > 65535) throw new InvalidOperationException("SMTP 端口配置非法。"); if (SmtpPort == SubmissionPort) throw new InvalidOperationException("SmtpPort 与 SubmissionPort 不能相同。"); if (!Storage.Provider.Equals("json", StringComparison.OrdinalIgnoreCase) && !Storage.Provider.Equals("sqlite", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Storage.Provider 只能是 json 或 sqlite。"); var registration = (Accounts.Registration ?? "").Trim().ToLowerInvariant(); if (registration is not ("open" or "invite" or "closed")) throw new InvalidOperationException("Accounts.Registration 只能是 open / invite / closed。"); if (registration == "invite" && string.IsNullOrWhiteSpace(Accounts.InviteCode)) throw new InvalidOperationException("Accounts.Registration=invite 时必须设置 Accounts.InviteCode。"); if (Accounts.MinPasswordLength < 8) throw new InvalidOperationException("Accounts.MinPasswordLength 不能小于 8。"); if (Accounts.CodeMinutes < 1 || Accounts.CodeMinutes > 24 * 60) throw new InvalidOperationException("Accounts.CodeMinutes 应在 1..1440 之间。"); } } /// /// 账号体系配置:自助注册策略、邮箱验证、密码强度、登录锁定、限流。 /// /// 默认值刻意偏保守:**注册默认 invite(需要邀请码)**,且注册的邮箱域名默认只允许 /// 服务器自己的 Domain —— 公网上的邮件服务器一旦开放注册,很快就会变成垃圾邮件跳板。 /// 要真正开放,请显式改 Registration=open 并配置 AllowedDomains。 /// public sealed class AccountsConfig { /// open = 任何人可注册;invite = 需要邀请码;closed = 关闭注册(只能管理员建号)。 public string Registration { get; set; } = "invite"; /// invite 模式下的邀请码。 public string InviteCode { get; set; } = ""; /// 允许注册的邮箱域名(含服务器自身域名)。留空表示只允许 Domain。 public string[] AllowedDomains { get; set; } = []; /// 注册后是否必须用邮箱里的验证码激活(强烈建议 true)。 public bool RequireEmailVerification { get; set; } = true; /// 密码最小长度(同时会检查:不能是纯数字、不能与邮箱相同)。 public int MinPasswordLength { get; set; } = 12; /// 验证码有效期(分钟)。 public int CodeMinutes { get; set; } = 30; /// 同一个验证码最多尝试几次(超过即作废,需重新获取)。 public int MaxCodeAttempts { get; set; } = 5; /// 同一账号在窗口期内连续登录失败多少次后锁定。 public int MaxLoginFailures { get; set; } = 8; /// 登录失败统计窗口与锁定时长(分钟)。 public int LockoutMinutes { get; set; } = 15; /// 同一 IP 每小时最多发起几次注册 / 重发验证码(防刷)。 public int RegisterPerHourPerIp { get; set; } = 5; /// 同一邮箱每小时最多重发几次验证码。 public int ResendPerHourPerEmail { get; set; } = 5; /// 审计日志最多保留多少条(超出后按时间淘汰)。 public int AuditLimit { get; set; } = 2000; /// 把配置里的域名规则解析成实际允许的域名集合。 public string[] EffectiveDomains(string serverDomain) { var list = AllowedDomains .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim().TrimStart('@').ToLowerInvariant()) .ToList(); if (list.Count == 0 && !string.IsNullOrWhiteSpace(serverDomain)) list.Add(serverDomain.Trim().TrimStart('@').ToLowerInvariant()); return [.. list.Distinct()]; } } /// /// 存储后端选择。 /// /// - json :v2.0.x 的原始实现,users/messages/queue/sessions 各一个 JSON 文件, /// **任何一次改动都会整文件重写**,随邮件量增长呈 O(N) 放大。 /// - sqlite :SQLite 单文件数据库(元数据 + 索引 + 事务),原始报文仍落在 raw/ 目录。 /// 默认值,也是推荐值;改回 json 即可一键回滚(两套数据互不覆盖)。 /// public sealed class StorageConfig { public string Provider { get; set; } = "sqlite"; /// SQLite 数据库文件路径;留空则用 DataDirectory/wpywmail.db。 public string DatabasePath { get; set; } = ""; /// WAL 模式下定期检查点阈值(页数),0 表示交给 SQLite 默认策略。 public int WalAutoCheckpointPages { get; set; } /// /// 是否为正文建立 FTS5(trigram)全文索引。 /// 打开后搜索从「全表 LIKE 扫描」变成索引命中,代价是**索引本身会额外占用接近正文大小的磁盘** /// (trigram 索引通常与正文同量级)。默认关闭,因为本机磁盘偏紧、而 LIKE 在数千封量级仍是毫秒级。 /// public bool FullTextSearch { get; set; } } public sealed class DirectDeliveryConfig { public int ConnectionTimeoutSeconds { get; set; } = 30; public int CommandTimeoutSeconds { get; set; } = 30; public int DnsTimeoutSeconds { get; set; } = 5; public bool OpportunisticStartTls { get; set; } = true; public bool RequireStartTls { get; set; } public string DnsServer { get; set; } = ""; /// 投递时使用的 HELO 名称,留空则用 Hostname。 public string HeloName { get; set; } = ""; } public sealed class RelayConfig { public string Host { get; set; } = ""; public int Port { get; set; } = 587; public string User { get; set; } = ""; public string Password { get; set; } = ""; public bool EnableSsl { get; set; } = true; } /// 失败重投策略。4xx(临时)与 5xx(永久)分开处理。 public sealed class RetryConfig { public int MaxAttempts { get; set; } = 12; public int InitialDelaySeconds { get; set; } = 60; public int MaxDelaySeconds { get; set; } = 3600; /// 5xx 默认也重试若干次:封锁/策略类 5xx 往往是临时的。 public bool RetryOnPermanentFailure { get; set; } = true; public int MaxAttemptsForPermanent { get; set; } = 3; /// 彻底失败时给发件人投递退信(NDR)。 public bool SendBounceNotification { get; set; } = true; } /// DKIM 签名配置。私钥不存在时会自动生成并打印需要配置的 DNS 记录。 public sealed class DkimConfig { public bool Enabled { get; set; } public string Selector { get; set; } = "mail"; /// 留空则用 Domain。 public string SigningDomain { get; set; } = ""; /// 留空则放在 DataDirectory/dkim/<selector>.private.pem。 public string PrivateKeyPath { get; set; } = ""; public string[] Headers { get; set; } = ["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type", "Content-Transfer-Encoding"]; } /// /// 入站邮件身份校验(SPF / DKIM / DMARC)与垃圾邮件判定。 /// /// 默认策略:**标注 + 投垃圾箱,不拒收** —— 校验实现自身也可能有 bug,拒收不可逆, /// 投进垃圾箱可逆。要严格拒收把 打开。 /// public sealed class InboundAuthConfig { public bool Enabled { get; set; } = true; /// 是否往报文里写 Authentication-Results / X-Spam-Score 头(标准做法,保留证据)。 public bool AddAuthenticationResults { get; set; } = true; /// 判定为垃圾时投进 spam 文件夹而不是收件箱。 public bool SpamFolderOnFail { get; set; } = true; /// DMARC p=reject 且校验失败时直接在 SMTP 阶段 550 拒收。默认关(怕误杀)。 public bool RejectOnDmarcReject { get; set; } = false; /// DKIM 验签(含 DNS 取公钥)开关;关掉只做 SPF/DMARC 的 SPF 部分。 public bool VerifyDkim { get; set; } = true; /// 判为垃圾的分数阈值(DMARC 失败固定 +4)。 public int SpamScoreThreshold { get; set; } = 3; public int DnsTimeoutSeconds { get; set; } = 5; /// SPF 的 DNS 查询次数上限(RFC 7208 规定 10)。 public int MaxSpfLookups { get; set; } = 10; } public sealed class ApiConfig { public int SessionDays { get; set; } = 30; /// 允许的跨域来源;默认 * 便于本机客户端调试,公网使用建议收紧。 public string CorsOrigin { get; set; } = "*"; /// 推送新邮件的长轮询上限(秒)。 public int LongPollSeconds { get; set; } = 25; /// /// 可选的公网 HTTPS 前缀(例如 https://mail.example.com:9443/),只为客户端在公网 /// 自助注册 / 找回密码 / 管理会话资料而开。**只放行账号类接口**,邮件读写与管理接口不在这里暴露。 /// 留空 = 不开(默认)。HTTPS 前缀必须先绑定证书:netsh http add sslcert hostnameport=mail.example.com:9443 ... /// public string PublicPrefix { get; set; } = ""; } /// IMAP 服务配置(让标准邮件客户端也能接入)。 public sealed class ImapConfig { public bool Enabled { get; set; } = true; /// 143:明文 + STARTTLS。 public int Port { get; set; } = 143; /// 993:隐式 TLS。设为 0 表示不监听。 public int TlsPort { get; set; } = 993; /// 是否要求先建立 TLS 才允许 LOGIN(推荐 true)。 public bool RequireTlsForLogin { get; set; } = true; /// 允许未加密登录的来源地址(默认仅本机,便于自检/调试)。 public string[] PlaintextLoginAllowFrom { get; set; } = ["127.0.0.1", "::1"]; } public sealed class SmtpConfig { /// 单封邮件最大字节数。 public int MaxMessageBytes { get; set; } = 25 * 1024 * 1024; /// 是否始终广告 STARTTLS(只要加载到证书就广告,含自签名)。 public bool AdvertiseStartTls { get; set; } = true; /// 25 端口也允许 AUTH(默认否;587 端口始终允许)。 public bool AllowAuthOnInbound { get; set; } /// 给收到的邮件补 Received 头。 public bool AddReceivedHeader { get; set; } = true; /// 同一 IP 连续认证失败多少次后临时封禁。 public int AuthFailuresBeforeBan { get; set; } = 8; public int BanMinutes { get; set; } = 15; /// 已认证用户是否必须使用自己的地址作为发件人。 public bool EnforceSenderMatch { get; set; } = true; } public sealed class MailUser { public string Email { get; set; } = ""; public string DisplayName { get; set; } = ""; public string PasswordHash { get; set; } = ""; public string PasswordSalt { get; set; } = ""; public bool Active { get; set; } = true; public string Role { get; set; } = "user"; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? LastLoginAt { get; set; } } public sealed class Attachment { public string FileName { get; set; } = ""; public string ContentType { get; set; } = "application/octet-stream"; public long Size { get; set; } /// 相对 DataDirectory 的存储路径,例如 attachments/xxx.bin。 public string StoredAs { get; set; } = ""; public string ContentId { get; set; } = ""; public bool Inline { get; set; } } public sealed class MailMessage { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string OwnerEmail { get; set; } = ""; /// inbox / sent / drafts / archive / trash / spam public string Folder { get; set; } = "inbox"; public string From { get; set; } = ""; public string To { get; set; } = ""; public string Cc { get; set; } = ""; public string Subject { get; set; } = "(无主题)"; public string Text { get; set; } = ""; public string Html { get; set; } = ""; public string RawPath { get; set; } = ""; public string MessageId { get; set; } = ""; public string InReplyTo { get; set; } = ""; public string References { get; set; } = ""; public DateTimeOffset Date { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset ReceivedAt { get; set; } = DateTimeOffset.UtcNow; public bool Unread { get; set; } = true; public bool Starred { get; set; } /// IMAP UID:在同一文件夹内单调递增且稳定,首次入库时分配。 public int Uid { get; set; } /// received / queued / sent / failed public string DeliveryStatus { get; set; } = "received"; public string LastError { get; set; } = ""; public long Size { get; set; } public List Attachments { get; set; } = []; public bool HasAttachments => Attachments.Count > 0; /// DKIM 是否签名成功(发件侧)。 public bool DkimSigned { get; set; } } public sealed class QueueItem { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string MessageId { get; set; } = ""; public string OwnerEmail { get; set; } = ""; public string[] Recipients { get; set; } = []; public int Attempts { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset NextAttempt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? LastAttemptAt { get; set; } /// pending / processing / retry / sent / failed public string Status { get; set; } = "pending"; public string LastError { get; set; } = ""; public int LastCode { get; set; } } public sealed class SessionRecord { public string Token { get; set; } = ""; public string Email { get; set; } = ""; public DateTimeOffset Expires { get; set; } = DateTimeOffset.UtcNow.AddDays(30); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } public sealed record LoginRequest(string Email, string Password); // ─────────────────────────────────────────────────────────── 账号体系 /// /// 邮箱验证码(注册激活 / 密码重置共用一个表)。 /// /// 只存**验证码的哈希**,不存明文 —— 数据库被人拿到也不能直接拿来激活账号或改密码。 /// 注册场景下,密码的哈希与显示名先暂存在 Payload 里,验证通过后才真正建号, /// 这样「未验证的注册」不会在用户表里留下垃圾数据。 /// public sealed class VerificationCode { public string Email { get; set; } = ""; /// register = 注册激活;reset = 重置密码。 public string Purpose { get; set; } = "register"; public string CodeHash { get; set; } = ""; public string Salt { get; set; } = ""; /// register 时是 JSON:{ displayName, passwordHash, passwordSalt }。 public string Payload { get; set; } = ""; public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddMinutes(30); public int Attempts { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? SentAt { get; set; } } /// /// 认证事件审计:登录成功/失败、注册、验证码发送与校验、密码重置、会话吊销。 /// 用途有三个:排查问题、登录锁定判定、按 IP/邮箱做限流。 /// public sealed class AuthEvent { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Email { get; set; } = ""; public string Ip { get; set; } = ""; /// login-ok / login-failed / login-locked / register / register-verify / code-sent / reset-ok / session-revoked public string Reason { get; set; } = ""; public bool Success { get; set; } public string Detail { get; set; } = ""; public string UserAgent { get; set; } = ""; public DateTimeOffset At { get; set; } = DateTimeOffset.UtcNow; } public sealed record RegisterRequest(string Email, string Password, string? DisplayName = null, string? InviteCode = null); public sealed record VerifyCodeRequest(string Email, string Code); public sealed record ResetPasswordRequest(string Email, string Code, string Password); public sealed record ProfileRequest(string? DisplayName = null); /// 附件上传:内容用 base64 传递。 public sealed record AttachmentRequest(string FileName, string ContentType, string Base64); public sealed record SendRequest( string To, string Subject, string? Text, string? Html = null, string? Cc = null, string? InReplyTo = null, List? Attachments = null); public sealed record DraftRequest(string To, string Subject, string Text);