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);