49 Commits
Author SHA1 Message Date
dsh 0576e6e348 chore: 忽略本地构建产物 bin/ 目录 2026-08-19 23:39:49 -04:00
dsh 034901a467 fix(db): AutoMigrate 前清理旧模型遗留的同名非唯一索引,修复 MySQL 启动失败
旧模型 BanEntry.IPAddress 为普通 index,由 AutoMigrate 建成非唯一索引
idx_ban_entries_ip_address;升级到 uniqueIndex 后,gorm mysql 驱动
MigrateColumnUnique 对非唯一列无条件 CREATE UNIQUE INDEX(不检查
HasIndex),与同名旧索引冲突报 Error 1061 Duplicate key name,服务
启动即失败(AutoMigrate 末尾的 HasIndex 保护执行不到)。

dropLegacyBanIndex 在 dedupeBanEntries 之后、AutoMigrate 之前,检测
information_schema.statistics 中同名 non_unique=1 的索引并删除,由
AutoMigrate 重建为唯一索引。仅 MySQL 生效,表不存在/失败仅告警。
2026-08-19 23:36:21 -04:00
kevin 6ee2dd8184 fix(security): 修复 P4 封禁记录数据错位 + P5 枚举爆破宽限(方案 A)
P4 #17 手动封禁 Create 非 upsert 导致数据错位:
- BanStore 新增 BanIP(ip, reason, duration):事务内清理该 IP 全部
  既有记录(兼容历史脏数据)后插入单条封禁记录,计数清零;
  DisconnectConnection 改用(原裸 Create 为全仓库唯一调用点)
- BanEntry.IPAddress 升级 uniqueIndex;InitDB 在 AutoMigrate 前
  dedupeBanEntries 清理历史重复行(MySQL 1093 兼容写法)
- IncrementFail 原子化:SQL 侧 fail_count+1,miss 时 OnConflict
  DoNothing 插入兜底并发竞态,回读计数

P5 #18 方案 A(按失败性质区分宽限):
- RecordAuthFailure 新增 knownUser 参数:用户名不存在(枚举型
  爆破)跳过 3 次宽限、首次触发即封第 1 档;用户名存在(真实
  用户输错)保留宽限防误封
- 新增 UserStore.LoginExists(邮箱/裸用户名);五个失败调用点
  接线(Web 登录查存在性;SMTP/IMAP/POP3 用登录名;LDAP 侧
  存在性不可判定,保守按已知用户处理)
- 封禁原因注明「未知用户名,跳过宽限」便于后台审计

新增 8 个测试(-race 通过):BanIP 单行 upsert、唯一索引约束、
16 协程并发计数精确、dedupe 清理/表不存在静默、未知用户即时
封禁、已知用户宽限回归、LoginExists 矩阵。
2026-08-20 11:25:10 +08:00
kevin 37b4816573 fix(web): 时间统一 12 小时制(上午/下午),修复后台时间错误
- 后台页面此前直接 Format 输出库内时间(服务器 UTC),比北京时间慢 8 小时
- 新增模板函数 time12/time12m:先按 Web 时区转换,输出「2026-08-20 下午 2:35:05」
- shortDate 改为 12 小时制且非今天的邮件也显示完整时间(今天「下午 2:35」,
  今年「08-20 下午 2:35」,更早「2026-08-20 下午 2:35」)
- 统一替换:admin/mails、admin/outbound 用 time12m;protocol-logs、
  connections、bans、mail_view、banned、view 用 time12
- 时区修正:协议日志 from/to 筛选与「今日」统计边界按 Web 时区计算
  (此前 time.Local 在服务器 UTC 时差 8 小时)
- 邮件列表日期列自适应宽度适配更长格式;新增 timefmt_test.go 回归测试
2026-08-20 02:00:10 +08:00
kevin 87a85ef7ca fix(web): 静态资源内嵌二进制,修复 install.sh 部署后 /static 404 导致写邮件页无编辑器
- 线上经 install.sh 部署时只复制二进制 + templates,internal/web/static
  未被复制,/static/vendor/quill/* 全部 404,Quill 加载失败正文不可输入
- 改为 go:embed 把 Quill css/js 编译进二进制,engine.StaticFS 提供,
  运行期不再依赖部署目录/工作目录,杜绝同类部署遗漏
- 已验证:移除磁盘 static 目录后静态资源仍可正常提供
2026-08-20 01:46:43 +08:00
kevin 2f7f322b62 fix(web): Quill 编辑器本地化,修复写邮件页无法输入正文
- 修复 3f28ec2 引入的回归:CSP script-src/style-src 'self' 拦截了
  CDN 的 quill.min.js / quill.snow.css,编辑器初始化失败导致正文不可输入
- 下载 Quill 1.3.7 到 internal/web/static/vendor/quill/(.gitignore 加例外)
- 新增 /static 静态路由,compose 模板改为同源加载:CSP 保持严格,
  且服务器离线可用、摆脱第三方 CDN 依赖
- 新增 static_test.go 回归测试验证静态资源可访问
2026-08-20 01:37:42 +08:00
kevin dc164fdf66 feat(mailbox): 文件夹数据驱动,Web 通过 IMAP 共享服务层操作邮箱
- 新增 mailboxes 表与 MailboxStore(系统文件夹幂等创建,自定义文件夹 CRUD)
- 提取 MailboxService:IMAP 会话与 Web handler 共用,LIST 返回什么 Web 就显示什么
- IMAP 支持 CREATE/DELETE/RENAME/SUBSCRIBE(系统文件夹禁删改、非空禁删)
- Web 删除改为 IMAP 语义:移入 Trash,新增恢复/彻底删除/清空
- 新增通用 /folder/:name 页面与动态侧边栏,/inbox /sent /drafts 保留兼容重定向
2026-08-20 01:25:40 +08:00
kevin 962c5f454c fix(imap): 升级 go-imap v2 + \Deleted 落库,修复删除邮件只复制到垃圾桶
删除流程(Apple Mail/iOS 等):UID COPY→Trash + UID STORE \Deleted +
UID EXPUNGE。此前 go-imap v1.2.1 不支持 UID EXPUNGE(回
Command unsupported with UID),且 \Deleted 只存内存会话 map(重选/
重连即丢失),导致 EXPUNGE 不删原邮件——垃圾桶只有副本、INBOX 原封
不动。

- 依赖:go-imap v1.2.1 → go-imap/v2 v2.0.0-beta.8(原生支持 UID
  EXPUNGE,且库为 race-clean,移除集成测试 !race 标签)
- Message 新增 IsDeleted 字段持久化 \Deleted 标记(AutoMigrate 自动
  加列,SQLite/MySQL 通用;migrate 工具随模型字段自动复制)
- IMAP 层重写为 v2 imapserver.Session 架构(session.go 替代
  backend.go):SELECT/STATUS/LIST/FETCH/SEARCH/STORE/COPY/MOVE/
  APPEND/EXPUNGE/Poll/Idle;EXPUNGE 按数据库 IsDeleted 删除,UID
  EXPUNGE 只删集合内已标记消息
- server.go:显式能力集(IMAP4rev1+UIDPLUS+MOVE+LITERAL++CHILDREN+
  SPECIAL-USE);自实现 mailboxHub 跨会话推送(含来源排除,避免 v2
  库 MailboxTracker 对 EXPUNGE/EXISTS 的回声重复响应);Pusher 接口
  签名不变,Web/SMTP/POP3 调用方零改动;DisconnectByAddr 改用会话
  注册表 + conn.Bye()
- 测试:集成测试改用 v2 imapclient,新增删除流程/跨会话持久化/MOVE
  回归用例;notify_test 适配 hub 推送模型并覆盖来源排除与
  allowExpunge 语义
2026-08-20 00:44:37 +08:00
kevin f533fc8feb Merge pull request 'feat(db): 支持 MySQL 迁移(修复模型外键冲突 + 大文本类型 + 迁移工具)' (#13) from dsh/mailgo:fix/mysql-migration into main
Reviewed-on: kevin/mailgo#13
2026-08-19 11:49:06 -04:00
kevin 8be5df57c6 Merge pull request 'fix(auth+store): 协议登录成功清零失败计数/支持裸用户名 + 邮件日期统一 UTC 排序' (#12) from dsh/mailgo:fix/auth-reset-and-date-sort into main
Reviewed-on: kevin/mailgo#12
2026-08-19 11:48:59 -04:00
kevin 7a394bbd9e Merge pull request 'fix(imap): UIDVALIDITY 改为持久化随机值,修复数据库重建后客户端缓存永不失效(TB 只显示 1-3 封)' (#11) from dsh/mailgo:fix/imap-uidvalidity into main
Reviewed-on: kevin/mailgo#11
2026-08-19 11:48:51 -04:00
dsh 9607c4f023 fix(migrate): 目标库缺少 mailbox_states 表时自动建表(兼容上游模型未含 MailboxState 的场景) 2026-08-19 11:27:25 -04:00
dsh 4367f7fb92 fix(migrate): mailbox_states 改用原生 SQL 迁移,不依赖 MailboxState 模型(兼容未合并 UIDVALIDITY 特性的上游) 2026-08-19 11:27:00 -04:00
dsh af839a1378 chore: 移除误提交的 migrate 编译产物,加入 .gitignore 2026-08-19 11:26:32 -04:00
dsh 07e81fc328 feat(db): 支持 MySQL 迁移(修复模型外键冲突 + 大文本类型 + 迁移工具)
- Attachment 模型移除 Message 关联:其 foreignKey 名 MessageID 与
  Message.MessageID 字符串字段冲突,GORM AutoMigrate 会生成错误外键
  (messages.message_id → attachments.id 且强转 bigint),MySQL 下建表
  直接失败(SQLite 因动态类型侥幸可用)
- Message.TextBody/HtmlBody 改 mediumtext:MySQL TEXT 仅 64KB,
  大 HTML 邮件会写入失败
- 新增 cmd/migrate:SQLite → MySQL 一次性迁移工具(GORM 模型读源、
  批量写目标、时间统一 UTC、ban_entries 零值时间转 NULL、逐表校验)
2026-08-19 11:26:32 -04:00
dsh aa0437fb4f perf(imap): STORE 批量持久化 + 推送序号复用已加载列表,消除逐条写库与全量扫描
原实现每封匹配消息单独 UPDATE + GetByID + seqOf 全量扫描(含大
附件 raw_data),手机整批标记已读(60+ 封)时产生 60 次写 +
180 次全量读,连接被长时间占住,其他连接响应被推送洪泛阻塞。

优化:收集目标状态后合并为单条 UPDATE ... IN 批量写;推送更新
直接用已加载列表的序号与目标状态(buildFlagsUpdateAt),不再
重复查库。实测 19 封批量 STORE 服务器耗时 372µs。
2026-08-19 11:13:42 -04:00
dsh 9fef9da830 fix(imap): 新邮件推送补发 EXISTS 通知,修复 Apple Mail 等客户端需手动同步
问题:go-imap 更新通道只能表达 FETCH/EXPUNGE 类更新,新邮件到达时
客户端(IDLE 中)只收到裸 FETCH。不少客户端(如 Apple Mail)只认
RFC 2177 要求的 EXISTS 才会唤醒拉取,表现为'必须手动点击同步'。

修复:PushNewMessage 先向所有已选中该邮箱的匹配连接直接写入
'* N EXISTS'(Dovecot 同款行为),再广播 FETCH 更新。
实测 IDLE 客户端按序收到 '* 21 EXISTS' + '* 1 FETCH (UID ...)'。
2026-08-19 10:56:26 -04:00
dsh 9df17a7509 feat(web): Web 界面时间按配置时区显示(默认 Asia/Shanghai),邮件日期 UTC 存储后显示不受影响
- 新增 [web].timezone 配置(IANA 时区或固定偏移,默认 Asia/Shanghai)
- 新增 localTime 模板函数;shortDate 的"今天"判断也按展示时区
- view.html 邮件时间改用 localTime 渲染
2026-08-19 10:45:23 -04:00
dsh d9bb3cbf3f fix(store): 邮件日期统一 UTC 存储,修复混合时区偏移导致 Web/IMAP 排序错乱
根因:date 列在 SQLite 中是文本,历史数据混存 +08:00/-04:00/+00:00
等时区偏移,ORDER BY date DESC 变成字符串字典序而非时间序:
- Web 列表最新邮件排不到最前(如 22:21+08 的邮件排在 20:03+08 后面)
- IMAP 序号与客户端按日期排序的视图不一致

修复:
- mailStore.Create 统一 msg.Date = msg.Date.UTC()(所有写入路径
  共用:SMTP/IMAP APPEND/Web/外发回执),新数据字典序即时间序
- 生产数据一次性迁移为 UTC 格式(130 条,Python 全量解析转换)
- 验证:kevin INBOX 排序 172→170→166→164…(严格最新在前)
2026-08-19 10:35:50 -04:00
dsh f48776e89b fix(auth): 协议登录(IMAP/SMTP/POP3)成功时清零失败计数 + 支持裸用户名
问题:ResetFail 只在 Web 登录调用,协议层失败计数只增不减。
合法用户的客户端(手机 APP 用裸用户名重试、配置向导探测、输错
密码等)失败次数持续累积,每达到阈值就触发一次封禁档位,从第 4
次触发起真实封禁 30 分钟+——用户被反复误封,手机端表现为一直
卡在"正在接收邮件"。

修复:
- IMAP/SMTP/POP3 认证成功路径调用 Bans.ResetFail(与 Web 一致)
- 新增 UserStore.AuthenticateLogin:支持裸用户名(如 "kevin"),
  唯一归属时自动解析到其域名;跨域名同名歧义时要求完整邮箱
- 新增 TestAuthenticateLoginBareUsername 单元测试
2026-08-19 10:12:28 -04:00
dsh 6d5a7a8ed4 fix(imap): mailbox_states 表加入 AutoMigrate(此前漏迁移导致 UIDVALIDITY 回退 1) 2026-08-19 09:45:39 -04:00
dsh 7ede29a18a fix(imap): UIDVALIDITY 改为持久化随机值,修复数据库重建后客户端缓存永不失效
根因:Status() 硬编码 UidValidity=1。数据库重建后消息 ID 空间
完全变化(uid 2-166 对应完全不同的邮件),但 UIDVALIDITY 不变,
Thunderbird 认为本地缓存(旧 uid 1-166)仍然有效,只下载"新增"
(uid 167:*)与"缺失"(2,4,8,9)的少数邮件——列表只剩 1-3 封,
每次刷新数量随机。

修复(RFC 3501 UIDVALIDITY 语义):新增 mailbox_states 表,
每(用户,文件夹)首次访问随机生成并持久化 UIDVALIDITY;数据库
重建后新值触发客户端丢弃缓存全量重同步。0 值修正,不同用户/
文件夹互相独立。

新增 TestMailboxStateUidValidity 单元测试。
2026-08-19 09:36:55 -04:00
kevin f8fbe1ebdb Merge pull request 'fix(imap): FETCH BODY/BODYSTRUCTURE 解析失败时服务器 panic,客户端只能取到部分邮件' (#10) from dsh/mailgo:fix/imap-fetch-body-panic into main
Reviewed-on: kevin/mailgo#10
2026-08-19 08:55:22 -04:00
dsh 3a4636c2c5 fix(imap): FETCH BODY/BODYSTRUCTURE 解析失败时服务器 panic 导致客户端只能取到部分邮件
根因:backendutil.FetchBodyStructure 对部分消息返回 nil(典型场景:
- message/rfc822 附件为 base64 编码时库内不解码,把编码文本当嵌套
  消息头解析报错(转发邮件场景,如 .mail-monitor/forward.py 转发)
- multipart 缺少结束边界(截断)时 extended 解析报错)

buildIMAPMessage 未处理 nil,go-imap 格式化 FETCH 响应时在 send()
协程 nil 指针解引用 panic,连接中断——Thunderbird 只取到崩溃前已
发送的几封邮件,手机客户端一直卡在"正在获取邮件"。

修复:
- BodyStructure 解析失败时降级为 text/plain 单段结构,杜绝 nil
- FetchBodySection 返回 nil 时跳过该 section,不再写入 nil literal
- 新增集成测试 TestFetchBodyMalformedMIME 覆盖两类畸形 MIME
2026-08-19 08:52:39 -04:00
kevin 07ba01a6f6 fix(imap): 修复 Thunderbird 未读状态丢失(SQLite 并发写 + 客户端 seq 错位)
A. SQLite 并发写失败被吞(高概率根因):
- DSN 追加 _busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL,
  WAL 下读不阻塞写,消除瞬时 SQLITE_BUSY
- UpdateMessagesFlags 不再吞 MarkReadState/MarkFlagged 错误:
  出错记日志并返回 error(客户端收到 NO 会重试)
- Web MarkRead/Delete 同款静默丢错补日志

B. 客户端 seq 视图错位(集成测试复现后修复):
- 真实服务器+脚本客户端测试复现:客户端按日期倒序自编号发
  seq 式 STORE 时,旧排序(id ASC)把最旧邮件标为已读
- 规范排序改为 date DESC, id DESC(最新在前),与主流客户端
  默认视图一致;buildNewMessageUpdate 改用 seqOf 取真实序号
- 新增 UID STORE / 服务器下发 seq STORE / 自编号 seq STORE 集成测试

注:go-imap v1.2.1 存在库内 *conn.silent() 数据竞争(启用推送后
必然触发),集成测试以 //go:build !race 排除,-race 下由单元测试
覆盖推送逻辑
2026-08-19 20:30:06 +08:00
kevin 278c16c36a fix(imap): 修复多监听器广播更新导致 close of closed channel panic
broadcastUpdate 此前把同一个 backend.Update 对象推送到明文/TLS 两个
监听器的推送通道,两个 go-imap listenUpdates goroutine 各自对
update.Done() 执行 close,二次关闭同一 channel 触发 panic
(server.go:373 close of closed channel)。

修复:按监听器克隆 Update 对象(载荷共享、Done channel 独立),
新增回归测试:两个监听器收到的更新 Done channel 必须不同,且各自
close 不 panic;全量 -race 通过
2026-08-19 19:58:18 +08:00
kevin 6a9dcd0285 feat(imap): 多客户端状态实时同步 + 当前连接「断开并封禁」
- 推送扩展:IMAP STORE(已读/星标/\Deleted)推送 FETCH 标志更新、
  EXPUNGE 推送 ExpungeUpdate(删除前序号)、APPEND/COPY/MOVE 推送
  新邮件;POP3 QUIT 删除、Web 标已读/删除同样实时同步到 IMAP 客户端
- Pusher 接口统一 SMTP/POP3/Web 的推送入口,IMAP 内部操作经会话
  通道直接入队(非阻塞,满则丢弃)
- 当前连接页新增「断开并封禁」:connhub 支持断开回调,SMTP/POP3
  关底层连接、IMAP 经 ForEachConn 按地址断开;一键封禁 180 天并
  断开该 IP 全部在线连接,黑名单页可随时解封
- 修复:POP3 PASS 成功后保留完整邮箱(此前被裸用户名覆盖)
- 新增测试:断开/按 IP 断开、flags/expunge 推送内容、POP3 删除推送、
  Web 断开封禁处理器;全量 -race 通过
2026-08-19 19:56:04 +08:00
kevin ede85e0698 feat(imap): 新邮件实时推送(IDLE)+ 后台当前连接监控
- IMAP 推送:imapBackend 实现 backend.BackendUpdater,SMTP 本地投递
  与 Web 写信投递成功后 NotifyNewMessage,挂起 IDLE 的客户端即时收到
  新邮件 FETCH 通知(按用户名+INBOX 过滤广播,通道满非阻塞丢弃)
- 当前连接:新增 internal/connhub 连接注册中心,SMTP/IMAP/POP3 三协议
  注册/注销/用户名/TLS/活跃时间追踪;后台新增「当前连接」页
  (/admin/connections,统计卡片+连接表格,每 5 秒自动刷新)
- 新增测试:connhub 并发安全、推送内容/非阻塞/nil 安全、
  后台页面渲染;全量 -race 通过
2026-08-19 19:40:05 +08:00
kevin b158b8f1f5 feat(outbound): 外发投递多线程化(worker 池 + 每域并发上限)
- 1 个 dispatcher + N 个 worker 池(默认 4)并行投递,启动时立即
  扫描清空积压;workers=0/1 退化为串行(旧行为)
- 每收件域并发信号量(默认 2,配置 max_concurrent_per_domain),
  防对单个 MX 域并发过多被判定滥发;中继模式统一按 relay 限制
- store 新增 Claim 原子抢占(sending 状态防重复投递),并发下
  每封邮件恰好投递一次
- 新增配置 workers/batch_size(替代硬编码 50);dispatcher 改为
  抢占后投递、worker 只负责发送,退避重试/退信/状态机不变
- 新增测试:真并发、串行回退、每域上限、临时/永久失败、错误隔离、
  Claim 并发原子性与状态可抢占性;-race 无警告
2026-08-19 19:23:27 +08:00
kevin 6d73171207 fix(web): 管理后台内容被固定顶栏遮挡
.container 缺少顶部内边距,固定定位的 topbar(56px)盖住后台页面
标题/统计卡片/表格表头;为用户页面同等的 --topbar-h 下移空间,
移动端断点同步修正(50px)
2026-08-19 19:11:32 +08:00
kevin f2493da03e feat(security): IP 阶段性封禁,前3次触发不封禁、第4次起按档位递增
- 封禁规则:达到失败阈值记为一次触发,前 3 次只计数不封禁;
  第 4 次起封禁并按档位递增:30分钟(ban_duration_min)→ 3小时
  → 3个月 → 半年(上限);封禁过期后保留记录作为升档依据,
  成功登录或管理员解封清零
- BanEntry 新增 BanCount(累计触发次数),每 IP 一条记录 upsert,
  不再重复建行;RecordAuthFailure 统一 Web/LDAP/SMTP/IMAP/POP3
  五处封禁逻辑,原因带档位(如"第1次封禁:登录失败次数过多
  (第4次触发,失败5次)")
- 黑名单页修复:列表仅显示已封禁或曾封禁记录(原因/到期时间必填),
  新增封禁次数列与封禁中/已过期状态徽章,移除清理过期按钮
- 用户封禁页显示第 N 次封禁档位;新增档位升级与列表过滤单测
2026-08-19 19:07:25 +08:00
kevin 353bfa88f2 feat: 新增 SMTP/IMAP/POP3 协议调用日志(含攻击分析筛选)
- 每个连接记录一条日志:协议、端口、来源 IP、用户名、成功/失败、
  失败原因(密码错误/IP封禁/中继被拒/发件人伪造/未认证发信等)、
  操作摘要、消息数与会话时长
- 管理后台新增「协议日志」页:按协议/状态/IP/用户名/时间筛选,
  今日与历史成功/失败统计卡片,分页查看,可手动清理
- 后台每 6 小时自动清理超出 protocol_log_keep_days(默认30天)
  的日志;新增 [web] protocol_log_keep_days 配置项
- 修复 POP3 认证既有 bug:handleUSER 丢弃邮箱域名导致 PASS 永远失败
- 新增 store 单测、SMTP/POP3 端到端测试与模板渲染测试
2026-08-19 18:49:29 +08:00
kevin 8ea4a623a9 fix(security): 修复 P3 低危项(开放重定向/配额TOCTOU/safeJS/会话治理)
- Referer 开放重定向:safeRedirectPath 仅放行同站相对路径,
  外部 URL/协议跳转一律回退 /inbox
- 发信配额 TOCTOU:新增 TryReserveQuota 原子预扣
  (UPDATE ... WHERE used_bytes + n <= quota_bytes),超配额即拒发;
  附件保存失败按大小补偿回退
- 移除危险模板函数 safeHTML/safeJS:新增 jsonify(json.Marshal,
  < > & 转义为 \u003c 等,无法逃出 </script>),compose 页
  quill.innerHTML 改用 jsonify;srcdoc 改回默认属性转义
- 会话治理:登录成功后 session.Clear() 清旧状态;记录 loginAt,
  绝对过期 7 天 + 滑动续期(活跃会话 12h 写回刷新)
- 确认 #15 Content-Disposition 编码随 P1 #4 已完成
- 新增 12 个测试:重定向路径矩阵、配额原子性(含超额不部分扣费)、
  jsonify 逃逸防护、会话绝对过期/有效访问(签名会话构造)

至此 16 项安全审计项(P0-P3)全部修复完成。
2026-08-19 16:56:23 +08:00
wuwenfengmi1998 8cfeb43c6a docs: 新增移动端界面截图(响应式布局) 2026-08-19 16:48:26 +08:00
kevin 3f28ec20f4 fix(security): 修复 P2 中危项(cookie/协议限速/路径遍历/默认口令/中继TLS/安全头/信息泄露)
- 会话 cookie 增加 Secure 标志;新增 [web].cookie_secure 配置
  (默认 true,仅本地 HTTP 调试关闭;缺失字段按安全默认处理)
- SMTP/IMAP/POP3 认证接入封禁体系(store.RecordAuthFailure 与 Web
  共用 ban_entries):失败计数达 ban.max_fail_attempts 即封禁 IP,
  已封禁 IP 拒绝认证,堵住协议层暴力破解
- 附件存储路径遍历防护重写:FullPath 白名单校验(UUID 文件名格式)
  + baseDir 前缀兜底,非法路径返回错误;Save 扩展名白名单化
- 初始管理员不再使用 admin/admin:密码取 MAILGO_ADMIN_PASSWORD 或
  随机生成并打印一次;新增 MustChangePassword 首登强制改密
  (管理员重置密码同样触发)
- 外发中继默认验证 TLS 证书(保护 AUTH 凭据,防 MITM),直投 MX
  保持机会式 TLS;新增 outbound.relay_tls_insecure 开关(默认 false)
- 新增安全响应头中间件:HSTS、X-Frame-Options DENY、nosniff、
  Referrer-Policy、基础 CSP(frame-ancestors 'none' 防点击劫持,
  connect-src/form-action 'self' 防数据外泄)
- LDAP/OAuth 登录错误统一为通用文案,原始错误只写日志,
  不再回显邮箱/内部细节(防用户枚举与信息泄露)
- 新增 25 个回归测试:cookie 标志、封禁阈值、路径遍历用例、
  中继 TLS 验证(自签证书 STARTTLS 集成)、安全头、OAuth 文案

部署注意:升级后所有会话失效需重新登录;若直接以 HTTP 提供
服务需显式配置 cookie_secure = false。
2026-08-19 16:45:21 +08:00
kevin c725d0b91e fix(security): 修复 P1 高危项(OAuth2 state / 伪造客户端IP / CRLF 邮件头注入)
- OAuth2: state 改为 crypto/rand 随机值并写入独立短期 cookie
  (主会话为 SameSite=Strict,跨站回调不携带,不能放主会话);
  回调用 ConstantTimeCompare 校验 state,缺失/不匹配返回 403,
  校验后立即清除保证一次性使用。原硬编码 mailgo_oauth2_state
  可被利用做授权码注入/登录 CSRF。
- 代理信任: engine.SetTrustedProxies 仅信任 127.0.0.1/::1。
  外部直连时 X-Forwarded-For 完全不可信,防止伪造客户端 IP
  绕过登录封禁或恶意封禁他人;本机 Caddy/Nginx 转发不受影响。
- CRLF 注入: Web 写信的 To/Cc/Subject 及附件文件名不再原样拼入
  MIME 头。新增 sanitizeHeaderField(strip CR/LF/NUL)、
  subject 按 RFC 2047 编码、附件名用 mime.FormatMediaType
  (RFC 2231);附件下载的 Content-Disposition 同步修复。
  消息构建抽为 buildOutgoingMessage 纯函数便于测试。
- test: 新增 13 个回归测试(trustedproxy / mail_injection /
  oauth2_state),覆盖伪造 XFF、注入载荷、state 校验全部分支。
2026-08-19 16:31:25 +08:00
kevin 551ed981de fix(security): 会话密钥改为随机生成,修复硬编码密钥可伪造管理员会话
安全审计 P0 修复:旧版会话签名密钥硬编码在源码中(源码公开即泄露),
任何人可据此伪造 isAdmin 会话接管后台。

- config: 新增 [web].secret_key,首启/升级时用 crypto/rand 自动生成
  32 字节随机密钥并持久化;旧硬编码值自动替换
- config: 支持 MAILGO_SECRET_KEY 环境变量覆盖(覆盖值不落盘)
- config: 配置文件权限收紧为 0600(同时保护 relay_password 等敏感字段)
- web: NewWebServer 校验密钥(空/旧默认值/短于16字节拒绝启动)
- test: 伪造会话拒绝、密钥生命周期(生成/持久化/重启稳定/env不落盘)等
  9 个测试
- docs: README 配置参考、security_todo.md 安全修复清单

部署注意:升级重启后所有用户需重新登录。
2026-08-19 16:21:28 +08:00
kevin 6484af7e63 Merge pull request 'fix: 返回列表后未读邮件状态不更新(BFCache 静默同步)' (#9) from dsh/mailgo:fix/unread-status-bfcache into main
Reviewed-on: kevin/mailgo#9
2026-08-17 23:18:30 -04:00
dsh bf283ba5fc fix: 返回列表后未读邮件状态不更新(BFCache 静默同步)
问题:打开未读邮件(阅读页已自动标记已读)后返回列表,浏览器从 BFCache
恢复旧 DOM,列表仍显示未读,需手动刷新。

修复:
- 三个列表页的交互脚本提取为公共模板 listjs(base.html),行为一致
- pageshow(persisted=true) 时静默 fetch 最新列表并局部更新行状态
  (未读类/圆点/已删除行/未读角标/总数),不整页刷新、保留滚动
- visibilitychange 兜底:标签页重新可见时也同步一次(离线失败静默忽略,
  仅 BFCache 场景失败才整页刷新)
- 验证:合成 pageshow 事件驱动同步,未读->已读局部更新 PASS;
  桌面/移动/平板布局回归无异常
2026-08-17 23:08:15 -04:00
kevin e3514246b0 Merge pull request 'feat: 前端移动端响应式适配' (#8) from dsh/mailgo:feature/mobile-responsive into main
Reviewed-on: kevin/mailgo#8
2026-08-17 22:48:48 -04:00
dsh f281121a4d feat: 前端移动端响应式适配(≤767px)
- 左侧文件夹栏在小屏下变为底部导航条:收件箱/草稿箱/已发送/管理后台 + 居中「写信」悬浮圆钮(带未读角标)
- 顶栏紧凑化:列表页搜索框自动下移为通栏第二行
- 邮件列表:隐藏正文摘要、缩小头像/时间列、行高加大便于触控,删除按钮常显
- 阅读页/写信页/设置页:内边距与字号收紧,配额条适配
- 管理后台:侧栏改为横向换行导航,表格横向滚动,统计卡两列排布
- 全部模板 viewport 增加 viewport-fit=cover(iOS 安全区),底部导航支持 env(safe-area-inset-bottom)
- settings.html 内联样式改为 .settings-main 类以便响应式覆盖
2026-08-17 13:21:19 -04:00
kevin fe1bf25d31 Merge pull request 'docs: README 增加界面预览截图(QQ 邮箱风格界面)' (#7) from dsh/mailgo:docs/readme-screenshots into main
Reviewed-on: kevin/mailgo#7
2026-08-17 04:51:15 -04:00
dsh df5ea7b228 docs: README 增加界面预览截图与 QQ 邮箱风格界面说明 2026-08-17 04:50:53 -04:00
kevin 9e7e8e1fb5 Merge pull request 'feat: 前端整体重写为 QQ 邮箱风格布局' (#6) from dsh/mailgo:feature/qq-mail-frontend into main
Reviewed-on: kevin/mailgo#6
2026-08-17 04:42:34 -04:00
dsh 878d31b48e feat: 前端整体重写为 QQ 邮箱风格布局
- base.html: 全新设计系统(顶部导航栏 + 左侧文件夹栏 + 内容区三栏布局,
  蓝/橙主题色),兼容管理后台原有组件类
- inbox/drafts/sent: 邮件列表页重写,支持全选、批量删除、实时搜索过滤、
  未读标记、头像圆标、QQ 式短日期、悬停行内删除、分页
- view: 邮件阅读页重写(返回/回复/删除工具条、发件人卡片、附件区)
- compose: 写信页重写(发送/附件/取消、字段行、附件 chips、配额进度条)
- settings/login/banned: 设置页(账号信息+配额条)、登录页、封禁页重写
- server.go: 新增模板函数 mailName/mailEmail/initial/truncate/shortDate/avatarStyle
- mail.go: 侧栏文件夹计数(收件箱未读红标、草稿/已发送数量)
- 新增 render_test.go 模板渲染回归测试
2026-08-17 04:42:03 -04:00
kevin 9036523d9f Merge pull request 'fix: 修复编辑域名时私钥留空仍报“TLS 私钥和公钥证书必须同时填写”' (#5) from dsh/mailgo:caddy-cert-hot-reload into main
Reviewed-on: kevin/mailgo#5
2026-08-16 23:46:57 -04:00
dsh eed0c7b95f fix: 修复编辑域名时私钥留空仍报“TLS 私钥和公钥证书必须同时填写”
浏览器提交 textarea 会把换行规范为 CRLF,而证书文件(尤其是一键从
Caddy 导入的)是 LF,导致“证书未修改”的比较失效,私钥留空(保留
现有私钥)时误报必须同时填写。

- handleDomainTLSUpdate 增加 normalizePEM:提交值与磁盘文件统一
  归一化为 LF 后再比较/写入
- 新增 3 个回归测试:CRLF 提交证书未变+私钥留空、CRLF 新证书对
  保存为 LF、证书已变但私钥留空仍应报错
2026-08-16 23:45:44 -04:00
kevin f24c939eda Merge pull request 'feat: 管理后台一键从 Caddy 获取证书 + TLS 证书热加载' (#4) from dsh/mailgo:caddy-cert-hot-reload into main
Reviewed-on: kevin/mailgo#4
2026-08-16 23:41:25 -04:00
dsh f0b9ad3e6f feat: 管理后台一键从 Caddy 获取证书 + TLS 证书热加载
- 域名编辑页新增“从 Caddy 获取证书”按钮:一键把本机 Caddy 已签发的
  证书与私钥导入该域名的 TLS 目录并自动启用 TLS,支持通配符证书
  (如 *.example.com 可匹配 mail.example.com),成功/失败均回显横幅
- 新增 internal/caddycert:搜索 Caddy 证书存储(同步镜像目录优先、
  caddy.data_dir 与常见位置兜底),校验密钥对/有效期/SAN,并给出
  可操作的中文错误提示(未找到/证书无效/权限不足)
- install.sh 新增 setup-caddy-cert:安装 root 权限的 systemd
  path+timer 同步任务(mailgo-caddy-sync),把 Caddy 证书树镜像到
  /srv/mail_go/tls/caddy(证书续期后自动更新、每日兜底),另授予
  ACL 作为直接读取兜底;install 时自动检测并配置
- 新增 [caddy] data_dir 配置节,支持自定义 Caddy 数据目录
- 新增 internal/tlsutil:TLS 证书热加载器,每次握手按需重载证书
  文件(mtime 检测),重载失败继续使用旧证书兜底并节流重试;
  应用于 SMTPS 465/IMAPS 993/POP3S 995 与 STARTTLS,导入或上传
  新证书后无需重启服务即生效
- 证书来源动态切换:协议显式配置优先,否则取首个启用 TLS 且有证书
  的域名(10 秒缓存),新域名一键导入证书后自动切换
- 更新 README 与界面文案(去掉“重启服务生效”提示)
2026-08-16 23:39:43 -04:00
105 changed files with 13708 additions and 2059 deletions
+10
View File
@@ -13,6 +13,12 @@
# Go 工具缓存
vendor/
# 例外:Web 端本地化的第三方前端库(Quill,CSP 要求同源加载)
!internal/web/static/vendor/
!internal/web/static/vendor/quill/
!internal/web/static/vendor/quill/quill.min.js
!internal/web/static/vendor/quill/quill.snow.css
# 日志文件
*.log
@@ -40,3 +46,7 @@ win/srv/
# 临时测试文件
login_test.html
migrate
# 本地构建产物
bin/
+84 -8
View File
@@ -1,18 +1,34 @@
# MailGo
Go 语言编写的轻量级邮件系统,集成 SMTP / IMAP / POP3 协议服务和 Web 管理界面。
Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 + 邮件列表三栏设计。
## 功能特性
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
- **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
- **Web 邮箱**收件箱、已发送、草稿箱、富文本编辑(Quill.js)、附件上传/下载
- **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、**并发 worker 池投递**(默认 4 线程 + 每收件域并发上限 2)、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
- **Web 邮箱**QQ 邮箱风格界面,支持收件箱 / 已发送 / 草稿箱、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计
- **协议调用日志**SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理)
- **IMAP 新邮件推送**:本地投递(SMTP/Web 写信)成功后实时推送,挂起 IDLE 的客户端即时收到新邮件通知(无需轮询);其他客户端造成的已读/星标/删除变化也实时同步(IMAP STORE/EXPUNGE、POP3 删除、Web 标已读/删除)
- **当前连接监控**:管理后台实时查看 SMTP/IMAP/POP3 活动连接(来源 IP、用户名、TLS、时长),每 5 秒自动刷新;支持「断开并封禁」一键封禁该 IP 全部在线连接(封禁 180 天,可随时解封)
- **外部认证**OAuth2Google / GitHub)、LDAP(可选,默认关闭)
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
- **多数据库**:默认 SQLite,可切换 MySQL
- **跨平台**Linux 生产部署 + Windows 本地调试
## 界面预览
| 收件箱 | 邮件阅读 |
|--------|----------|
| ![收件箱](docs/screenshots/inbox.png) | ![邮件阅读](docs/screenshots/view.png) |
| 写信 | 设置 | 登录 |
|------|------|------|
| ![写信](docs/screenshots/compose.png) | ![设置](docs/screenshots/settings.png) | ![登录](docs/screenshots/login.png) |
> 截图使用演示数据渲染,实际界面以部署为准。
## 快速开始
### 编译
@@ -69,6 +85,13 @@ attach_dir = "/srv/mail_go/attachments" # 附件存储目录
[web]
addr = ":8080" # 监听地址,支持 TCP 端口或 Unix socket
secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成
# 随机密钥并写入本文件(请妥善备份,泄露/丢失
# 分别意味着会话可被伪造/所有登录态失效)
cookie_secure = true # 会话 cookie 仅通过 HTTPS 传输(Secure 标志);
# 仅本地 HTTP 调试时才改为 false
protocol_log_keep_days = 30 # SMTP/IMAP/POP3 协议调用日志保留天数,
# 超出后由后台任务自动清理;0 表示不清理
[smtp]
addr = ":25" # SMTP 明文端口
@@ -106,11 +129,23 @@ ldap_use_tls = false
[ban]
max_fail_attempts = 5 # 登录失败次数阈值
ban_duration_min = 30 # 封禁时长(分钟)
ban_duration_min = 30 # 第 1 次封禁时长(分钟);之后按档位递增:
# 第 2 次 3 小时 → 第 3 次 3 个月 → 第 4 次起半年(上限)
# 前 3 次达到阈值只计数不封禁,成功登录后清零
[caddy]
data_dir = "" # Caddy 数据目录(含 certificates/ 的那个),
# 供后台一键导入证书;留空自动探测
# /var/lib/caddy/.local/share/caddy 等常见位置
[outbound]
hostname = "" # EHLO 主机名,留空使用 [smtp] domain
poll_interval = 15 # 外发队列扫描间隔(秒)
workers = 4 # 并发投递 worker 数(多线程并行发送,
# 大量邮件时吞吐提升;0/1 为串行)
batch_size = 50 # 每次扫描最多取出的待投递邮件数
max_concurrent_per_domain = 2 # 同一收件域(或中继)的最大并发连接数,
# 防被判定为滥发;0 表示不限制
max_attempts = 12 # 单封邮件最大投递尝试次数
retry_base_min = 5 # 重试退避基数(分钟),指数增长:5/10/20/40...
max_recipients = 50 # 单封邮件最大外部收件人数
@@ -122,6 +157,9 @@ relay_port = 587 # 465 = 隐式 TLS,其他端口按需
relay_user = "" # 中继认证用户名(AUTH PLAIN
relay_password = "" # 中继认证密码
relay_starttls = true # 非 465 端口是否使用 STARTTLS
relay_tls_insecure = false # 是否跳过中继服务器 TLS 证书验证;
# 默认验证证书(保护中继凭据),仅自签证书
# 内网中继且明确知晓风险时才改为 true
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
```
@@ -157,6 +195,16 @@ addr = "/run/mail_go/web.sock"
`addr``/` 开头时,Gin 自动以 Unix socket 方式监听。
### 3. 指定会话密钥(容器/多实例部署)
会话签名密钥可通过环境变量 `MAILGO_SECRET_KEY` 覆盖(优先于配置文件,且不会写入磁盘):
```bash
MAILGO_SECRET_KEY="$(openssl rand -hex 32)" mail_go
```
要求:长度至少 16 字节;留空时由配置文件提供(首次启动自动生成)。更换密钥后所有已登录会话立即失效,用户需重新登录。
Nginx 反向代理配置:
```nginx
@@ -207,6 +255,32 @@ tls_key = "/etc/mail_go/certs/server.key"
> # 私钥路径: /etc/letsencrypt/live/mail.example.com/privkey.pem
> ```
#### 从 Caddy 一键导入证书
如果本机已用 [Caddy](https://caddyserver.com/) 托管该域名 HTTPS(Caddy 会自动签发并续期证书),
可在管理后台 **域名管理 → 编辑域名** 页面点击 **“从 Caddy 获取证书”** 按钮,
一键把 Caddy 存储中的证书与私钥导入邮件服务(自动启用该域名的 TLS),无需手动复制 PEM 文件。
支持通配符证书(如 `*.example.com` 可匹配 `mail.example.com`)。
证书支持**热加载**:导入(或手动上传)后立即生效,无需重启服务——SMTP/IMAP/POP3
每次 TLS 握手会自动检查并重载变化的证书文件。
由于 Caddy 的证书目录仅 `caddy` 用户可读,install.sh 会安装一个 root 权限的证书同步任务
`mailgo-caddy-sync.{path,timer}`),把 Caddy 证书树镜像到 `/srv/mail_go/tls/caddy`
证书续期后自动同步,mail_go 始终可读;另外还会授予 ACL 权限作为直接读取的兜底。
安装时自动配置,也可手动执行:
```bash
sudo ./install.sh setup-caddy-cert # 自动探测 Caddy 数据目录并配置同步 + ACL
sudo ./install.sh setup-caddy-cert /path/to/caddy/data # 或手动指定数据目录
```
若 Caddy 数据目录不在常见位置,可在配置文件中显式指定:
```toml
[caddy]
data_dir = "/var/lib/caddy/.local/share/caddy"
```
### 4. 启用 OAuth2 登录(Google 示例)
```toml
@@ -321,16 +395,18 @@ mailgo/
│ │ ├── domain_store.go # 域名数据操作
│ │ ├── attachment_store.go # 附件数据操作
│ │ ├── outbound_store.go # 外发队列数据操作
│ │ ── ban_store.go # 封禁数据操作
│ │ ── ban_store.go # 封禁数据操作
│ │ └── protocol_log_store.go # 协议调用日志数据操作
│ ├── smtp_server/server.go # SMTP 服务
│ ├── outbound/
│ │ ├── mailer.go # MX 查询与 SMTP 出站客户端
│ │ ├── manager.go # 外发队列、重试、限速、退信
│ │ └── sign.go # DKIM 签名
│ ├── imap_server/
│ │ ├── server.go # IMAP 服务
│ │ └── backend.go # IMAP 后端
│ │ ├── server.go # IMAP 服务(监听器/能力/跨会话推送)
│ │ └── session.go # IMAP Session 实现(SELECT/FETCH/STORE/EXPUNGE
│ ├── pop3_server/server.go # POP3 服务
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
│ ├── storage/attachment.go # 附件文件存储
│ ├── dkim/keys.go # DKIM 密钥生成
│ ├── auth/
@@ -445,7 +521,7 @@ sudo journalctl -u mailgo -f
| 数据库 | SQLite(默认)/ MySQL |
| 配置格式 | TOML |
| SMTP | github.com/emersion/go-smtp |
| IMAP | github.com/emersion/go-imap v1 |
| IMAP | github.com/emersion/go-imap/v2 |
| POP3 | 手工实现 TCP 协议 |
| 密码哈希 | golang.org/x/crypto/bcrypt |
| 富文本 | Quill.js (CDN) |
+260
View File
@@ -0,0 +1,260 @@
// migrate 一次性工具:把 SQLite 数据迁移到 MySQLmailgo 库)。
// 用法:go run ./cmd/migrate -from /srv/mail_go/mail.db -dsn "mailgo:密码@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=UTC"
package main
import (
"flag"
"fmt"
"log"
"time"
"mail_go/config"
"mail_go/internal/db"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
fromDSN = flag.String("from", "/srv/mail_go/mail.db", "SQLite 数据库路径")
mysqlDSN = flag.String("dsn", "", "MySQL DSN(目标库,需已创建 mailgo 库与用户)")
)
func main() {
flag.Parse()
if *mysqlDSN == "" {
log.Fatal("缺少 -dsn")
}
// 目标:MySQLInitDB 内含 AutoMigrate,按当前模型建表)
mdb, err := db.InitDB(config.DatabaseConfig{Driver: "mysql", DSN: *mysqlDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
if err != nil {
log.Fatalf("连接 MySQL 失败: %v", err)
}
log.Println("MySQL 建表完成(AutoMigrate")
// 源:SQLite(只读)
sdb, err := db.InitDB(config.DatabaseConfig{Driver: "sqlite", DSN: *fromDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
if err != nil {
log.Fatalf("连接 SQLite 失败: %v", err)
}
sdb.Logger = logger.Default.LogMode(logger.Silent)
// 关闭 GORM 自动时间戳(保留原始 CreatedAt/UpdatedAt
mw := mdb.Session(&gorm.Session{SkipHooks: true})
stateWant := int64(-1) // mailbox_states 期望行数;-1 = 源库无此表不校验
// 按外键依赖顺序复制:domains → users → messages → attachments → 其余
// 所有时间统一 UTCMySQL DATETIME 无时区)。
utc := func(t time.Time) time.Time {
if t.IsZero() {
// MySQL DATETIME 最小年份 1000;零值由调用方转 NULL
return t
}
return t.UTC()
}
_ = utc
// ---- domains ----
var domains []db.Domain
if err := sdb.Order("id").Find(&domains).Error; err != nil {
log.Fatalf("读 domains: %v", err)
}
for i := range domains {
domains[i].CreatedAt = domains[i].CreatedAt.UTC()
domains[i].UpdatedAt = domains[i].UpdatedAt.UTC()
}
if err := mw.Create(&domains).Error; err != nil {
log.Fatalf("写 domains: %v", err)
}
log.Printf("domains: %d", len(domains))
// ---- users ----
var users []db.User
if err := sdb.Order("id").Find(&users).Error; err != nil {
log.Fatalf("读 users: %v", err)
}
for i := range users {
users[i].CreatedAt = users[i].CreatedAt.UTC()
users[i].UpdatedAt = users[i].UpdatedAt.UTC()
}
if err := mw.Create(&users).Error; err != nil {
log.Fatalf("写 users: %v", err)
}
log.Printf("users: %d", len(users))
// ---- messages ----
var msgs []db.Message
if err := sdb.Order("id").Find(&msgs).Error; err != nil {
log.Fatalf("读 messages: %v", err)
}
for i := range msgs {
msgs[i].Date = msgs[i].Date.UTC()
msgs[i].CreatedAt = msgs[i].CreatedAt.UTC()
}
if err := mw.Create(&msgs).Error; err != nil {
log.Fatalf("写 messages: %v", err)
}
log.Printf("messages: %d", len(msgs))
// ---- attachments ----
var atts []db.Attachment
if err := sdb.Order("id").Find(&atts).Error; err != nil {
log.Fatalf("读 attachments: %v", err)
}
for i := range atts {
atts[i].CreatedAt = atts[i].CreatedAt.UTC()
}
if err := mw.Create(&atts).Error; err != nil {
log.Fatalf("写 attachments: %v", err)
}
log.Printf("attachments: %d", len(atts))
// ---- outbound_messages(原样,含时间转 UTC----
var outs []db.OutboundMessage
if err := sdb.Order("id").Find(&outs).Error; err != nil {
log.Fatalf("读 outbound_messages: %v", err)
}
for i := range outs {
outs[i].NextAttemptAt = outs[i].NextAttemptAt.UTC()
if outs[i].CompletedAt != nil && !outs[i].CompletedAt.IsZero() {
u := outs[i].CompletedAt.UTC()
outs[i].CompletedAt = &u
}
outs[i].CreatedAt = outs[i].CreatedAt.UTC()
outs[i].UpdatedAt = outs[i].UpdatedAt.UTC()
}
if err := mw.Create(&outs).Error; err != nil {
log.Fatalf("写 outbound_messages: %v", err)
}
log.Printf("outbound_messages: %d", len(outs))
// ---- ban_entriesexpires_at 零值 → NULL----
rows, err := sdb.Raw("SELECT id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at FROM ban_entries ORDER BY id").Rows()
if err != nil {
log.Fatalf("读 ban_entries: %v", err)
}
defer rows.Close()
bans := 0
for rows.Next() {
var (
id uint
ip string
reason *string
failCount int
banCount int
expires *time.Time
created *time.Time
updated *time.Time
)
if err := rows.Scan(&id, &ip, &reason, &failCount, &banCount, &expires, &created, &updated); err != nil {
log.Fatalf("扫 ban_entries: %v", err)
}
norm := func(t *time.Time) *time.Time {
if t == nil || t.IsZero() {
return nil
}
u := t.UTC()
return &u
}
if err := mdb.Exec("INSERT INTO ban_entries (id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
id, ip, reason, failCount, banCount, norm(expires), norm(created), norm(updated)).Error; err != nil {
log.Fatalf("写 ban_entries id=%d: %v", id, err)
}
bans++
}
log.Printf("ban_entries: %d", bans)
// ---- protocol_logs ----
var logs []db.ProtocolLog
if err := sdb.Order("id").Find(&logs).Error; err != nil {
log.Fatalf("读 protocol_logs: %v", err)
}
for i := range logs {
logs[i].CreatedAt = logs[i].CreatedAt.UTC()
}
if err := mw.Create(&logs).Error; err != nil {
log.Fatalf("写 protocol_logs: %v", err)
}
log.Printf("protocol_logs: %d", len(logs))
// ---- mailbox_states(原生 SQL:该表随 UIDVALIDITY 特性存在,旧版本源库可能没有)----
var stateCount int64
sdb.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='mailbox_states'").Scan(&stateCount)
if stateCount > 0 {
// 目标库若没有该表(上游模型未含 MailboxState 时 AutoMigrate 不会建),先建表
var tcnt int64
mdb.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'mailbox_states'").Scan(&tcnt)
if tcnt == 0 {
if err := mdb.Exec(`CREATE TABLE mailbox_states (
user_id bigint unsigned NOT NULL,
folder varchar(64) NOT NULL,
uid_validity bigint unsigned NOT NULL,
created_at datetime(3) NULL,
updated_at datetime(3) NULL,
PRIMARY KEY (user_id, folder))`).Error; err != nil {
log.Fatalf("建 mailbox_states 表: %v", err)
}
log.Println("mailbox_states: 目标库已建表")
}
srows, err := sdb.Raw("SELECT user_id, folder, uid_validity, created_at, updated_at FROM mailbox_states ORDER BY user_id, folder").Rows()
if err != nil {
log.Fatalf("读 mailbox_states: %v", err)
}
defer srows.Close()
states := 0
for srows.Next() {
var (
userID uint
folder string
validity uint32
created *time.Time
updated *time.Time
)
if err := srows.Scan(&userID, &folder, &validity, &created, &updated); err != nil {
log.Fatalf("扫 mailbox_states: %v", err)
}
norm := func(t *time.Time) *time.Time {
if t == nil || t.IsZero() {
return nil
}
u := t.UTC()
return &u
}
if err := mdb.Exec("INSERT INTO mailbox_states (user_id, folder, uid_validity, created_at, updated_at) VALUES (?,?,?,?,?)",
userID, folder, validity, norm(created), norm(updated)).Error; err != nil {
log.Fatalf("写 mailbox_states: %v", err)
}
states++
}
log.Printf("mailbox_states: %d", states)
stateWant = int64(states)
} else {
log.Println("mailbox_states: 源库无此表,跳过")
}
// ---- 校验 ----
check := func(table string, want int64) {
var got int64
if err := mdb.Table(table).Count(&got).Error; err != nil {
log.Fatalf("校验 %s: %v", table, err)
}
if got != want {
log.Fatalf("校验 %s 失败: got %d want %d", table, got, want)
}
fmt.Printf("校验 %s: %d/%d ✓\n", table, got, want)
}
check("domains", int64(len(domains)))
check("users", int64(len(users)))
check("messages", int64(len(msgs)))
check("attachments", int64(len(atts)))
check("outbound_messages", int64(len(outs)))
check("ban_entries", int64(bans))
check("protocol_logs", int64(len(logs)))
if stateWant >= 0 {
check("mailbox_states", stateWant)
}
log.Println("迁移完成 ✅")
}
+161 -5
View File
@@ -1,7 +1,10 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
@@ -25,8 +28,36 @@ type StorageConfig struct {
// WebConfig holds web server settings.
type WebConfig struct {
Addr string `toml:"addr"`
// SecretKey 是 Web 会话 cookie 的签名密钥。留空时首次启动自动生成
// 随机密钥并持久化到配置文件;也可通过环境变量 MAILGO_SECRET_KEY
// 覆盖(覆盖值不落盘,适合容器部署)。
SecretKey string `toml:"secret_key"`
// CookieSecure 控制会话 cookie 是否仅通过 HTTPS 传输(Secure 标志)。
// 默认 true;仅当应用直接以 HTTP 提供服务(本地调试、内网明文)时
// 才应改为 false。
CookieSecure bool `toml:"cookie_secure"`
// ProtocolLogKeepDays SMTP/IMAP/POP3 协议调用日志保留天数,
// 超过该天数的记录会被后台任务自动清理。
ProtocolLogKeepDays int `toml:"protocol_log_keep_days"`
// Timezone Web 界面显示时间所用的 IANA 时区(如 "Asia/Shanghai")。
// 为空时使用服务器本地时区。邮件日期在库中统一为 UTC 存储,
// 展示时按此配置转换。
Timezone string `toml:"timezone"`
}
// SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。
const SecretKeyEnvVar = "MAILGO_SECRET_KEY"
// InsecureLegacySecretKey 是旧版本硬编码在源码中的会话签名密钥。
// 源码公开意味着该密钥完全不可信,任何出现都必须替换。
const InsecureLegacySecretKey = "mail-go-secret-key-change-in-production"
// MinSecretKeyLen 是允许的最短会话密钥长度(字节)。
const MinSecretKeyLen = 16
// secretKeyRandomBytes 是自动生成密钥的随机字节数(hex 编码后 64 字符)。
const secretKeyRandomBytes = 32
// SMTPConfig holds SMTP server settings.
type SMTPConfig struct {
Addr string `toml:"addr"`
@@ -79,6 +110,15 @@ type BanConfig struct {
BanDurationMin int `toml:"ban_duration_min"` // Default: 30 (minutes)
}
// CaddyConfig holds settings for importing TLS certificates from a local Caddy.
type CaddyConfig struct {
// DataDir is the Caddy data directory (the one containing the
// "certificates/" subdirectory), used by the one-click certificate
// import in the admin panel. Leave empty to auto-detect common
// locations such as /var/lib/caddy/.local/share/caddy.
DataDir string `toml:"data_dir"`
}
// OutboundConfig holds outbound (external) mail delivery settings.
type OutboundConfig struct {
Hostname string `toml:"hostname"` // EHLO 主机名,留空使用 [smtp] domain
@@ -90,6 +130,15 @@ type OutboundConfig struct {
MaxPerDay int `toml:"max_per_day"` // 每用户每日最大外发数,0 表示禁用外部投递
ConnectTimeout int `toml:"connect_timeout"` // 连接远程 MX 超时(秒)
// Workers 并发投递 worker 数:多 goroutine 并行发送队列中的邮件。
// 0 或 1 表示串行(旧行为)。
Workers int `toml:"workers"`
// BatchSize 每次扫描最多取出的待投递邮件数。
BatchSize int `toml:"batch_size"`
// MaxConcurrentPerDomain 同一收件域(或中继)的最大并发连接数,
// 防止对单个 MX 域并发过多而被判定为滥发;0 表示不限制。
MaxConcurrentPerDomain int `toml:"max_concurrent_per_domain"`
// Smarthost relay: when relay_host is non-empty, all external mail is
// delivered through this relay instead of direct MX delivery. Useful when
// the server IP is listed in PBL/blocklists (residential/dynamic IPs).
@@ -98,6 +147,10 @@ type OutboundConfig struct {
RelayUser string `toml:"relay_user"` // 中继认证用户名(AUTH PLAIN
RelayPassword string `toml:"relay_password"` // 中继认证密码
RelayStartTLS bool `toml:"relay_starttls"` // 非 465 端口是否使用 STARTTLS
// RelayTLSInsecure 是否跳过中继服务器的 TLS 证书验证。
// 默认 false(验证证书),避免凭据被中间人截获;仅当使用自签证书的
// 内网中继且明确知晓风险时才设为 true。
RelayTLSInsecure bool `toml:"relay_tls_insecure"`
// IP family and source address binding for outbound connections.
IPFamily string `toml:"ip_family"` // ipv4(默认,PTR/SPF 最可靠)| ipv6 | auto
@@ -114,6 +167,7 @@ type Config struct {
POP3 POP3Config `toml:"pop3"`
Auth AuthConfig `toml:"auth"`
Ban BanConfig `toml:"ban"`
Caddy CaddyConfig `toml:"caddy"`
Outbound OutboundConfig `toml:"outbound"`
}
@@ -160,6 +214,9 @@ func defaultConfig() *Config {
},
Web: WebConfig{
Addr: DefaultWebPort,
CookieSecure: true,
ProtocolLogKeepDays: DefaultProtocolLogKeepDays,
Timezone: DefaultTimezone,
},
SMTP: SMTPConfig{
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
@@ -184,6 +241,8 @@ func defaultConfig() *Config {
MaxFailAttempts: 5,
BanDurationMin: 30,
},
// Caddy: 留空则自动探测常见数据目录,无需配置
Caddy: CaddyConfig{},
Outbound: OutboundConfig{
PollInterval: 15, // 15 秒扫描一次队列
MaxAttempts: 12, // 最多尝试 12 次
@@ -195,6 +254,9 @@ func defaultConfig() *Config {
RelayPort: 587, // smarthost 默认提交端口
RelayStartTLS: true,
IPFamily: "ipv4",
Workers: DefaultOutboundWorkers,
BatchSize: DefaultOutboundBatchSize,
MaxConcurrentPerDomain: DefaultMaxConcurrentPerDomain,
},
}
}
@@ -221,6 +283,12 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
if cfg.Web.Addr == "" {
cfg.Web.Addr = defaults.Web.Addr
}
if cfg.Web.ProtocolLogKeepDays == 0 {
cfg.Web.ProtocolLogKeepDays = defaults.Web.ProtocolLogKeepDays
}
if cfg.Web.Timezone == "" {
cfg.Web.Timezone = defaults.Web.Timezone
}
if cfg.SMTP.Addr == "" {
cfg.SMTP.Addr = defaults.SMTP.Addr
}
@@ -277,6 +345,15 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
if cfg.Outbound.ConnectTimeout == 0 {
cfg.Outbound.ConnectTimeout = defaults.Outbound.ConnectTimeout
}
if cfg.Outbound.Workers == 0 {
cfg.Outbound.Workers = defaults.Outbound.Workers
}
if cfg.Outbound.BatchSize == 0 {
cfg.Outbound.BatchSize = defaults.Outbound.BatchSize
}
if cfg.Outbound.MaxConcurrentPerDomain == 0 {
cfg.Outbound.MaxConcurrentPerDomain = defaults.Outbound.MaxConcurrentPerDomain
}
if cfg.Outbound.RelayPort == 0 {
cfg.Outbound.RelayPort = defaults.Outbound.RelayPort
}
@@ -286,6 +363,63 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
return cfg
}
// generateSecretKey generates a cryptographically random session key,
// hex-encoded (64 characters for 32 random bytes).
func generateSecretKey() (string, error) {
buf := make([]byte, secretKeyRandomBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机会话密钥失败: %w", err)
}
return hex.EncodeToString(buf), nil
}
// ensureSecretKey guarantees that cfg holds a trustworthy session key:
// an empty value or the known insecure legacy default is replaced with a
// freshly generated random key. The caller is responsible for persisting
// the updated config.
func ensureSecretKey(cfg *Config) error {
if cfg.Web.SecretKey != "" && cfg.Web.SecretKey != InsecureLegacySecretKey {
return nil
}
key, err := generateSecretKey()
if err != nil {
return err
}
if cfg.Web.SecretKey == InsecureLegacySecretKey {
log.Printf("检测到不安全的旧默认会话密钥,已自动更换为随机密钥(所有已登录会话将失效)")
} else {
log.Printf("已生成随机会话密钥并写入配置文件(Web 会话签名密钥,请妥善备份)")
}
cfg.Web.SecretKey = key
return nil
}
// applySecretKeyEnv lets the MAILGO_SECRET_KEY environment variable
// override the key loaded from the config file. The override value is
// never persisted to disk.
func applySecretKeyEnv(cfg *Config) *Config {
if env := os.Getenv(SecretKeyEnvVar); env != "" {
cfg.Web.SecretKey = env
}
return cfg
}
// ValidateSecretKey rejects session signing keys that are missing, too
// short, or the known insecure legacy default hardcoded in old versions.
func ValidateSecretKey(key string) error {
if key == "" {
return fmt.Errorf("Web 会话密钥为空,拒绝启动:请检查配置文件 [web].secret_key 或环境变量 %s", SecretKeyEnvVar)
}
if key == InsecureLegacySecretKey {
return fmt.Errorf("Web 会话密钥为已知不安全的旧默认值,拒绝启动:请删除配置文件 [web].secret_key 后重启以自动生成随机密钥")
}
if len(key) < MinSecretKeyLen {
return fmt.Errorf("Web 会话密钥过短(%d 字节,最少 %d):请检查 %s 或 [web].secret_key",
len(key), MinSecretKeyLen, SecretKeyEnvVar)
}
return nil
}
// writeConfig writes the configuration to the given file path.
// It creates the parent directories if they don't exist.
func writeConfig(path string, cfg *Config) error {
@@ -304,6 +438,11 @@ func writeConfig(path string, cfg *Config) error {
if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
// 配置文件包含会话密钥、中继密码等敏感信息,收紧为仅属主可读写
if err := os.Chmod(path, 0600); err != nil {
return fmt.Errorf("设置配置文件权限失败 %s: %w", path, err)
}
return nil
}
@@ -311,15 +450,23 @@ func writeConfig(path string, cfg *Config) error {
// If the configuration file does not exist, it creates one with default values.
// If the file exists but has missing fields, they are filled with defaults and the file is updated.
func LoadConfig() (*Config, error) {
path := configFilePath()
return loadConfigFrom(configFilePath())
}
// loadConfigFrom implements LoadConfig against an explicit file path so it
// can be unit-tested with temporary directories.
func loadConfigFrom(path string) (*Config, error) {
defaults := defaultConfig()
// If config file doesn't exist, create it with defaults
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := ensureSecretKey(defaults); err != nil {
return nil, err
}
if mkErr := writeConfig(path, defaults); mkErr != nil {
return nil, mkErr
}
return defaults, nil
return applySecretKeyEnv(defaults), nil
}
// Read existing config file
@@ -333,11 +480,20 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
// relay_starttls defaults to true for safety; the raw file is checked
// because TOML decoding cannot distinguish an absent bool from false.
// relay_starttls 与 web.cookie_secure 默认值为 true for safety;
// the raw file is checked because TOML decoding cannot distinguish
// an absent bool from false.
if !strings.Contains(string(data), "relay_starttls") {
cfg.Outbound.RelayStartTLS = defaults.Outbound.RelayStartTLS
}
if !strings.Contains(string(data), "cookie_secure") {
cfg.Web.CookieSecure = defaults.Web.CookieSecure
}
// 会话密钥缺失或不安全时补发随机密钥(随下面的写回一并落盘)
if err := ensureSecretKey(cfg); err != nil {
return nil, err
}
// Merge defaults for any missing fields
merged := mergeDefaults(cfg, defaults)
@@ -348,5 +504,5 @@ func LoadConfig() (*Config, error) {
return nil, writeErr
}
return merged, nil
return applySecretKeyEnv(merged), nil
}
+191
View File
@@ -0,0 +1,191 @@
package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestGenerateSecretKey(t *testing.T) {
key, err := generateSecretKey()
if err != nil {
t.Fatalf("generateSecretKey() error: %v", err)
}
// 32 随机字节 hex 编码 = 64 字符
if len(key) != secretKeyRandomBytes*2 {
t.Fatalf("key length = %d, want %d", len(key), secretKeyRandomBytes*2)
}
// 两次生成必须不同
key2, err := generateSecretKey()
if err != nil {
t.Fatalf("generateSecretKey() error: %v", err)
}
if key == key2 {
t.Fatal("generated keys must be unique")
}
if key == InsecureLegacySecretKey {
t.Fatal("generated key must never equal the legacy insecure default")
}
}
func TestLoadConfigFirstBootGeneratesSecretKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "mail_go.toml")
cfg, err := loadConfigFrom(path)
if err != nil {
t.Fatalf("loadConfigFrom() error: %v", err)
}
if cfg.Web.SecretKey == "" {
t.Fatal("secret key should be generated on first boot")
}
// 密钥必须落盘,保证重启后会话不失效
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config file: %v", err)
}
if !strings.Contains(string(data), "secret_key = \""+cfg.Web.SecretKey+"\"") {
t.Fatalf("generated secret key should be persisted, file content:\n%s", data)
}
// 第二次加载返回相同密钥(会话保持)
cfg2, err := loadConfigFrom(path)
if err != nil {
t.Fatalf("second loadConfigFrom() error: %v", err)
}
if cfg2.Web.SecretKey != cfg.Web.SecretKey {
t.Fatalf("secret key must be stable across restarts: %q != %q", cfg2.Web.SecretKey, cfg.Web.SecretKey)
}
// 配置文件包含敏感信息,权限必须为 0600(Windows 无 POSIX 权限,跳过)
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat config file: %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("config file mode = %o, want 0600", perm)
}
}
}
func TestLoadConfigBackfillsSecretKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "mail_go.toml")
// 模拟旧版本升级:配置文件中没有 secret_key 字段
old := "[web]\naddr = \":9090\"\n\n[smtp]\ndomain = \"example.com\"\n"
if err := os.WriteFile(path, []byte(old), 0644); err != nil {
t.Fatal(err)
}
cfg, err := loadConfigFrom(path)
if err != nil {
t.Fatalf("loadConfigFrom() error: %v", err)
}
if cfg.Web.SecretKey == "" {
t.Fatal("missing secret key should be backfilled")
}
// 原有字段保持不变
if cfg.Web.Addr != ":9090" {
t.Fatalf("existing field overwritten: addr = %q", cfg.Web.Addr)
}
if cfg.SMTP.Domain != "example.com" {
t.Fatalf("existing field overwritten: domain = %q", cfg.SMTP.Domain)
}
}
func TestLoadConfigReplacesLegacySecretKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "mail_go.toml")
content := "[web]\naddr = \":8080\"\nsecret_key = \"" + InsecureLegacySecretKey + "\"\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := loadConfigFrom(path)
if err != nil {
t.Fatalf("loadConfigFrom() error: %v", err)
}
if cfg.Web.SecretKey == InsecureLegacySecretKey {
t.Fatal("legacy insecure secret key must be replaced")
}
if cfg.Web.SecretKey == "" {
t.Fatal("replacement key must be non-empty")
}
// 替换后的密钥必须落盘
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), InsecureLegacySecretKey) {
t.Fatal("legacy key should be removed from the config file")
}
if !strings.Contains(string(data), cfg.Web.SecretKey) {
t.Fatal("replaced key should be persisted")
}
}
func TestSecretKeyEnvOverride(t *testing.T) {
path := filepath.Join(t.TempDir(), "mail_go.toml")
// 先正常生成一个落盘密钥
cfg1, err := loadConfigFrom(path)
if err != nil {
t.Fatalf("first loadConfigFrom() error: %v", err)
}
// 环境变量覆盖运行时密钥
envKey := "env-override-secret-key-0123456789abcdef"
t.Setenv(SecretKeyEnvVar, envKey)
cfg2, err := loadConfigFrom(path)
if err != nil {
t.Fatalf("second loadConfigFrom() error: %v", err)
}
if cfg2.Web.SecretKey != envKey {
t.Fatalf("env var should override the file key: got %q", cfg2.Web.SecretKey)
}
// 环境变量的值不能落盘
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), envKey) {
t.Fatal("env-provided secret must not be persisted to disk")
}
// 落盘密钥保持原值
if !strings.Contains(string(data), cfg1.Web.SecretKey) {
t.Fatal("file key should remain unchanged when env override is active")
}
}
func TestValidateSecretKey(t *testing.T) {
cases := []struct {
name string
key string
wantErr bool
}{
{"empty", "", true},
{"legacy default", InsecureLegacySecretKey, true},
{"too short", "short", true},
{"valid hex key", "0123456789abcdef0123456789abcdef", false},
{"valid env style", "env-override-secret-key-0123456789abcdef", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateSecretKey(tc.key)
if tc.wantErr && err == nil {
t.Fatalf("expected error for key %q", tc.key)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for key %q: %v", tc.key, err)
}
})
}
}
+16
View File
@@ -36,5 +36,21 @@ const (
DefaultQuotaBytes int64 = 5 * 1024 * 1024 * 1024 // 5GB
)
// DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。
const DefaultProtocolLogKeepDays = 30
// DefaultTimezone 是 Web 界面显示时间的默认 IANA 时区(北京时间)。
const DefaultTimezone = "Asia/Shanghai"
// Outbound delivery concurrency defaults.
const (
// DefaultOutboundWorkers 并发投递 worker 数(0/1 为串行)。
DefaultOutboundWorkers = 4
// DefaultOutboundBatchSize 每次扫描最多取出的待投递邮件数。
DefaultOutboundBatchSize = 50
// DefaultMaxConcurrentPerDomain 同一收件域的最大并发连接数。
DefaultMaxConcurrentPerDomain = 2
)
// ConfigFileName is the name of the configuration file
const ConfigFileName = "mail_go.toml"
+17 -15
View File
@@ -83,8 +83,8 @@ mail_go/
│ ├── smtp_server/
│ │ └── server.go # SMTP 服务端(go-smtp Backend 实现)
│ ├── imap_server/
│ │ ├── server.go # IMAP 服务端启动
│ │ └── backend.go # go-imap Backend/User/Mailbox/Message 实现
│ │ ├── server.go # IMAP 服务端启动(监听器/能力/跨会话推送)
│ │ └── session.go # go-imap/v2 imapserver.Session 实现
│ ├── pop3_server/
│ │ └── server.go # POP3 服务端(TCP 监听 + 文本协议)
│ ├── web/
@@ -373,11 +373,11 @@ func (s *smtpSession) Logout() error
#### IMAP Backend 接口(go-imap/v2 要求)
```go
// internal/imap_server/backend.go
// internal/imap_server/session.go
// 实现 go-imap/v2 的 backend.Backend 接口
type imapBackend struct {
userStore store.UserStore
// 实现 go-imap/v2 的 imapserver.Session 接口
type imapSession struct {
stores *store.Stores
mailStore store.MailStore
domainStore store.DomainStore
attStore store.AttachmentStore
@@ -413,14 +413,16 @@ type imapMailbox struct {
| GET | /login | auth.ShowLogin | — | 登录页 |
| POST | /login | auth.DoLogin | — | 登录提交 |
| POST | /logout | auth.DoLogout | Auth | 登出 |
| GET | / | mail.Inbox | Auth | 收件箱(重定向到 /inbox |
| GET | /inbox | mail.Inbox | Auth | 收件箱列表 |
| GET | /inbox/:id | mail.View | Auth | 查看邮件 |
| GET | / | | Auth | 收件箱(重定向到 /inbox |
| GET | /folder/:name | mail.Folder | Auth | 通用文件夹页(目录与 IMAP LIST 同源) |
| GET | /folder/:name/:id | mail.View | Auth | 查看文件夹内邮件 |
| POST | /folder/:name/empty | mail.EmptyFolder | Auth | 清空文件夹 |
| GET | /inbox /sent /drafts(及 /:id | — | Auth | 旧路径兼容重定向到 /folder/<name> |
| GET | /compose | mail.Compose | Auth | 撰写页面 |
| POST | /compose | mail.DoSend | Auth | 发送邮件 |
| GET | /sent | mail.Sent | Auth | 发件箱 |
| GET | /sent/:id | mail.View | Auth | 查看已发送邮件 |
| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件 |
| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件(移入 Trash;Trash 内为彻底删除) |
| POST | /mail/restore/:id | mail.Restore | Auth | 恢复邮件到收件箱 |
| POST | /mail/purge/:id | mail.Purge | Auth | 彻底删除邮件 |
| POST | /mail/read/:id | mail.MarkRead | Auth | 标记已读 |
| GET | /attachment/:id | mail.DownloadAttachment | Auth | 下载附件 |
| GET | /admin | admin.Dashboard | Auth + Admin | 管理后台首页 |
@@ -557,7 +559,7 @@ sequenceDiagram
| Task ID | 任务名称 | 依赖 | 涉及文件 | 优先级 |
|---------|---------|------|---------|--------|
| T01 | 项目基础设施:go.mod + 入口 + 配置系统 + 数据库层 | — | go.mod, main.go, config/config.go, config/defaults.go, internal/db/db.go, internal/db/models.go, internal/store/*.go | P0 |
| T02 | 邮件协议服务端(SMTP + IMAP + POP3 | T01 | internal/smtp_server/server.go, internal/imap_server/server.go, internal/imap_server/backend.go, internal/pop3_server/server.go | P0 |
| T02 | 邮件协议服务端(SMTP + IMAP + POP3 | T01 | internal/smtp_server/server.go, internal/imap_server/server.go, internal/imap_server/session.go, internal/pop3_server/server.go | P0 |
| T03 | Web 服务核心:路由 + 中间件 + 认证 + 邮件页面 | T01 | internal/web/server.go, internal/web/middleware/auth.go, internal/web/middleware/admin.go, internal/web/handlers/auth.go, internal/web/handlers/mail.go | P0 |
| T04 | 管理后台 + 附件存储 + 模板 | T01, T03 | internal/web/handlers/admin.go, internal/storage/attachment.go, internal/web/templates/*.html | P0 |
| T05 | 集成调试 + 安装脚本 | T02, T03, T04 | main.go(更新), scripts/install.sh | P1 |
@@ -594,7 +596,7 @@ sequenceDiagram
- 管理后台 handler:域名 CRUD、用户 CRUD、DNS 提示
- AttachmentStorage:附件文件写入/读取/删除磁盘操作
- 所有 HTML 模板:base 布局 + login/inbox/compose/sent/view/admin 系列页面
- 所有 HTML 模板:base 布局 + login/folder/compose/view/admin 系列页面
- 附件上传(compose 页面多文件上传)+ 下载 handler
**T05: 集成调试 + 安装脚本**
@@ -614,7 +616,7 @@ go 1.22
require (
github.com/emersion/go-smtp v0.21.0
github.com/emersion/go-imap/v2 v2.0.0-beta.5
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.0
github.com/gin-gonic/gin v1.10.0
github.com/gin-contrib/sessions v0.0.5
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+2 -2
View File
@@ -4,7 +4,7 @@ go 1.25.0
require (
github.com/BurntSushi/toml v1.4.0
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/emersion/go-msgauth v0.7.0
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
@@ -13,6 +13,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.2
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.35.0
@@ -38,7 +39,6 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+2 -6
View File
@@ -17,19 +17,16 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
@@ -163,7 +160,6 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+165 -1
View File
@@ -214,6 +214,20 @@ do_install() {
# 4. 拉取代码并编译
build_binary
# 4.5 配置 Caddy 证书同步(若检测到 Caddy),供后台一键导入使用
local caddy_data
caddy_data="$(find_caddy_data_dir 2>/dev/null || true)"
if [[ -n "${caddy_data}" ]]; then
info "检测到 Caddy${caddy_data}),配置证书同步任务 ..."
if install_caddy_sync "${caddy_data}"; then
ok "Caddy 证书同步已配置(后台可一键导入证书)"
else
warn "Caddy 证书同步配置失败,可稍后手动执行: sudo $0 setup-caddy-cert"
fi
# ACL 兜底(失败不影响使用)
command -v setfacl &>/dev/null && setup_caddy_acls "${caddy_data}" || true
fi
# 5. 部署文件
deploy_files
@@ -250,6 +264,153 @@ do_install() {
warn "⚠ 请登录后立即修改默认密码!"
}
# ======================== Caddy 证书同步 ========================
# mail_go 后台有一键“从 Caddy 获取证书”功能:从 Caddy 的证书存储读取
# 域名证书与私钥并导入邮件服务。Caddy 证书目录默认仅 caddy 用户可读
# (0700/0600),且证书续期后文件会被替换(权限重置),因此安装一个
# root 权限的 systemd path/timer 同步任务,把 Caddy 证书树镜像到
# mail_go 可读的 <storage>/tls/caddy 目录,续期后自动更新;
# 同时授予 ACL 作为直接读取的兜底。用法: sudo ./install.sh setup-caddy-cert [caddy数据目录]
# 探测 Caddy 数据目录(包含 certificates/ 子目录的那个目录)
find_caddy_data_dir() {
local candidates=(
"/var/lib/caddy/.local/share/caddy"
"/root/.local/share/caddy"
"/home/caddy/.local/share/caddy"
)
local d
for d in "${candidates[@]}"; do
if [[ -d "${d}/certificates" ]]; then
echo "${d}"
return 0
fi
done
# systemd 服务可能配置了自定义 HOME
local home
home=$(systemctl show caddy -p Environment --value 2>/dev/null | grep -oP '(?<=HOME=)[^ ]+' || true)
if [[ -n "${home}" && -d "${home}/.local/share/caddy/certificates" ]]; then
echo "${home}/.local/share/caddy"
return 0
fi
return 1
}
# 用 ACL 授予 mail_go 用户读取 Caddy 证书的权限(幂等,兜底用:
# 续期后 caddy 以 0600 重建文件,ACL 可能失效,靠同步任务保障)
setup_caddy_acls() {
local data_dir="${1:-$(find_caddy_data_dir 2>/dev/null || true)}"
[[ -n "${data_dir}" ]] || return 1
local certs_dir="${data_dir}/certificates"
[[ -d "${certs_dir}" ]] || return 1
command -v setfacl &>/dev/null || return 1
# 各级父目录需要 x(遍历)权限
local p="${data_dir}"
while [[ "${p}" != "/" ]]; do
setfacl -m "u:${SERVICE_USER}:x" "${p}" 2>/dev/null
p="$(dirname "${p}")"
done
setfacl -R -m "u:${SERVICE_USER}:rX" "${certs_dir}" 2>/dev/null
setfacl -R -m "d:u:${SERVICE_USER}:rX" "${certs_dir}" 2>/dev/null
return 0
}
# 安装证书同步脚本 + systemd path/timer 单元,并立即同步一次
install_caddy_sync() {
local data_dir="$1"
local sync_script="/usr/local/sbin/mailgo-caddy-cert-sync.sh"
local sync_dir="${DATA_DIR}/tls/caddy"
local certs_dir="${data_dir}/certificates"
# 同步脚本(把数据目录固化进去)
cat > "${sync_script}" <<EOF
#!/usr/bin/env bash
# MailGo - 将 Caddy 证书存储镜像到 mail_go 可读目录(root 运行,
# 由 mailgo-caddy-sync.{path,timer} 触发),供后台一键导入使用。
set -euo pipefail
SRC="${certs_dir}"
SYNC="${sync_dir}"
[[ -d "\${SRC}" ]] || exit 0
mkdir -p "\${SYNC}"
rm -rf "\${SYNC}/.certs.tmp"
cp -a "\${SRC}" "\${SYNC}/.certs.tmp"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "\${SYNC}/.certs.tmp"
chmod -R u+rwX,go-rwx "\${SYNC}/.certs.tmp"
rm -rf "\${SYNC}/certificates"
mv "\${SYNC}/.certs.tmp" "\${SYNC}/certificates"
EOF
chmod 700 "${sync_script}"
cat > /etc/systemd/system/mailgo-caddy-sync.service <<EOF
[Unit]
Description=MailGo - 同步 Caddy 证书到 mail_go TLS 目录
After=network.target
[Service]
Type=oneshot
ExecStart=${sync_script}
EOF
cat > /etc/systemd/system/mailgo-caddy-sync.path <<EOF
[Unit]
Description=MailGo - 监视 Caddy 证书目录变化并触发同步
[Path]
PathChanged=${certs_dir}
PathChanged=${certs_dir}/*/*
Unit=mailgo-caddy-sync.service
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/mailgo-caddy-sync.timer <<EOF
[Unit]
Description=MailGo - 定期同步 Caddy 证书(开机 + 每日兜底)
[Timer]
OnBootSec=1min
OnUnitActiveSec=1d
Unit=mailgo-caddy-sync.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now mailgo-caddy-sync.path mailgo-caddy-sync.timer >/dev/null
systemctl start mailgo-caddy-sync.service
return 0
}
do_setup_caddy_cert() {
check_root
local data_dir="${2:-}"
if [[ -z "${data_dir}" ]]; then
data_dir="$(find_caddy_data_dir || true)"
if [[ -z "${data_dir}" ]]; then
error "未检测到 Caddy 数据目录(/var/lib/caddy 等),请手动指定: sudo $0 setup-caddy-cert <caddy数据目录>"
fi
info "检测到 Caddy 数据目录: ${data_dir}"
elif [[ ! -d "${data_dir}/certificates" ]]; then
error "目录 ${data_dir} 下未找到 certificates/ 子目录,请确认传入的是 Caddy 数据目录"
fi
info "安装证书同步任务(systemd path + timer,续期后自动同步)..."
install_caddy_sync "${data_dir}"
ok "证书同步任务已安装并完成首次同步"
if command -v setfacl &>/dev/null && setup_caddy_acls "${data_dir}"; then
ok "已授予 ${SERVICE_USER} 用户直接读取 Caddy 证书的 ACL 权限(兜底)"
else
warn "未配置 ACL 兜底(不影响使用,同步镜像始终可读)"
fi
ok "现在可在管理后台“编辑域名”页点击“从 Caddy 获取证书”一键导入"
}
# ======================== 卸载 ========================
do_uninstall() {
info "========== 卸载 ${SERVICE_NAME} =========="
@@ -358,8 +519,9 @@ case "${1:-}" in
stop) do_stop ;;
restart) do_restart ;;
status) do_status ;;
setup-caddy-cert) do_setup_caddy_cert ;;
*)
echo "用法: sudo $0 {install|uninstall|start|stop|restart|status}"
echo "用法: sudo $0 {install|uninstall|start|stop|restart|status|setup-caddy-cert}"
echo ""
echo " install — 完整安装/更新(拉代码+编译+部署+启动+开机自启)"
echo " uninstall — 卸载服务(可选保留数据)"
@@ -367,6 +529,8 @@ case "${1:-}" in
echo " stop — 停止服务"
echo " restart — 重启服务"
echo " status — 查看服务状态"
echo " setup-caddy-cert — 授予 mail_go 读取本机 Caddy 证书的 ACL 权限"
echo " (后台“从 Caddy 获取证书”按钮的前置条件)"
exit 1
;;
esac
+243
View File
@@ -0,0 +1,243 @@
// Package caddycert 从本机 Caddy 的证书存储中查找并读取某个域名
// 的 TLS 证书与私钥,供 MailGo 一键导入使用。
//
// Caddycertmagic)将 ACME 证书保存在其数据目录下的
//
// <data>/certificates/<CA 目录>/<域名>/<域名>.crt
// <data>/certificates/<CA 目录>/<域名>/<域名>.key
//
// 数据目录默认是 $HOME/.local/share/caddysystemd 服务通常是
// /var/lib/caddy/.local/share/caddy),可通过配置 caddy.data_dir 覆盖。
package caddycert
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"os/user"
"path/filepath"
"sort"
"strings"
"time"
)
// DefaultDataDirs 是未显式配置时依次探测的 Caddy 数据目录。
var DefaultDataDirs = []string{
"/var/lib/caddy/.local/share/caddy", // Debian/Ubuntu 软件包的 systemd 服务默认 HOME
"/root/.local/share/caddy", // 直接以 root 运行的 caddy
"/home/caddy/.local/share/caddy",
}
// Cert 是从 Caddy 存储中找到的一对证书与私钥(PEM 编码)。
type Cert struct {
CertPEM []byte // 证书链(含叶子证书)
KeyPEM []byte // 私钥
Source string // 来源 .crt 文件的绝对路径
}
// Fetch 在给定的 Caddy 证书数据目录中查找 domain 的证书与私钥。
//
// dataDirs 按优先级从高到低排列,每个目录都是包含 certificates/ 子目录的
// Caddy 数据目录(如 /var/lib/caddy/.local/share/caddy,或 mail_go 的同步
// 镜像目录 /srv/mail_go/tls/caddy);空字符串项被忽略。dataDirs 为空时仅
// 探测 DefaultDataDirs 及当前进程用户的数据目录。
//
// 返回的证书保证:能组成有效的密钥对、尚未过期、且证书 SAN 覆盖 domain
// (支持通配符证书,例如 *.example.com 的证书可匹配 mail.example.com)。
func Fetch(domain string, dataDirs []string) (*Cert, error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, fmt.Errorf("域名为空")
}
roots := dataRoots(dataDirs)
var (
permDenied []string
seen []string // 找到同名/相关文件但证书无效的来源
)
for _, root := range roots {
cert, found, invalid, err := searchRoot(domain, root)
if err != nil {
if os.IsPermission(err) {
permDenied = append(permDenied, root)
continue
}
continue
}
if found {
return cert, nil
}
seen = append(seen, invalid...)
}
msg := fmt.Sprintf("在 Caddy 证书存储中未找到域名 %q 的证书(请确认 Caddy 已为该域名签发证书)", domain)
if len(seen) > 0 {
msg += fmt.Sprintf(";发现相关文件但证书无效/已过期/不匹配域名: %s", strings.Join(seen, "、"))
}
if len(permDenied) > 0 {
msg += fmt.Sprintf(";另有目录因权限不足未能检查: %s,可运行 install.sh 的 setup-caddy-cert 授予 %s 用户读取权限",
strings.Join(permDenied, "、"), currentUsername())
}
return nil, fmt.Errorf("%s", msg)
}
// dataRoots 返回要探测的候选数据目录列表(去重,保留优先级顺序)。
func dataRoots(dataDirs []string) []string {
var roots []string
seen := map[string]bool{}
add := func(p string) {
p = strings.TrimRight(filepath.Clean(p), string(filepath.Separator))
if p == "" || seen[p] {
return
}
seen[p] = true
roots = append(roots, p)
}
for _, d := range dataDirs {
add(d)
}
for _, d := range DefaultDataDirs {
add(d)
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
add(filepath.Join(home, ".local", "share", "caddy"))
}
return roots
}
// searchRoot 在单个数据目录中查找 domain 的证书。
// 返回 (证书, 是否找到, 找到但无效的来源列表, 错误)。
func searchRoot(domain, root string) (*Cert, bool, []string, error) {
// 允许把 certificates/ 目录本身当作 data_dir 传入
certsDir := root
if filepath.Base(certsDir) != "certificates" {
certsDir = filepath.Join(root, "certificates")
}
info, err := os.Stat(certsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil, nil
}
return nil, false, nil, err
}
if !info.IsDir() {
return nil, false, nil, nil
}
caDirs, err := os.ReadDir(certsDir)
if err != nil {
return nil, false, nil, err
}
var invalid []string
// 1) 直接路径: certificates/<CA>/<domain>/<domain>.crt|.key
for _, ca := range caDirs {
if !ca.IsDir() {
continue
}
cert, found, bad, err := readDomainDir(filepath.Join(certsDir, ca.Name(), domain), domain)
if err != nil {
return nil, false, nil, err
}
if found {
return cert, true, nil, nil
}
invalid = append(invalid, bad...)
}
// 2) 全量扫描,处理通配符证书(如 *.example.com 目录)等情况
for _, ca := range caDirs {
if !ca.IsDir() {
continue
}
caPath := filepath.Join(certsDir, ca.Name())
domDirs, err := os.ReadDir(caPath)
if err != nil {
return nil, false, nil, err
}
for _, d := range domDirs {
if !d.IsDir() {
continue
}
cert, found, bad, err := readDomainDir(filepath.Join(caPath, d.Name()), domain)
if err != nil {
return nil, false, nil, err
}
if found {
return cert, true, nil, nil
}
invalid = append(invalid, bad...)
}
}
sort.Strings(invalid)
return nil, false, invalid, nil
}
// readDomainDir 读取 Caddy 某个域名目录下的 <name>.crt 与 <name>.key
// 校验其是否为 domain 的有效证书。bad 返回“存在但无效”的来源路径。
func readDomainDir(dirPath, domain string) (*Cert, bool, []string, error) {
name := filepath.Base(dirPath)
certPath := filepath.Join(dirPath, name+".crt")
keyPath := filepath.Join(dirPath, name+".key")
certPEM, err := os.ReadFile(certPath)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil, nil
}
return nil, false, nil, err
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil, nil
}
return nil, false, nil, err
}
// 只有与目标域名相关的目录才值得报“无效”,否则静默跳过
related := strings.TrimSuffix(name, "."+domain) == domain ||
name == domain || strings.HasPrefix(name, "*.") && strings.HasSuffix(domain, name[1:])
if !validPair(certPEM, keyPEM, domain) {
if related {
return nil, false, []string{certPath}, nil
}
return nil, false, nil, nil
}
return &Cert{CertPEM: certPEM, KeyPEM: keyPEM, Source: certPath}, true, nil, nil
}
// validPair 校验证书/私钥是否组成有效密钥对、未过期且 SAN 覆盖 domain。
func validPair(certPEM, keyPEM []byte, domain string) bool {
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return false
}
if len(pair.Certificate) == 0 {
return false
}
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil {
return false
}
if time.Now().After(leaf.NotAfter) {
return false
}
return leaf.VerifyHostname(domain) == nil
}
// currentUsername 返回当前进程的运行用户(错误提示用)。
func currentUsername() string {
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
return os.Getenv("USER")
}
+176
View File
@@ -0,0 +1,176 @@
package caddycert
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// makeCert 生成一份自签名证书(含指定 SAN),返回 PEM 编码的证书与私钥。
func makeCert(t *testing.T, dnsNames []string, notBefore, notAfter time.Time) (certPEM, keyPEM []byte) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("生成私钥失败: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: dnsNames[0]},
DNSNames: dnsNames,
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("生成证书失败: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
return certPEM, keyPEM
}
// writeSite 在 Caddy 风格目录结构中写入某个域名的证书。
func writeSite(t *testing.T, dataDir, domain string, certPEM, keyPEM []byte) {
t.Helper()
dir := filepath.Join(dataDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, domain+".crt"), certPEM, 0600); err != nil {
t.Fatalf("写入证书失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, domain+".key"), keyPEM, 0600); err != nil {
t.Fatalf("写入私钥失败: %v", err)
}
}
func TestFetchExactDomain(t *testing.T) {
dataDir := t.TempDir()
certPEM, keyPEM := makeCert(t, []string{"mail.example.com"}, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
writeSite(t, dataDir, "mail.example.com", certPEM, keyPEM)
got, err := Fetch("mail.example.com", []string{dataDir})
if err != nil {
t.Fatalf("Fetch 失败: %v", err)
}
if string(got.CertPEM) != string(certPEM) {
t.Error("返回的证书与写入的不一致")
}
if string(got.KeyPEM) != string(keyPEM) {
t.Error("返回的私钥与写入的不一致")
}
}
func TestFetchWildcardCoversSubdomain(t *testing.T) {
dataDir := t.TempDir()
certPEM, keyPEM := makeCert(t, []string{"*.example.com", "example.com"}, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
writeSite(t, dataDir, "*.example.com", certPEM, keyPEM)
got, err := Fetch("mail.example.com", []string{dataDir})
if err != nil {
t.Fatalf("通配符证书应覆盖子域名,Fetch 失败: %v", err)
}
if got.Source == "" {
t.Error("Source 不应为空")
}
}
func TestFetchSkipsExpiredCert(t *testing.T) {
dataDir := t.TempDir()
certPEM, keyPEM := makeCert(t, []string{"mail.example.com"}, time.Now().Add(-48*time.Hour), time.Now().Add(-24*time.Hour))
writeSite(t, dataDir, "mail.example.com", certPEM, keyPEM)
_, err := Fetch("mail.example.com", []string{dataDir})
if err == nil {
t.Fatal("过期证书不应被返回")
}
if !strings.Contains(err.Error(), "无效") {
t.Errorf("错误信息应说明证书无效,实际: %v", err)
}
}
func TestFetchNotExist(t *testing.T) {
dataDir := t.TempDir()
_, err := Fetch("nobody.example.com", []string{dataDir})
if err == nil {
t.Fatal("不存在的域名应返回错误")
}
if !strings.Contains(err.Error(), "未找到") {
t.Errorf("错误信息应包含“未找到”,实际: %v", err)
}
}
func TestFetchUppercaseDomainIsLowercased(t *testing.T) {
dataDir := t.TempDir()
certPEM, keyPEM := makeCert(t, []string{"mail.example.com"}, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
writeSite(t, dataDir, "mail.example.com", certPEM, keyPEM)
if _, err := Fetch("MAIL.Example.COM", []string{dataDir}); err != nil {
t.Fatalf("域名大小写应被归一化,Fetch 失败: %v", err)
}
}
func TestFetchCertificatesDirAsDataDir(t *testing.T) {
dataDir := t.TempDir()
certPEM, keyPEM := makeCert(t, []string{"mail.example.com"}, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
writeSite(t, dataDir, "mail.example.com", certPEM, keyPEM)
// 把 certificates 目录本身当作 data_dir 传入
certsDir := filepath.Join(dataDir, "certificates")
if _, err := Fetch("mail.example.com", []string{certsDir}); err != nil {
t.Fatalf("data_dir 直接指向 certificates 目录时应可用: %v", err)
}
}
func TestFetchPrefersFirstDataDir(t *testing.T) {
// 模拟“同步镜像目录优先”:两个目录都有该域名证书时,应返回第一个的
dirA := t.TempDir()
dirB := t.TempDir()
certA, keyA := makeCert(t, []string{"mail.example.com"}, time.Now().Add(-time.Hour), time.Now().Add(48*time.Hour))
certB, keyB := makeCert(t, []string{"mail.example.com"}, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
writeSite(t, dirA, "mail.example.com", certA, keyA)
writeSite(t, dirB, "mail.example.com", certB, keyB)
got, err := Fetch("mail.example.com", []string{dirA, dirB})
if err != nil {
t.Fatalf("Fetch 失败: %v", err)
}
if string(got.CertPEM) != string(certA) {
t.Error("应按优先级返回第一个目录中的证书")
}
}
func TestFetchPermissionDeniedHint(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root 用户不受文件权限限制,跳过")
}
dataDir := t.TempDir()
certPEM, keyPEM := makeCert(t, []string{"mail.example.com"}, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
writeSite(t, dataDir, "mail.example.com", certPEM, keyPEM)
if err := os.Chmod(dataDir, 0000); err != nil {
t.Fatalf("chmod 失败: %v", err)
}
defer os.Chmod(dataDir, 0700)
_, err := Fetch("mail.example.com", []string{dataDir})
if err == nil {
t.Fatal("无权限时应返回错误")
}
if !strings.Contains(err.Error(), "权限不足") {
t.Errorf("错误信息应提示权限不足,实际: %v", err)
}
}
+198
View File
@@ -0,0 +1,198 @@
// Package connhub 提供邮件协议(SMTP/IMAP/POP3)当前活动连接的注册中心,
// 供管理后台实时查看连接情况(来源 IP、用户、TLS、时长等)。
package connhub
import (
"sort"
"sync"
"time"
)
// Conn 表示一个活动中的协议连接。字段由 Hub 的锁保护。
type Conn struct {
ID uint64 // 自增序号
Protocol string // smtp | imap | pop3
IP string
Port int
User string // 认证后填充
TLS bool
Connected time.Time
LastActive time.Time
hub *Hub
// disconnect 强制断开底层连接的回调(由各协议服务器注册)。
// 关闭底层 socket 后协议服务器会正常走收尾清理(Logout/注销)。
disconnect func()
}
// Hub 管理所有活动连接(同一把锁保护注册表与连接字段)。
type Hub struct {
mu sync.Mutex
seq uint64
conns map[uint64]*Conn
}
// New 创建连接注册中心。
func New() *Hub {
return &Hub{conns: make(map[uint64]*Conn)}
}
// Register 注册一个新连接并返回其句柄;调用方在连接结束时调用 Close()。
func (h *Hub) Register(protocol, ip string, port int, tls bool) *Conn {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
h.seq++
now := time.Now()
c := &Conn{
ID: h.seq,
Protocol: protocol,
IP: ip,
Port: port,
TLS: tls,
Connected: now,
LastActive: now,
hub: h,
}
h.conns[c.ID] = c
return c
}
// SetUser 记录认证成功的用户名(邮箱)并刷新最后活跃时间。
func (c *Conn) SetUser(u string) {
if c == nil || c.hub == nil {
return
}
c.hub.mu.Lock()
c.User = u
c.LastActive = time.Now()
c.hub.mu.Unlock()
}
// SetTLS 更新连接的 TLS 状态(如 POP3 STLS 升级之后)。
func (c *Conn) SetTLS(on bool) {
if c == nil || c.hub == nil {
return
}
c.hub.mu.Lock()
c.TLS = on
c.hub.mu.Unlock()
}
// Touch 刷新最后活跃时间。
func (c *Conn) Touch() {
if c == nil || c.hub == nil {
return
}
c.hub.mu.Lock()
c.LastActive = time.Now()
c.hub.mu.Unlock()
}
// SetDisconnect 注册强制断开底层连接的回调(管理后台「断开并封禁」用)。
func (c *Conn) SetDisconnect(fn func()) {
if c == nil || c.hub == nil {
return
}
c.hub.mu.Lock()
c.disconnect = fn
c.hub.mu.Unlock()
}
// Close 从注册中心移除该连接。
func (c *Conn) Close() {
if c == nil || c.hub == nil {
return
}
c.hub.mu.Lock()
delete(c.hub.conns, c.ID)
c.hub.mu.Unlock()
}
// Get 按 ID 查找活动连接。
func (h *Hub) Get(id uint64) (*Conn, bool) {
if h == nil {
return nil, false
}
h.mu.Lock()
defer h.mu.Unlock()
c, ok := h.conns[id]
return c, ok
}
// Disconnect 强制断开指定连接(关闭底层连接并注销)。
func (h *Hub) Disconnect(id uint64) bool {
c, ok := h.Get(id)
if !ok {
return false
}
h.mu.Lock()
fn := c.disconnect
h.mu.Unlock()
if fn != nil {
fn()
}
return true
}
// DisconnectByIP 强制断开该 IP 的全部连接,返回断开的连接数。
// 用于封禁 IP 后立即踢掉其所有在线会话。
func (h *Hub) DisconnectByIP(ip string) int {
if h == nil || ip == "" {
return 0
}
h.mu.Lock()
var fns []func()
for _, c := range h.conns {
if c.IP == ip && c.disconnect != nil {
fns = append(fns, c.disconnect)
}
}
h.mu.Unlock()
for _, fn := range fns {
fn()
}
return len(fns)
}
// List 返回当前所有活动连接(拷贝),按连接时间升序。
func (h *Hub) List() []Conn {
if h == nil {
return nil
}
h.mu.Lock()
out := make([]Conn, 0, len(h.conns))
for _, c := range h.conns {
out = append(out, Conn{
ID: c.ID,
Protocol: c.Protocol,
IP: c.IP,
Port: c.Port,
User: c.User,
TLS: c.TLS,
Connected: c.Connected,
LastActive: c.LastActive,
})
}
h.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Counts 返回按协议分组的当前连接数。
func (h *Hub) Counts() map[string]int {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
counts := make(map[string]int)
for _, c := range h.conns {
counts[c.Protocol]++
}
return counts
}
+177
View File
@@ -0,0 +1,177 @@
package connhub
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestRegisterListCountClose(t *testing.T) {
h := New()
c1 := h.Register("smtp", "10.0.0.1", 25, false)
c2 := h.Register("imap", "10.0.0.2", 993, true)
c3 := h.Register("pop3", "10.0.0.3", 110, false)
if n := len(h.List()); n != 3 {
t.Fatalf("list len = %d, want 3", n)
}
counts := h.Counts()
if counts["smtp"] != 1 || counts["imap"] != 1 || counts["pop3"] != 1 {
t.Fatalf("counts = %v", counts)
}
c2.SetUser("alice@example.com")
c1.SetTLS(true)
time.Sleep(time.Millisecond)
c3.Touch()
// 用户名 / TLS 状态生效
var imapUser string
for _, c := range h.List() {
if c.Protocol == "imap" {
imapUser = c.User
}
if c.Protocol == "smtp" && !c.TLS {
t.Fatal("smtp conn should be TLS after SetTLS(true)")
}
if c.Protocol == "pop3" && !c.LastActive.After(c.Connected) {
t.Fatal("pop3 conn LastActive should be after Connected after Touch")
}
}
if imapUser != "alice@example.com" {
t.Fatalf("imap user = %q", imapUser)
}
c1.Close()
c2.Close()
if n := len(h.List()); n != 1 {
t.Fatalf("after close, len = %d, want 1", n)
}
if h.Counts()["imap"] != 0 {
t.Fatalf("imap count after close = %d, want 0", h.Counts()["imap"])
}
}
func TestNilHubSafe(t *testing.T) {
var h *Hub
if c := h.Register("smtp", "1.2.3.4", 25, false); c != nil {
t.Fatal("nil hub Register must return nil")
}
if h.List() != nil || h.Counts() != nil {
t.Fatal("nil hub List/Counts must be nil")
}
var c *Conn
c.SetUser("x") // 不应 panic
c.Touch()
c.SetTLS(true)
c.Close()
}
func TestConcurrentRegisterClose(t *testing.T) {
h := New()
const n = 50
var wg sync.WaitGroup
conns := make([]*Conn, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
conns[i] = h.Register("smtp", "10.0.0.1", 25, false)
conns[i].SetUser("u")
conns[i].Touch()
}(i)
}
wg.Wait()
if len(h.List()) != n {
t.Fatalf("len = %d, want %d", len(h.List()), n)
}
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
conns[i].Close()
}(i)
}
wg.Wait()
if len(h.List()) != 0 {
t.Fatalf("after concurrent close, len = %d, want 0", len(h.List()))
}
}
func TestListOrderedByID(t *testing.T) {
h := New()
h.Register("smtp", "1.1.1.1", 25, false)
h.Register("imap", "2.2.2.2", 143, false)
h.Register("pop3", "3.3.3.3", 110, false)
list := h.List()
for i := 1; i < len(list); i++ {
if list[i].ID <= list[i-1].ID {
t.Fatalf("list not ordered by ID: %+v", list)
}
}
}
// TestDisconnect 验证强制断开回调被调用。
func TestDisconnect(t *testing.T) {
h := New()
var closed atomic.Int32
c1 := h.Register("smtp", "10.0.0.1", 25, false)
c1.SetDisconnect(func() { closed.Add(1) })
c2 := h.Register("imap", "10.0.0.2", 993, true)
c2.SetDisconnect(func() { closed.Add(1) })
if !h.Disconnect(c1.ID) {
t.Fatal("Disconnect should report success")
}
if closed.Load() != 1 {
t.Fatalf("closed = %d, want 1", closed.Load())
}
// 已断开(未注销)仍可查到
if _, ok := h.Get(c1.ID); !ok {
t.Fatal("conn should still be registered until Close")
}
// 不存在的 ID
if h.Disconnect(99999) {
t.Fatal("Disconnect of unknown id must fail")
}
}
// TestDisconnectByIP 验证封禁时断开该 IP 全部连接。
func TestDisconnectByIP(t *testing.T) {
h := New()
var closed atomic.Int32
// 同一 IP 三个协议连接
for _, proto := range []string{"smtp", "imap", "pop3"} {
c := h.Register(proto, "203.0.113.5", 25, false)
c.SetDisconnect(func() { closed.Add(1) })
}
// 另一 IP 不受影响
other := h.Register("smtp", "203.0.113.6", 25, false)
other.SetDisconnect(func() { closed.Add(1) })
n := h.DisconnectByIP("203.0.113.5")
if n != 3 {
t.Fatalf("disconnected = %d, want 3", n)
}
if closed.Load() != 3 {
t.Fatalf("closed = %d, want 3", closed.Load())
}
}
// TestDisconnectNoCallback 验证未注册断开回调的连接安全跳过。
func TestDisconnectNoCallback(t *testing.T) {
h := New()
c := h.Register("pop3", "10.0.0.9", 110, false)
if !h.Disconnect(c.ID) {
t.Fatal("Disconnect should report success even without callback")
}
if n := h.DisconnectByIP("10.0.0.9"); n != 0 {
t.Fatalf("disconnected = %d, want 0", n)
}
}
+73 -1
View File
@@ -2,8 +2,10 @@ package db
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"mail_go/config"
@@ -31,6 +33,15 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败 %s: %w", dir, err)
}
// 多连接并发(SMTP/IMAP/POP3/Web/外发 worker/推送)下:
// - WAL 模式:读不阻塞写,消除瞬时 SQLITE_BUSY 导致写失败被吞
// - busy_timeout=5000ms:写竞争时等待而非立刻失败
// - synchronous=NORMALWAL 下安全且写入更快
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn = dsn + sep + "_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
dialector = sqlite.Open(dsn)
case "mysql":
dialector = mysql.Open(cfg.DSN)
@@ -45,10 +56,71 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
// AutoMigrate 前清理 ban_entries 的历史重复行(保留每 IP 最大 id):
// ip_address 将升级为唯一索引,重复行会使索引创建失败。
// 首次安装表不存在时忽略错误(AutoMigrate 会建新表)。
dedupeBanEntries(db)
// 再清理旧模型遗留的同名非唯一索引(见 dropLegacyBanIndex)。
dropLegacyBanIndex(db)
// Auto-migrate all models
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}); err != nil {
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}, &Mailbox{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
return db, nil
}
// dedupeBanEntries 删除 ban_entries 中同一 ip_address 的重复行(保留
// 每组 id 最大的一条),为 ip_address 唯一索引的 AutoMigrate 扫清障碍。
// 表不存在(首次安装)时静默跳过;清理失败仅告警,不阻断启动
// (索引创建失败会在 AutoMigrate 中显式报错)。
func dedupeBanEntries(db *gorm.DB) {
// SQLite 与 MySQL 均支持;MySQL 不允许 DELETE 子查询直接引用同表
// (1093),因此用派生表包一层。
sql := "DELETE FROM ban_entries WHERE id NOT IN (" +
"SELECT mid FROM (SELECT MAX(id) AS mid FROM ban_entries GROUP BY ip_address) AS t)"
if err := db.Exec(sql).Error; err != nil {
// 表不存在(首次安装)为预期情况
msg := err.Error()
if strings.Contains(msg, "no such table") || strings.Contains(msg, "doesn't exist") {
return
}
log.Printf("清理 ban_entries 重复行失败(唯一索引可能无法创建): %v", err)
}
}
// dropLegacyBanIndex 删除 ban_entries 上历史遗留的同名非唯一索引
// (旧模型 IPAddress 为普通 `index` 时由 AutoMigrate 创建)。
// IPAddress 升级为 uniqueIndex 后,MySQL 的 MigrateColumnUnique 会对
// 非唯一列无条件执行 CREATE UNIQUE INDEX(该路径不检查 HasIndex),
// 与同名旧索引冲突时报 Error 1061 Duplicate key name,导致启动即失败
// AutoMigrate 末尾按名查 HasIndex 的保护根本执行不到)。
// 仅 MySQL 存在此问题;SQLite 迁移路径不受影响。
// 表不存在(首次安装)时静默跳过;失败仅告警,不阻断启动
// (索引创建失败会在 AutoMigrate 中显式报错)。
func dropLegacyBanIndex(db *gorm.DB) {
if db.Dialector.Name() != "mysql" {
return
}
var cnt int64
err := db.Raw(
"SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ban_entries' AND index_name = 'idx_ban_entries_ip_address' AND non_unique = 1",
).Scan(&cnt).Error
if err != nil {
// 表不存在(首次安装)为预期情况
if strings.Contains(err.Error(), "doesn't exist") {
return
}
log.Printf("检查 ban_entries 遗留索引失败(唯一索引可能无法创建): %v", err)
return
}
if cnt == 0 {
return
}
if err := db.Exec("ALTER TABLE `ban_entries` DROP INDEX `idx_ban_entries_ip_address`").Error; err != nil {
log.Printf("删除 ban_entries 遗留非唯一索引失败(唯一索引可能无法创建): %v", err)
return
}
log.Printf("已删除 ban_entries 遗留非唯一索引 idx_ban_entries_ip_addressAutoMigrate 将重建为唯一索引)")
}
+84
View File
@@ -0,0 +1,84 @@
package db
// P4 #17 回归:dedupeBanEntries 在 AutoMigrate 前清理历史重复行,
// 为 ip_address 唯一索引扫清障碍;表不存在时静默跳过。
import (
"path/filepath"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// createLegacyBanTable 按旧版结构建表(ip_address 无唯一索引)。
func createLegacyBanTable(t *testing.T) *gorm.DB {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.Exec(`CREATE TABLE ban_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
reason TEXT,
fail_count INTEGER DEFAULT 0,
ban_count INTEGER DEFAULT 0,
expires_at DATETIME,
created_at DATETIME,
updated_at DATETIME
)`).Error; err != nil {
t.Fatalf("create legacy table: %v", err)
}
return gdb
}
func TestDedupeBanEntriesRemovesDuplicates(t *testing.T) {
gdb := createLegacyBanTable(t)
// 同一 IP 三条旧记录(id 1,2,3),另一 IP 一条
for _, rec := range []struct {
ip string
fc int
}{
{"1.2.3.4", 1},
{"1.2.3.4", 2},
{"1.2.3.4", 3},
{"5.6.7.8", 4},
} {
if err := gdb.Exec(
"INSERT INTO ban_entries (ip_address, fail_count) VALUES (?, ?)", rec.ip, rec.fc,
).Error; err != nil {
t.Fatalf("insert: %v", err)
}
}
dedupeBanEntries(gdb)
// 每 IP 仅剩 id 最大的一条
var rows []struct {
IP string `gorm:"column:ip_address"`
FC int `gorm:"column:fail_count"`
}
if err := gdb.Raw("SELECT ip_address, fail_count FROM ban_entries ORDER BY ip_address").Scan(&rows).Error; err != nil {
t.Fatalf("query: %v", err)
}
if len(rows) != 2 {
t.Fatalf("rows after dedupe = %d, want 2", len(rows))
}
if rows[0].IP != "1.2.3.4" || rows[0].FC != 3 {
t.Fatalf("1.2.3.4 row = %+v, want the max-id row (fail_count=3)", rows[0])
}
if rows[1].IP != "5.6.7.8" || rows[1].FC != 4 {
t.Fatalf("5.6.7.8 row = %+v, want fail_count=4", rows[1])
}
}
func TestDedupeBanEntriesMissingTableSilent(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
// 表不存在时不应 panic/报错(首次安装场景)
dedupeBanEntries(gdb)
}
+98 -4
View File
@@ -15,6 +15,9 @@ type User struct {
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
// 用户)在首次登录后必须修改密码。
MustChangePassword bool `gorm:"default:false" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -57,11 +60,16 @@ type Message struct {
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
CcAddr string `gorm:"size:2048" json:"cc_addr"`
Subject string `gorm:"size:1024" json:"subject"`
TextBody string `gorm:"type:text" json:"text_body"`
HtmlBody string `gorm:"type:text" json:"html_body"`
TextBody string `gorm:"type:mediumtext" json:"text_body"`
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
RawData string `gorm:"type:mediumtext" json:"raw_data"`
IsRead bool `gorm:"default:false" json:"is_read"`
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
// IsDeleted 持久化 IMAP \Deleted 标记(STORE +FLAGS \Deleted 写入,
// EXPUNGE/UID EXPUNGE 时按此删除)。此前该标记只存于内存会话
// imapMailbox.deleted map),重选文件夹/重连即丢失,导致客户端
// COPY→Trash 后 EXPUNGE 删不掉原邮件(垃圾桶只有副本)。
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
Date time.Time `json:"date"`
CreatedAt time.Time `json:"created_at"`
}
@@ -108,9 +116,15 @@ func (OutboundMessage) TableName() string {
// BanEntry represents an IP address that has been banned due to excessive login failures.
type BanEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
IPAddress string `gorm:"size:45;index;not null" json:"ip_address"`
// IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用),
// 防止并发写入产生重复行导致计数与档位读写错位。
IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"`
Reason string `gorm:"size:255" json:"reason"`
FailCount int `gorm:"default:0" json:"fail_count"`
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
// 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。
// 成功登录或管理员解封会删除记录,次数随之清零。
BanCount int `gorm:"default:0" json:"ban_count"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -119,11 +133,40 @@ type BanEntry struct {
// TableName specifies the table name for BanEntry.
func (BanEntry) TableName() string { return "ban_entries" }
// Protocol log statuses.
const (
ProtocolSMTP = "smtp"
ProtocolIMAP = "imap"
ProtocolPOP3 = "pop3"
)
// ProtocolLog records one SMTP/IMAP/POP3 connection session: auth result,
// failure reason and source IP, for admin analysis of attacks/abuse.
type ProtocolLog struct {
ID uint `gorm:"primaryKey" json:"id"`
Protocol string `gorm:"size:16;index;not null" json:"protocol"` // smtp | imap | pop3
Port int `json:"port"` // 25/465/587/143/993/110/995
ClientIP string `gorm:"size:64;index;not null" json:"client_ip"`
Username string `gorm:"size:255;index" json:"username"`
Success bool `gorm:"index" json:"success"`
FailReason string `gorm:"size:512" json:"fail_reason"`
Detail string `gorm:"size:2048" json:"detail"`
MsgCount int `json:"msg_count"`
DurationMs int64 `json:"duration_ms"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// TableName specifies the table name for ProtocolLog.
func (ProtocolLog) TableName() string {
return "protocol_logs"
}
// Attachment represents a file attached to an email message.
// 注意:不声明 Message 关联(避免 GORM 外键名 MessageID 与
// Message.MessageID 字符串字段冲突,导致 AutoMigrate 生成错误外键)。
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
MessageID uint `gorm:"index;not null" json:"message_id"`
Message Message `gorm:"foreignKey:MessageID" json:"message"`
FileName string `gorm:"size:255;not null" json:"file_name"`
FilePath string `gorm:"size:512;not null" json:"file_path"`
ContentType string `gorm:"size:128" json:"content_type"`
@@ -135,3 +178,54 @@ type Attachment struct {
func (Attachment) TableName() string {
return "attachments"
}
// SystemMailbox 定义一个系统预置文件夹(RFC 6154 SPECIAL-USE 角色)。
type SystemMailbox struct {
Name string // 规范名(INBOX 大小写不敏感)
SpecialUse string // Sent / Drafts / TrashINBOX 为 ""
}
// SystemMailboxes 是系统预置文件夹清单:IMAP LIST 与 Web 侧边栏
// 共用此定义,保证「IMAP 返回什么,Web 就显示什么」。
var SystemMailboxes = []SystemMailbox{
{Name: "INBOX", SpecialUse: ""},
{Name: "Sent", SpecialUse: "Sent"},
{Name: "Drafts", SpecialUse: "Drafts"},
{Name: "Trash", SpecialUse: "Trash"},
}
// Mailbox 表示一个用户文件夹(IMAP mailbox / Web 侧边栏条目)。
// 系统文件夹在首次访问时由 MailboxStore.EnsureSystem 幂等创建;
// 自定义文件夹经 IMAP CREATE 创建。
type Mailbox struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_mailbox_user_name;not null" json:"user_id"`
Name string `gorm:"size:64;uniqueIndex:idx_mailbox_user_name;not null" json:"name"`
SpecialUse string `gorm:"size:16" json:"special_use"` // 自定义文件夹为空
IsSubscribed bool `gorm:"default:true" json:"is_subscribed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for Mailbox.
func (Mailbox) TableName() string {
return "mailboxes"
}
// MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。
// UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间
// 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存
// 并全量重新同步。此前硬编码为 1,数据库重建后客户端缓存永不失效,
// 导致只显示/下载少量"缺失"邮件。
type MailboxState struct {
UserID uint `gorm:"primaryKey" json:"user_id"`
Folder string `gorm:"primaryKey;size:64" json:"folder"`
UidValidity uint32 `gorm:"not null" json:"uid_validity"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for MailboxState.
func (MailboxState) TableName() string {
return "mailbox_states"
}
-825
View File
@@ -1,825 +0,0 @@
package imap_server
import (
"bufio"
"bytes"
"fmt"
"io"
"log"
"net/mail"
"strings"
"time"
"mail_go/internal/db"
"mail_go/internal/mailutil"
"mail_go/internal/store"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend"
"github.com/emersion/go-imap/backend/backendutil"
asgomail "github.com/emersion/go-message/mail"
"github.com/emersion/go-message/textproto"
)
// ---------- imapBackend ----------
// imapBackend implements backend.Backend.
type imapBackend struct {
stores *store.Stores
}
// Login authenticates a user by email and password.
func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) {
user, err := b.stores.Users.Authenticate(username, password)
if err != nil {
return nil, fmt.Errorf("invalid credentials: %w", err)
}
email := user.Username + "@"
domain, err := b.stores.Domains.GetByID(user.DomainID)
if err == nil {
email = user.Username + "@" + domain.Name
}
return &imapUser{
stores: b.stores,
id: user.ID,
email: email,
}, nil
}
// ---------- imapUser ----------
// imapUser implements backend.User.
type imapUser struct {
stores *store.Stores
id uint
email string
}
// Username returns the user's email address.
func (u *imapUser) Username() string {
return u.email
}
// ListMailboxes returns the standard mailbox list.
func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) {
folders := []struct {
name string
delimiter string
attributes []string
}{
{"INBOX", "/", nil},
{"Sent", "/", []string{"\\Sent"}},
{"Drafts", "/", []string{"\\Drafts"}},
{"Trash", "/", []string{"\\Trash"}},
}
mailboxes := make([]backend.Mailbox, 0, len(folders))
for _, f := range folders {
mailboxes = append(mailboxes, &imapMailbox{
stores: u.stores,
user: u,
name: f.name,
delimiter: f.delimiter,
attributes: f.attributes,
})
}
return mailboxes, nil
}
// GetMailbox returns a mailbox by name.
func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) {
normalized, ok := canonicalMailboxName(name)
if !ok {
return nil, backend.ErrNoSuchMailbox
}
return &imapMailbox{
stores: u.stores,
user: u,
name: normalized,
delimiter: "/",
}, nil
}
func canonicalMailboxName(name string) (string, bool) {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "INBOX":
return "INBOX", true
case "SENT":
return "Sent", true
case "DRAFTS":
return "Drafts", true
case "TRASH":
return "Trash", true
default:
return "", false
}
}
// CreateMailbox creates a new mailbox (not supported in this version).
func (u *imapUser) CreateMailbox(name string) error {
return fmt.Errorf("mailbox creation not supported")
}
// DeleteMailbox deletes a mailbox (not supported in this version).
func (u *imapUser) DeleteMailbox(name string) error {
return fmt.Errorf("mailbox deletion not supported")
}
// RenameMailbox renames a mailbox (not supported in this version).
func (u *imapUser) RenameMailbox(existingName, newName string) error {
return fmt.Errorf("mailbox rename not supported")
}
// Logout is called when the user session ends.
func (u *imapUser) Logout() error {
return nil
}
// ---------- imapMailbox ----------
// imapMailbox implements backend.Mailbox.
type imapMailbox struct {
stores *store.Stores
user *imapUser
name string
delimiter string
attributes []string
// deleted tracks messages marked as \Deleted in this session
deleted map[uint]bool
}
// Name returns the mailbox name.
func (m *imapMailbox) Name() string {
return m.name
}
// Info returns mailbox metadata.
func (m *imapMailbox) Info() (*imap.MailboxInfo, error) {
attrs := m.attributes
if attrs == nil {
attrs = []string{}
}
return &imap.MailboxInfo{
Name: m.name,
Delimiter: m.delimiter,
Attributes: attrs,
}, nil
}
// Status returns mailbox status information.
func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
status := imap.NewMailboxStatus(m.name, items)
status.Flags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"}
status.PermanentFlags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"}
messages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
if err != nil {
return nil, err
}
status.Messages = uint32(len(messages))
var unseenCount uint32
for _, msg := range messages {
if !msg.IsRead {
unseenCount++
}
}
status.Unseen = unseenCount
status.Recent = 0
maxID, err := m.stores.Mails.MaxIDByUserAndFolder(m.user.id, m.name)
if err != nil {
return nil, err
}
status.UidNext = uint32(maxID + 1)
status.UidValidity = 1
return status, nil
}
// SetSubscribed sets the subscribed status (no-op for now).
func (m *imapMailbox) SetSubscribed(subscribed bool) error {
return nil
}
// Check is a no-op checkpoint.
func (m *imapMailbox) Check() error {
return nil
}
// ListMessages returns messages matching the sequence set and fetch items.
func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
defer close(ch)
// Fetch all messages in this mailbox
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
if err != nil {
return err
}
if len(dbMessages) == 0 {
return nil
}
// Build a mapping of sequence number (1-based) to db.Message
type seqEntry struct {
seqNum uint32
msg *db.Message
}
entries := make([]seqEntry, len(dbMessages))
for i := range dbMessages {
entries[i] = seqEntry{
seqNum: uint32(i + 1),
msg: &dbMessages[i],
}
}
for _, entry := range entries {
var match bool
if uid {
match = seqset.Contains(uint32(entry.msg.ID))
} else {
match = seqset.Contains(entry.seqNum)
}
if !match {
continue
}
imapMsg, err := m.buildIMAPMessage(entry.msg, entry.seqNum, items)
if err != nil {
log.Printf("IMAP: error building message %d: %v", entry.msg.ID, err)
continue
}
ch <- imapMsg
}
return nil
}
// buildIMAPMessage constructs an imap.Message from a db.Message with the requested items.
func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items []imap.FetchItem) (*imap.Message, error) {
imapMsg := imap.NewMessage(seqNum, items)
imapMsg.Uid = uint32(dbMsg.ID)
imapMsg.Flags = m.getMessageFlags(dbMsg)
imapMsg.InternalDate = dbMsg.Date
rawMsg := messageRawData(dbMsg)
imapMsg.Size = uint32(len(rawMsg))
for _, item := range items {
switch item {
case imap.FetchUid, imap.FetchFlags, imap.FetchInternalDate, imap.FetchRFC822Size:
continue
case imap.FetchEnvelope:
hdr, _, err := headerAndBody(rawMsg)
if err == nil {
imapMsg.Envelope, _ = backendutil.FetchEnvelope(hdr)
}
if imapMsg.Envelope == nil {
imapMsg.Envelope = m.buildEnvelope(dbMsg)
}
case imap.FetchBody, imap.FetchBodyStructure:
hdr, body, err := headerAndBody(rawMsg)
if err == nil {
imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure)
}
default:
section, err := imap.ParseBodySectionName(item)
if err != nil {
continue
}
hdr, body, err := headerAndBody(rawMsg)
if err != nil {
return nil, err
}
literal, _ := backendutil.FetchBodySection(hdr, body, section)
imapMsg.Body[section] = literal
}
}
return imapMsg, nil
}
func messageRawData(msg *db.Message) []byte {
if msg.RawData != "" {
return []byte(msg.RawData)
}
return buildRawMessage(msg)
}
func headerAndBody(raw []byte) (textproto.Header, io.Reader, error) {
body := bufio.NewReader(bytes.NewReader(raw))
hdr, err := textproto.ReadHeader(body)
return hdr, body, err
}
// getMessageFlags returns IMAP flags for a database message.
func (m *imapMailbox) getMessageFlags(dbMsg *db.Message) []string {
flags := make([]string, 0)
if dbMsg.IsRead {
flags = append(flags, "\\Seen")
}
if dbMsg.IsFlagged {
flags = append(flags, "\\Flagged")
}
if m.deleted != nil && m.deleted[dbMsg.ID] {
flags = append(flags, "\\Deleted")
}
return flags
}
// buildEnvelope constructs an imap.Envelope from a db.Message.
func (m *imapMailbox) buildEnvelope(dbMsg *db.Message) *imap.Envelope {
env := &imap.Envelope{
Date: dbMsg.Date,
Subject: dbMsg.Subject,
From: parseAddressList(dbMsg.FromAddr),
Sender: parseAddressList(dbMsg.FromAddr),
ReplyTo: parseAddressList(dbMsg.FromAddr),
To: parseAddressList(dbMsg.ToAddr),
Cc: parseAddressList(dbMsg.CcAddr),
MessageId: dbMsg.MessageID,
}
return env
}
// SearchMessages returns sequence numbers or UIDs of messages matching the criteria.
func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
if err != nil {
return nil, err
}
var results []uint32
for i, dbMsg := range dbMessages {
if m.matchesCriteria(&dbMsg, criteria) {
if uid {
results = append(results, uint32(dbMsg.ID))
} else {
results = append(results, uint32(i+1))
}
}
}
return results, nil
}
// matchesCriteria checks if a message matches the given search criteria.
func (m *imapMailbox) matchesCriteria(msg *db.Message, criteria *imap.SearchCriteria) bool {
// Check WithFlags (messages must have all these flags)
for _, flag := range criteria.WithFlags {
switch flag {
case "\\Seen":
if !msg.IsRead {
return false
}
case "\\Flagged":
if !msg.IsFlagged {
return false
}
case "\\Deleted":
if m.deleted == nil || !m.deleted[msg.ID] {
return false
}
}
}
// Check WithoutFlags (messages must NOT have any of these flags)
for _, flag := range criteria.WithoutFlags {
switch flag {
case "\\Seen":
if msg.IsRead {
return false
}
case "\\Flagged":
if msg.IsFlagged {
return false
}
case "\\Deleted":
if m.deleted != nil && m.deleted[msg.ID] {
return false
}
}
}
// Check date range
if !criteria.Since.IsZero() && msg.Date.Before(criteria.Since) {
return false
}
if !criteria.Before.IsZero() && !msg.Date.Before(criteria.Before) {
return false
}
// Check header fields
if criteria.Header != nil {
if subject := criteria.Header.Get("Subject"); subject != "" {
if !strings.Contains(strings.ToLower(msg.Subject), strings.ToLower(subject)) {
return false
}
}
if from := criteria.Header.Get("From"); from != "" {
if !strings.Contains(strings.ToLower(msg.FromAddr), strings.ToLower(from)) {
return false
}
}
if to := criteria.Header.Get("To"); to != "" {
if !strings.Contains(strings.ToLower(msg.ToAddr), strings.ToLower(to)) {
return false
}
}
}
// Check body text
for _, text := range criteria.Body {
bodyText := strings.ToLower(msg.TextBody + " " + msg.HtmlBody)
if !strings.Contains(bodyText, strings.ToLower(text)) {
return false
}
}
// Check generic text (searches headers + body)
for _, text := range criteria.Text {
allText := strings.ToLower(msg.Subject + " " + msg.FromAddr + " " + msg.ToAddr + " " + msg.TextBody + " " + msg.HtmlBody)
if !strings.Contains(allText, strings.ToLower(text)) {
return false
}
}
// Check NOT criteria
for _, notCrit := range criteria.Not {
if m.matchesCriteria(msg, notCrit) {
return false
}
}
// Check OR criteria (at least one must match)
for _, orPair := range criteria.Or {
if !m.matchesCriteria(msg, orPair[0]) && !m.matchesCriteria(msg, orPair[1]) {
return false
}
}
return true
}
// CreateMessage appends a new message to the mailbox (IMAP APPEND command).
func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error {
// Read the message literal
data, err := io.ReadAll(body)
if err != nil {
return fmt.Errorf("failed to read message body: %w", err)
}
// Parse as MIME message
mr, err := asgomail.CreateReader(bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to parse MIME message: %w", err)
}
header := mr.Header
fromAddr := mailutil.FormatAddressList(&header, "From")
toAddr := mailutil.FormatAddressList(&header, "To")
ccAddr := mailutil.FormatAddressList(&header, "Cc")
subject, _ := header.Subject()
messageID, _ := header.MessageID()
msgDate, _ := header.Date()
if msgDate.IsZero() {
msgDate = date
}
if msgDate.IsZero() {
msgDate = time.Now()
}
var textBody, htmlBody string
for {
p, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
switch h := p.Header.(type) {
case *asgomail.InlineHeader:
contentType, params, _ := h.ContentType()
buf, _ := io.ReadAll(p.Body)
// 检测并转换字符集
charset := ""
if cs, ok := params["charset"]; ok {
charset = cs
}
decoded := mailutil.DecodeCharset(buf, charset)
if strings.HasPrefix(contentType, "text/plain") {
textBody = decoded
} else if strings.HasPrefix(contentType, "text/html") {
htmlBody = decoded
}
case *asgomail.AttachmentHeader:
// Attachments from APPEND are not saved in this simple implementation
}
}
if textBody == "" && htmlBody == "" {
textBody = string(data)
}
// Determine initial flag state
isRead := false
isFlagged := false
for _, flag := range flags {
switch flag {
case "\\Seen":
isRead = true
case "\\Flagged":
isFlagged = true
}
}
msg := &db.Message{
UserID: m.user.id,
MessageID: messageID,
Folder: m.name,
FromAddr: fromAddr,
ToAddr: toAddr,
CcAddr: ccAddr,
Subject: subject,
TextBody: textBody,
HtmlBody: htmlBody,
RawData: string(data),
IsRead: isRead,
IsFlagged: isFlagged,
Date: msgDate,
}
if err := m.stores.Mails.Create(msg); err != nil {
return fmt.Errorf("failed to create message: %w", err)
}
return nil
}
// UpdateMessagesFlags modifies flags on messages.
func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) error {
if m.deleted == nil {
m.deleted = make(map[uint]bool)
}
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
if err != nil {
return err
}
for i, dbMsg := range dbMessages {
var match bool
if uid {
match = seqset.Contains(uint32(dbMsg.ID))
} else {
match = seqset.Contains(uint32(i + 1))
}
if !match {
continue
}
flagSet := make(map[string]bool, len(flags))
for _, flag := range flags {
flagSet[flag] = true
}
applyFlag := func(flag string, enabled bool) {
switch flag {
case "\\Seen":
_ = m.stores.Mails.MarkReadState(dbMsg.ID, enabled)
case "\\Flagged":
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, enabled)
case "\\Deleted":
if enabled {
m.deleted[dbMsg.ID] = true
} else {
delete(m.deleted, dbMsg.ID)
}
}
}
switch op {
case imap.SetFlags:
applyFlag("\\Seen", flagSet["\\Seen"])
applyFlag("\\Flagged", flagSet["\\Flagged"])
applyFlag("\\Deleted", flagSet["\\Deleted"])
case imap.AddFlags:
for flag := range flagSet {
applyFlag(flag, true)
}
case imap.RemoveFlags:
for flag := range flagSet {
applyFlag(flag, false)
}
}
}
return nil
}
// CopyMessages copies messages to another mailbox.
func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error {
dest, ok := canonicalMailboxName(dest)
if !ok {
return backend.ErrNoSuchMailbox
}
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
if err != nil {
return err
}
for i, dbMsg := range dbMessages {
var match bool
if uid {
match = seqset.Contains(uint32(dbMsg.ID))
} else {
match = seqset.Contains(uint32(i + 1))
}
if !match {
continue
}
// Create a copy in the destination mailbox
copyMsg := &db.Message{
UserID: m.user.id,
MessageID: dbMsg.MessageID,
Folder: dest,
FromAddr: dbMsg.FromAddr,
ToAddr: dbMsg.ToAddr,
CcAddr: dbMsg.CcAddr,
Subject: dbMsg.Subject,
TextBody: dbMsg.TextBody,
HtmlBody: dbMsg.HtmlBody,
RawData: dbMsg.RawData,
IsRead: dbMsg.IsRead,
IsFlagged: dbMsg.IsFlagged,
Date: dbMsg.Date,
}
if err := m.stores.Mails.Create(copyMsg); err != nil {
log.Printf("IMAP: failed to copy message %d to %s: %v", dbMsg.ID, dest, err)
}
}
return nil
}
// MoveMessages moves messages to another mailbox.
func (m *imapMailbox) MoveMessages(uid bool, seqset *imap.SeqSet, dest string) error {
dest, ok := canonicalMailboxName(dest)
if !ok {
return backend.ErrNoSuchMailbox
}
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
if err != nil {
return err
}
for i, dbMsg := range dbMessages {
var match bool
if uid {
match = seqset.Contains(uint32(dbMsg.ID))
} else {
match = seqset.Contains(uint32(i + 1))
}
if !match {
continue
}
if err := m.stores.Mails.MoveToFolder(dbMsg.ID, dest); err != nil {
log.Printf("IMAP: failed to move message %d to %s: %v", dbMsg.ID, dest, err)
}
}
return nil
}
// Expunge permanently removes messages marked as \Deleted.
func (m *imapMailbox) Expunge() error {
if m.deleted == nil {
return nil
}
for msgID := range m.deleted {
if err := m.stores.Mails.Delete(msgID); err != nil {
log.Printf("IMAP: failed to expunge message %d: %v", msgID, err)
}
}
m.deleted = make(map[uint]bool)
return nil
}
// ---------- Helper functions ----------
// parseAddressList parses a comma-separated address string into imap.Address slice.
func parseAddressList(addrStr string) []*imap.Address {
if addrStr == "" {
return nil
}
addresses, err := mail.ParseAddressList(addrStr)
if err != nil {
// Fallback: treat the whole string as a single address
return []*imap.Address{{
MailboxName: addrStr,
HostName: "",
}}
}
result := make([]*imap.Address, 0, len(addresses))
for _, addr := range addresses {
parts := strings.SplitN(addr.Address, "@", 2)
mailbox := parts[0]
host := ""
if len(parts) > 1 {
host = parts[1]
}
result = append(result, &imap.Address{
PersonalName: addr.Name,
MailboxName: mailbox,
HostName: host,
})
}
return result
}
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
// If RawData is available, it uses the original raw data directly.
func buildRawMessage(msg *db.Message) []byte {
// 优先使用原始邮件数据
if msg.RawData != "" {
return []byte(msg.RawData)
}
// 降级:从字段重建
var buf bytes.Buffer
// Write headers
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
if msg.CcAddr != "" {
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
}
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z)))
if msg.MessageID != "" {
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
}
buf.WriteString("MIME-Version: 1.0\r\n")
// Write body
if msg.HtmlBody != "" && msg.TextBody != "" {
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
buf.WriteString("\r\n")
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
buf.WriteString(msg.TextBody)
buf.WriteString("\r\n")
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
buf.WriteString(msg.HtmlBody)
buf.WriteString("\r\n")
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
} else if msg.TextBody != "" {
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
buf.WriteString(msg.TextBody)
} else if msg.HtmlBody != "" {
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
buf.WriteString(msg.HtmlBody)
} else {
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
}
return buf.Bytes()
}
// buildRawHeader reconstructs just the header portion of a raw RFC822 message.
func buildRawHeader(msg *db.Message) []byte {
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
if msg.CcAddr != "" {
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
}
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z)))
if msg.MessageID != "" {
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
}
buf.WriteString("MIME-Version: 1.0\r\n")
if msg.HtmlBody != "" && msg.TextBody != "" {
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
} else if msg.TextBody != "" {
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
} else if msg.HtmlBody != "" {
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
}
return buf.Bytes()
}
+501
View File
@@ -0,0 +1,501 @@
// 集成测试:启动真实 IMAP 监听 + 脚本客户端(go-imap v2 imapclient)。
// v2 库为 race-clean(此前 v1.2.1 库内数据竞争导致本文件必须带
// !race 构建标签,升级后已移除)。推送逻辑的单元覆盖由 notify_test.go 承担。
package imap_server
import (
"net"
"path/filepath"
"testing"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// startIntegrationServer 启动一个真实的 IMAP 监听(随机端口)供客户端测试。
func startIntegrationServer(t *testing.T) (*store.Stores, string) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}, &db.MailboxState{}, &db.Mailbox{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
domain := &db.Domain{Name: "example.com"}
if err := stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
user := &db.User{Username: "alice", DomainID: domain.ID, PasswordHash: string(hashed), IsActive: true}
if err := stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
imapSrv := srv.newServer(ln.Addr().String(), nil)
go imapSrv.Serve(ln)
return stores, ln.Addr().String()
}
// seedMailbox 创建 n 封按时间递增的邮件(id 与 date 顺序一致时 id ASC == date ASC)。
func seedMailbox(t *testing.T, stores *store.Stores, userID uint, n int) []uint {
t.Helper()
ids := make([]uint, 0, n)
base := time.Now().Add(-time.Duration(n) * time.Hour)
for i := 0; i < n; i++ {
msg := &db.Message{
UserID: userID,
Folder: "INBOX",
FromAddr: "x@y",
ToAddr: "alice@example.com",
Subject: "m",
Date: base.Add(time.Duration(i) * time.Hour), // 时间递增:id 越大日期越新
CreatedAt: time.Now(),
}
if err := stores.Mails.Create(msg); err != nil {
t.Fatalf("create message: %v", err)
}
ids = append(ids, msg.ID)
}
return ids
}
func loginAndSelect(t *testing.T, addr string) *imapclient.Client {
t.Helper()
c, err := imapclient.DialInsecure(addr, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { c.Logout().Wait() })
if err := c.Login("alice@example.com", "secret123").Wait(); err != nil {
t.Fatalf("login: %v", err)
}
if _, err := c.Select("INBOX", nil).Wait(); err != nil {
t.Fatalf("select: %v", err)
}
return c
}
func assertReadState(t *testing.T, stores *store.Stores, msgID uint, want bool) {
t.Helper()
msg, err := stores.Mails.GetByID(msgID)
if err != nil {
t.Fatalf("get msg: %v", err)
}
if msg.IsRead != want {
t.Fatalf("msg %d IsRead = %v, want %v", msgID, msg.IsRead, want)
}
}
// TestUidStorePersists 验证 UID STORE +FLAGS(\Seen) 持久化(RFC 标准流程)。
func TestUidStorePersists(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
c := loginAndSelect(t, addr)
cmd := c.Store(imap.UIDSetNum(imap.UID(ids[1])), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("uid store: %v", err)
}
assertReadState(t, stores, ids[1], true)
assertReadState(t, stores, ids[0], false)
assertReadState(t, stores, ids[2], false)
}
// TestSeqStoreServerIssued 验证客户端用服务器下发的序号(FETCH 结果)做
// seq 式 STORE:任何排序下都应正确持久化。
func TestSeqStoreServerIssued(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
c := loginAndSelect(t, addr)
// 拉取全部消息,找到 ids[2](最新一封)的服务器序号
msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
if err != nil {
t.Fatalf("fetch: %v", err)
}
var targetSeq uint32
for _, m := range msgs {
if m.UID == imap.UID(ids[2]) {
targetSeq = m.SeqNum
}
}
if targetSeq == 0 {
t.Fatal("target message not found in fetch")
}
cmd := c.Store(imap.SeqSetNum(targetSeq), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("store: %v", err)
}
assertReadState(t, stores, ids[2], true)
}
// TestSeqStoreClientSelfNumbered 复现风险场景:客户端不信任服务器序号,
// 按自己的视图(日期倒序,最新在前)自行编号后发 seq 式 STORE。
// 服务器规范排序必须与常见客户端视图一致(date DESC, id DESC),
// 否则会把另一封邮件标为已读、目标邮件永远未读。
func TestSeqStoreClientSelfNumbered(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3) // 3 封,日期递增,最新的是 ids[2]
c := loginAndSelect(t, addr)
// 客户端按日期倒序视图:最新一封 = seq 1
cmd := c.Store(imap.SeqSetNum(1), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("store: %v", err)
}
// 客户端意图是标记最新一封(ids[2])为已读
assertReadState(t, stores, ids[2], true)
}
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
// message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE
// 不得 panicv2 的 ExtractBodyStructure 对畸形 MIME 返回降级结构,
// WriteBodyStructure 要求 Extended 非 nil,两者都需满足)。
func TestFetchBodyMalformedMIME(t *testing.T) {
stores, addr := startIntegrationServer(t)
// 1) base64 编码的 message/rfc822 附件(转发邮件场景)
rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4="
msgWithRFC822 := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "alice@example.com",
ToAddr: "alice@example.com",
Subject: "fwd",
Date: time.Now().Add(-2 * time.Hour),
RawData: "From: alice@example.com\r\n" +
"To: alice@example.com\r\n" +
"Subject: fwd\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"==fwd==\"\r\n\r\n" +
"--==fwd==\r\n" +
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
"Content-Transfer-Encoding: 8bit\r\n\r\n" +
"正文\r\n\r\n" +
"--==fwd==\r\n" +
"Content-Type: message/rfc822\r\n" +
"Content-Transfer-Encoding: base64\r\n" +
"Content-Disposition: attachment; filename=\"original.eml\"\r\n" +
"MIME-Version: 1.0\r\n\r\n" +
rfc822Body + "\r\n" +
"--==fwd==--\r\n",
}
// 2) 截断的 multipart(缺少结束边界)
msgTruncated := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "alice@example.com",
ToAddr: "alice@example.com",
Subject: "truncated",
Date: time.Now().Add(-1 * time.Hour),
RawData: "From: alice@example.com\r\n" +
"To: alice@example.com\r\n" +
"Subject: truncated\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/alternative; boundary=\"==trunc==\"\r\n\r\n" +
"--==trunc==\r\n" +
"Content-Type: text/plain\r\n\r\n" +
"hello\r\n",
// 无结束边界
}
if err := stores.Mails.Create(msgWithRFC822); err != nil {
t.Fatalf("create msg: %v", err)
}
if err := stores.Mails.Create(msgTruncated); err != nil {
t.Fatalf("create msg: %v", err)
}
c := loginAndSelect(t, addr)
seqSet := imap.SeqSetNum(1, 2)
// BODY(非扩展)
msgs, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{}}).Collect()
if err != nil {
t.Fatalf("fetch body: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("FETCH BODY 返回 %d/2 封", len(msgs))
}
// BODYSTRUCTURE(扩展)
msgs2, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{Extended: true}}).Collect()
if err != nil {
t.Fatalf("fetch bodystructure: %v", err)
}
if len(msgs2) != 2 {
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", len(msgs2))
}
}
// TestUidExpungeDeleteFlow 核心回归:模拟 Apple Mail / iOS Mail 的删除流程
// UID COPY → Trash + UID STORE \Deleted + UID EXPUNGE)。修复前:
// \Deleted 只存内存会话、UID EXPUNGE 不被 go-imap v1 支持 → 原邮件留在
// INBOX,垃圾桶只有副本。修复后:原邮件被真正删除,Trash 留有一份副本。
func TestUidExpungeDeleteFlow(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
uid := imap.UID(ids[1])
c := loginAndSelect(t, addr)
// 1) UID COPY → Trash
if _, err := c.Copy(imap.UIDSetNum(uid), "Trash").Wait(); err != nil {
t.Fatalf("uid copy: %v", err)
}
// 2) UID STORE +FLAGS.SILENT (\Deleted)
if _, err := c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagDeleted},
}, nil).Collect(); err != nil {
t.Fatalf("uid store deleted: %v", err)
}
// 3) UID EXPUNGE
seqs, err := c.UIDExpunge(imap.UIDSetNum(uid)).Collect()
if err != nil {
t.Fatalf("uid expunge: %v", err)
}
if len(seqs) != 1 {
t.Fatalf("uid expunge seqs = %v, want 1 条", seqs)
}
// 原邮件必须被真正删除
if _, err := stores.Mails.GetByID(ids[1]); err == nil {
t.Fatal("原邮件仍在数据库,UID EXPUNGE 未生效")
}
// 其余邮件保留
assertReadState(t, stores, ids[0], false)
assertReadState(t, stores, ids[2], false)
// Trash 中有一份副本
trashCount, err := stores.Mails.CountByUserAndFolder(1, "Trash")
if err != nil || trashCount != 1 {
t.Fatalf("Trash count = %d, want 1", trashCount)
}
}
// TestDeletedPersistsAcrossSessions 验证 \Deleted 落库:标记后断开重连
// (模拟客户端切换文件夹/重连),普通 EXPUNGE 仍能删除。修复前标记只存
// 内存会话对象,重连后 EXPUNGE 什么都不删。
func TestDeletedPersistsAcrossSessions(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
uid := imap.UID(ids[1])
c := loginAndSelect(t, addr)
if _, err := c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagDeleted},
}, nil).Collect(); err != nil {
t.Fatalf("uid store deleted: %v", err)
}
if err := c.Logout().Wait(); err != nil {
t.Fatalf("logout: %v", err)
}
// 重新连接(新的会话对象,此前标记必须仍在库中)
c2, err := imapclient.DialInsecure(addr, nil)
if err != nil {
t.Fatalf("dial2: %v", err)
}
t.Cleanup(func() { c2.Logout().Wait() })
if err := c2.Login("alice@example.com", "secret123").Wait(); err != nil {
t.Fatalf("login2: %v", err)
}
if _, err := c2.Select("INBOX", nil).Wait(); err != nil {
t.Fatalf("select2: %v", err)
}
seqs, err := c2.Expunge().Collect()
if err != nil {
t.Fatalf("expunge: %v", err)
}
if len(seqs) != 1 {
t.Fatalf("expunge seqs = %v, want 1 条", seqs)
}
if _, err := stores.Mails.GetByID(ids[1]); err == nil {
t.Fatal("标记 \\Deleted 的邮件在重连后未被 EXPUNGE 删除")
}
}
// TestUidMoveFlow 验证 UID MOVE:目标文件夹出现该邮件且源文件夹删除。
func TestUidMoveFlow(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 2)
uid := imap.UID(ids[1])
c := loginAndSelect(t, addr)
if _, err := c.Move(imap.UIDSetNum(uid), "Trash").Wait(); err != nil {
t.Fatalf("uid move: %v", err)
}
if _, err := stores.Mails.GetByID(ids[1]); err != nil {
t.Fatalf("moved message missing: %v", err)
}
msg, err := stores.Mails.GetByID(ids[1])
if err != nil {
t.Fatalf("get moved msg: %v", err)
}
if msg.Folder != "Trash" {
t.Fatalf("moved msg folder = %q, want Trash", msg.Folder)
}
}
// TestListDataDriven 验证文件夹目录由 mailboxes 表驱动:LIST 返回
// 4 个系统文件夹 + CREATE 的自定义文件夹,且带正确的 SPECIAL-USE 属性;
// DELETE 非空/系统文件夹被拒绝,LSUB 按订阅过滤。
func TestListDataDriven(t *testing.T) {
stores, addr := startIntegrationServer(t)
c, err := imapclient.DialInsecure(addr, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { c.Logout().Wait() })
if err := c.Login("alice@example.com", "secret123").Wait(); err != nil {
t.Fatalf("login: %v", err)
}
// LIST:系统文件夹齐全且属性正确
datas, err := c.List("", "*", nil).Collect()
if err != nil {
t.Fatalf("list: %v", err)
}
byName := map[string]*imap.ListData{}
for _, d := range datas {
byName[d.Mailbox] = d
}
for _, want := range []string{"INBOX", "Sent", "Drafts", "Trash"} {
if _, ok := byName[want]; !ok {
t.Fatalf("LIST 缺少 %s", want)
}
}
if !containsAttr(byName["Trash"].Attrs, imap.MailboxAttrTrash) {
t.Fatalf("Trash attrs = %v, want \\Trash", byName["Trash"].Attrs)
}
if !containsAttr(byName["Sent"].Attrs, imap.MailboxAttrSent) {
t.Fatalf("Sent attrs = %v, want \\Sent", byName["Sent"].Attrs)
}
// CREATE 自定义文件夹 → LIST 立即可见
if err := c.Create("工作", nil).Wait(); err != nil {
t.Fatalf("create: %v", err)
}
datas2, err := c.List("", "*", nil).Collect()
if err != nil {
t.Fatalf("list after create: %v", err)
}
found := false
for _, d := range datas2 {
if d.Mailbox == "工作" {
found = true
}
}
if !found {
t.Fatal("CREATE 后的自定义文件夹未出现在 LIST 中")
}
// 重名创建 → NO
if err := c.Create("工作", nil).Wait(); err == nil {
t.Fatal("重复 CREATE 应返回 NO")
}
// 系统文件夹不可删除
if err := c.Delete("Trash").Wait(); err == nil {
t.Fatal("删除系统文件夹应返回 NO")
}
// 非空自定义文件夹不可删除
ids := seedMailbox(t, stores, 1, 1)
if _, err := c.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
if _, err := c.Copy(imap.UIDSetNum(imap.UID(ids[0])), "工作").Wait(); err != nil {
t.Fatalf("copy to custom: %v", err)
}
if err := c.Delete("工作").Wait(); err == nil {
t.Fatal("删除非空文件夹应返回 NO")
}
// RENAME 自定义文件夹
if err := c.Rename("工作", "归档", nil).Wait(); err != nil {
t.Fatalf("rename: %v", err)
}
datas3, err := c.List("", "*", nil).Collect()
if err != nil {
t.Fatalf("list after rename: %v", err)
}
renamed := false
for _, d := range datas3 {
if d.Mailbox == "归档" {
renamed = true
}
if d.Mailbox == "工作" {
t.Fatal("旧文件夹名仍出现在 LIST 中")
}
}
if !renamed {
t.Fatal("重命名后的文件夹未出现在 LIST 中")
}
// UNSUBSCRIBE → LSUB 不再返回
if err := c.Unsubscribe("归档").Wait(); err != nil {
t.Fatalf("unsubscribe: %v", err)
}
lsub, err := c.List("", "*", &imap.ListOptions{SelectSubscribed: true}).Collect()
if err != nil {
t.Fatalf("lsub: %v", err)
}
for _, d := range lsub {
if d.Mailbox == "归档" {
t.Fatal("退订后的文件夹不应出现在 LSUB 中")
}
}
}
// containsAttr 判断属性列表是否包含目标属性。
func containsAttr(attrs []imap.MailboxAttr, want imap.MailboxAttr) bool {
for _, a := range attrs {
if a == want {
return true
}
}
return false
}
+207
View File
@@ -0,0 +1,207 @@
package imap_server
import (
"path/filepath"
"testing"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/emersion/go-imap/v2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// fakeSession 构造一个挂接在推送中心上的裸会话(无网络连接),用于
// 验证 Pusher 的跨会话推送内容与来源排除语义。
func fakeSession() *imapSession {
return &imapSession{notify: make(chan struct{}, 1)}
}
func newTestServer(t *testing.T) (*IMAPServer, *store.Stores) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.MailboxState{}, &db.Mailbox{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
domain := &db.Domain{Name: "example.com"}
if err := stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
if err := stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
return srv, stores
}
// TestPushNewMessage 验证本地投递成功后推送给已选中会话的 EXISTS 更新。
func TestPushNewMessage(t *testing.T) {
srv, stores := newTestServer(t)
// 已有一封旧邮件;新邮件投递后 INBOX 计数应为 2
old := &db.Message{UserID: 1, Folder: "INBOX", FromAddr: "x@y", Subject: "old", Date: time.Now().Add(-time.Hour)}
if err := stores.Mails.Create(old); err != nil {
t.Fatalf("create old message: %v", err)
}
inboxMsg := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "sender@other.com",
ToAddr: "alice@example.com",
Subject: "新邮件",
Date: time.Now(),
}
if err := stores.Mails.Create(inboxMsg); err != nil {
t.Fatalf("create message: %v", err)
}
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
srv.PushNewMessage("alice@example.com", inboxMsg)
updates := sess.takeUpdates(true)
if len(updates) != 1 || updates[0].exists == nil {
t.Fatalf("updates = %+v, want 1 条 EXISTS", updates)
}
if *updates[0].exists != 2 {
t.Fatalf("EXISTS = %d, want 2", *updates[0].exists)
}
}
// TestPushNewMessageNoSession 验证无会话选中时推送为 no-op(不 panic)。
func TestPushNewMessageNoSession(t *testing.T) {
srv, _ := newTestServer(t)
srv.PushNewMessage("alice@example.com", &db.Message{ID: 1, UserID: 1, Folder: "INBOX", Date: time.Now()})
}
// TestPushNewMessageNilSafe 验证空参数/空指针安全。
func TestPushNewMessageNilSafe(t *testing.T) {
var srv *IMAPServer
srv.PushNewMessage("a@b", &db.Message{ID: 1}) // 不应 panic
srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil)
srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱
srv.PushNewMessage("a@b", nil) // 空消息
srv.PushFlagsChanged("", "", nil)
srv.PushExpunged("", "", nil)
}
// TestPushFlagsChanged 验证标志变化(已读/星标)推送内容正确。
func TestPushFlagsChanged(t *testing.T) {
srv, stores := newTestServer(t)
msg := &db.Message{UserID: 1, Folder: "INBOX", FromAddr: "x@y", Subject: "s", Date: time.Now()}
if err := stores.Mails.Create(msg); err != nil {
t.Fatalf("create message: %v", err)
}
msg.IsRead = true
msg.IsFlagged = true
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
srv.PushFlagsChanged("alice@example.com", "INBOX", msg)
updates := sess.takeUpdates(true)
if len(updates) != 1 || updates[0].fetch == nil {
t.Fatalf("updates = %+v, want 1 条 FETCH", updates)
}
f := updates[0].fetch
if f.uid != imap.UID(msg.ID) {
t.Fatalf("uid = %d, want %d", f.uid, msg.ID)
}
got := make(map[imap.Flag]bool)
for _, fl := range f.flags {
got[fl] = true
}
if !got[imap.FlagSeen] || !got[imap.FlagFlagged] {
t.Fatalf("flags = %v, want \\Seen and \\Flagged", f.flags)
}
}
// TestPushExpunged 验证删除推送:每条序号一个 EXPUNGE 更新。
func TestPushExpunged(t *testing.T) {
srv, _ := newTestServer(t)
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
srv.PushExpunged("alice@example.com", "INBOX", []uint32{2, 5})
updates := sess.takeUpdates(true)
if len(updates) != 2 {
t.Fatalf("updates = %d, want 2", len(updates))
}
if updates[0].expunge == nil || updates[1].expunge == nil {
t.Fatalf("updates = %+v, want expunge updates", updates)
}
if *updates[0].expunge != 2 || *updates[1].expunge != 5 {
t.Fatalf("seqs = %d,%d, want 2,5", *updates[0].expunge, *updates[1].expunge)
}
}
// TestHubExcludesSource 验证来源会话不会收到自己动作的回声推送:
// 会话 A 的 STORE/EXPUNGE 只分发给同邮箱的其他会话(本会话的响应已由
// 命令本身写回)。回归:v2 库自带 MailboxTracker 对 EXPUNGE/EXISTS 无法
// 排除来源,曾导致本会话在后续 Poll 时收到重复 EXPUNGE。
func TestHubExcludesSource(t *testing.T) {
srv, _ := newTestServer(t)
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
source := fakeSession()
other := fakeSession()
hub.add(source)
hub.add(other)
hub.enqueue(sessionUpdate{expunge: ptrU32(3)}, source)
if got := source.takeUpdates(true); len(got) != 0 {
t.Fatalf("source 收到 %d 条回声更新,want 0", len(got))
}
got := other.takeUpdates(true)
if len(got) != 1 || got[0].expunge == nil || *got[0].expunge != 3 {
t.Fatalf("other updates = %+v, want 1 条 expunge(3)", got)
}
}
// TestPollAllowExpunge 验证 FETCH/STORE/SEARCH 期间不下发 EXPUNGE
// allowExpunge=false 时遇到 EXPUNGE 停止),与 RFC 一致。
func TestPollAllowExpunge(t *testing.T) {
srv, _ := newTestServer(t)
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{seq: 1, uid: 1, flags: []imap.Flag{imap.FlagSeen}}}, nil)
hub.enqueue(sessionUpdate{expunge: ptrU32(2)}, nil)
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{seq: 3, uid: 3, flags: []imap.Flag{imap.FlagSeen}}}, nil)
// allowExpunge=false:只取到第一条 FETCHEXPUNGE 及其后的留在队列
updates := sess.takeUpdates(false)
if len(updates) != 1 || updates[0].fetch == nil {
t.Fatalf("updates = %+v, want 仅 1 条 FETCH", updates)
}
// allowExpunge=true:剩余全部下发
rest := sess.takeUpdates(true)
if len(rest) != 2 {
t.Fatalf("rest = %d, want 2", len(rest))
}
if rest[0].expunge == nil || *rest[0].expunge != 2 {
t.Fatalf("rest[0] = %+v, want expunge(2)", rest[0])
}
}
+201 -26
View File
@@ -4,72 +4,247 @@ import (
"crypto/tls"
"fmt"
"log"
"net"
"strconv"
"sync"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
"github.com/emersion/go-imap/backend"
imapserver "github.com/emersion/go-imap/server"
"github.com/emersion/go-imap/v2"
imapserver "github.com/emersion/go-imap/v2/imapserver"
)
// IMAPServer wraps a go-imap Server and provides mailbox access capability.
// Pusher 是 IMAP 实时推送接口:SMTP/POP3/Web 在邮件状态变化后调用,
// 更新经 mailboxHub 分发给已选中对应邮箱的其他会话(IDLE 时即时送达)。
type Pusher interface {
// PushNewMessage 推送新邮件(本地投递成功)。
PushNewMessage(userEmail string, msg *db.Message)
// PushFlagsChanged 推送已读/星标等标志变化。
PushFlagsChanged(userEmail, mailbox string, msg *db.Message)
// PushExpunged 推送邮件被删除(seqNums 为删除前序号)。
PushExpunged(userEmail, mailbox string, seqNums []uint32)
}
// IMAPServer 管理 IMAP/IMAPS 监听器、会话注册与跨会话推送。
type IMAPServer struct {
stores *store.Stores
cfg config.IMAPConfig
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
hub *connhub.Hub
// svc 邮箱服务层(文件夹目录/消息操作),IMAP 会话与 Web 共用。
svc *MailboxService
// hubs 按「用户邮箱 + 文件夹」索引的推送中心,会话 SELECT 时加入。
mu sync.Mutex
hubs map[string]*mailboxHub
// sessions 全部活跃会话(DisconnectByAddr 用)。
sessions map[*imapSession]struct{}
}
// NewIMAPServer creates a new IMAP server instance.
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores) *IMAPServer {
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
// when TLS is not configured.
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *IMAPServer {
return &IMAPServer{
stores: stores,
cfg: cfg,
banCfg: banCfg,
tlsLoader: tlsLoader,
hub: hub,
svc: NewMailboxService(stores),
hubs: make(map[string]*mailboxHub),
sessions: make(map[*imapSession]struct{}),
}
}
// MailboxService 返回邮箱服务层(Web handler 共用)。
func (s *IMAPServer) MailboxService() *MailboxService {
return s.svc
}
// hubKey 生成推送中心索引键。
func hubKey(userEmail, mailbox string) string {
return userEmail + "\x00" + mailbox
}
// hubFor 返回已存在的推送中心,不存在返回 nil。
func (s *IMAPServer) hubFor(userEmail, mailbox string) *mailboxHub {
s.mu.Lock()
defer s.mu.Unlock()
return s.hubs[hubKey(userEmail, mailbox)]
}
// hubForOrCreate 返回推送中心,不存在则创建。
func (s *IMAPServer) hubForOrCreate(userEmail, mailbox string) *mailboxHub {
s.mu.Lock()
defer s.mu.Unlock()
key := hubKey(userEmail, mailbox)
h := s.hubs[key]
if h == nil {
h = newMailboxHub()
s.hubs[key] = h
}
return h
}
func (s *IMAPServer) unregisterSession(sess *imapSession) {
s.mu.Lock()
delete(s.sessions, sess)
s.mu.Unlock()
}
// NotifyNewMessage 推送新邮件到达通知:EXISTS 计数 + 标记更新。
// 由 SMTP/Web 本地投递成功时调用;无会话选中该邮箱时为 no-op。
// 推送目标邮箱取 msg.Folder(本地投递为 INBOXWeb 移动/恢复时
// 用于通知目标文件夹,如 Trash)。
func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) {
if s == nil || userEmail == "" || msg == nil || msg.Folder == "" {
return
}
hub := s.hubFor(userEmail, msg.Folder)
if hub == nil {
return
}
if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, msg.Folder); err == nil {
hub.enqueue(sessionUpdate{exists: ptrU32(uint32(count))}, nil)
}
}
// PushFlagsChanged 推送邮件标志(已读/星标/删除标记)变化给同用户其他会话。
func (s *IMAPServer) PushFlagsChanged(userEmail, mailbox string, msg *db.Message) {
if s == nil || userEmail == "" || mailbox == "" || msg == nil {
return
}
hub := s.hubFor(userEmail, mailbox)
if hub == nil {
return
}
seq := seqOf(s.stores, msg.UserID, mailbox, msg.ID)
if seq == 0 {
return
}
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{
seq: seq,
uid: imap.UID(msg.ID),
flags: flagsOf(msg.IsRead, msg.IsFlagged, msg.IsDeleted),
}}, nil)
}
// PushExpunged 推送邮件被删除(每条序号一个 EXPUNGE 更新)。
func (s *IMAPServer) PushExpunged(userEmail, mailbox string, seqNums []uint32) {
if s == nil || userEmail == "" || mailbox == "" || len(seqNums) == 0 {
return
}
hub := s.hubFor(userEmail, mailbox)
if hub == nil {
return
}
for _, seq := range seqNums {
hub.enqueue(sessionUpdate{expunge: ptrU32(seq)}, nil)
}
}
// DisconnectByAddr 强制断开指定远端地址的连接(管理后台「断开并封禁」)。
// 发送 BYE 并关闭底层连接,触发库的收尾流程(session.Close、协议日志回填)。
func (s *IMAPServer) DisconnectByAddr(remoteAddr string) {
if s == nil || remoteAddr == "" {
return
}
s.mu.Lock()
var targets []*imapSession
for sess := range s.sessions {
if sess.remoteAddr == remoteAddr {
targets = append(targets, sess)
}
}
s.mu.Unlock()
for _, sess := range targets {
_ = sess.conn.Bye("Connection closed by administrator")
}
}
func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
if s.tlsLoader == nil {
return nil, fmt.Errorf("IMAP TLS certificate or key not configured")
}
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
if err != nil {
return nil, fmt.Errorf("failed to load IMAP TLS certificate: %w", err)
}
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
// GetCertificate 每次握手按需重载证书,证书更新后无需重启服务
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
}
// newServer creates a configured imapserver.Server with the given address.
// imapCaps 是服务器支持的能力集:UIDPLUSRFC 4315)提供 UID EXPUNGE、
// COPYUID/APPENDUID 支持;MOVE 由 SessionMove 实现;IDLE/UNSELECT 在
// IMAP4rev1 认证后由库自动广告。
var imapCaps = imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapUIDPlus: {},
imap.CapMove: {},
imap.CapLiteralPlus: {},
imap.CapChildren: {},
imap.CapSpecialUse: {},
}
// newServer 创建指定监听地址的 IMAP 服务(会话工厂捕获监听端口)。
func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server {
be := &imapBackend{stores: s.stores}
srv := imapserver.New(be)
srv.Addr = addr
srv.TLSConfig = tlsConfig
srv.AllowInsecureAuth = tlsConfig == nil
port := portOf(addr)
srv := imapserver.New(&imapserver.Options{
Caps: imapCaps,
NewSession: s.newSessionFactory(port),
TLSConfig: tlsConfig,
InsecureAuth: tlsConfig == nil,
})
return srv
}
// Start starts the IMAP server on the plain-text port.
// newSessionFactory 构造会话工厂:每个连接一个 imapSession,注册到
// 会话表(DisconnectByAddr 用)。
func (s *IMAPServer) newSessionFactory(port int) func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
sess := newImapSession(s, conn, port)
s.mu.Lock()
s.sessions[sess] = struct{}{}
s.mu.Unlock()
return sess, nil, nil
}
}
// portOf 从监听地址解析端口号,失败返回 0。
func portOf(addr string) int {
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return 0
}
return port
}
// Start starts the IMAP server on the plain-text port (STARTTLS enabled
// when a certificate is configured).
func (s *IMAPServer) Start() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
log.Printf("IMAP STARTTLS 未启用: %v", err)
tlsConfig = nil
}
srv := s.newServer(s.cfg.Addr, tlsConfig)
log.Printf("IMAP server listening on %s", s.cfg.Addr)
return srv.ListenAndServe()
return srv.ListenAndServe(s.cfg.Addr)
}
// StartTLS starts the IMAP server on the TLS port.
// StartTLS starts the IMAP server on the implicit TLS port.
func (s *IMAPServer) StartTLS() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
return err
}
srv := s.newServer(s.cfg.TLSAddr, tlsConfig)
log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr)
return srv.ListenAndServeTLS()
return srv.ListenAndServeTLS(s.cfg.TLSAddr)
}
// ensure imapBackend satisfies backend.Backend at compile time
var _ backend.Backend = (*imapBackend)(nil)
+335
View File
@@ -0,0 +1,335 @@
package imap_server
// MailboxService 是邮箱(文件夹)与消息操作的服务层:
// IMAP 会话与 Web handler 共用同一份实现,保证「IMAP LIST 返回什么,
// Web 就显示什么」,且移动/删除等操作走同一语义(如移入 Trash)。
import (
"log"
"strings"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/emersion/go-imap/v2"
)
// FolderInfo 是一个文件夹在列表页(IMAP LIST / Web 侧边栏)
// 展示所需的信息。
type FolderInfo struct {
Name string
SpecialUse string
Subscribed bool
Total int64
Unseen int64
}
// MailboxService 提供用户级邮箱操作。
type MailboxService struct {
stores *store.Stores
}
// NewMailboxService creates a MailboxService backed by stores.
func NewMailboxService(stores *store.Stores) *MailboxService {
return &MailboxService{stores: stores}
}
// ListAll 返回用户全部文件夹(先确保系统文件夹存在)。
func (s *MailboxService) ListAll(userID uint) ([]db.Mailbox, error) {
if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil {
return nil, err
}
return s.stores.Mailboxes.List(userID)
}
// List 返回用户全部文件夹及统计信息(IMAP LIST / Web 侧边栏同源)。
func (s *MailboxService) List(userID uint) ([]FolderInfo, error) {
mbs, err := s.ListAll(userID)
if err != nil {
return nil, err
}
infos := make([]FolderInfo, 0, len(mbs))
for _, mb := range mbs {
total, err := s.stores.Mails.CountByUserAndFolder(userID, mb.Name)
if err != nil {
log.Printf("mailbox: 统计 %s 邮件数失败: %v", mb.Name, err)
}
unseen, err := s.stores.Mails.CountUnread(userID, mb.Name)
if err != nil {
log.Printf("mailbox: 统计 %s 未读数失败: %v", mb.Name, err)
}
infos = append(infos, FolderInfo{
Name: mb.Name,
SpecialUse: mb.SpecialUse,
Subscribed: mb.IsSubscribed,
Total: total,
Unseen: unseen,
})
}
return infos, nil
}
// Canonical 规范化邮箱名:INBOX 大小写不敏感,其余按 DB 实际名称
// 精确匹配。会先确保系统文件夹存在(客户端可能不 LIST 直接 SELECT)。
func (s *MailboxService) Canonical(userID uint, name string) (string, bool) {
name = strings.TrimSpace(name)
if name == "" {
return "", false
}
if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil {
log.Printf("mailbox: EnsureSystem 失败 user=%d: %v", userID, err)
return "", false
}
if strings.EqualFold(name, "INBOX") {
return "INBOX", true
}
mb, err := s.stores.Mailboxes.GetByName(userID, name)
if err != nil {
return "", false
}
return mb.Name, true
}
// Messages 分页返回用户某文件夹的邮件。
func (s *MailboxService) Messages(userID uint, name string, page, size int) ([]db.Message, int64, error) {
return s.stores.Mails.ListByUserAndFolder(userID, name, page, size)
}
// Select 计算 SELECT 响应数据(会话负责 hub 绑定与选中状态)。
func (s *MailboxService) Select(userID uint, name string) (*imap.SelectData, error) {
msgs, err := s.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil {
return nil, err
}
maxID, err := s.stores.Mails.MaxIDByUserAndFolder(userID, name)
if err != nil {
return nil, err
}
uidValidity, err := s.stores.MailboxState.UidValidity(userID, name)
if err != nil {
log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", name, err)
uidValidity = 1
}
flags := []imap.Flag{imap.FlagAnswered, imap.FlagFlagged, imap.FlagDeleted, imap.FlagSeen, imap.FlagDraft}
return &imap.SelectData{
Flags: flags,
PermanentFlags: append(flags, imap.FlagWildcard),
NumMessages: uint32(len(msgs)),
NumRecent: 0,
UIDNext: imap.UID(maxID + 1),
UIDValidity: uidValidity,
}, nil
}
// Status 计算 STATUS 响应数据。
func (s *MailboxService) Status(userID uint, name string, options *imap.StatusOptions) (*imap.StatusData, error) {
msgs, err := s.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil {
return nil, err
}
data := &imap.StatusData{Mailbox: name}
if options.NumMessages || options.NumUnseen || options.NumDeleted || options.Size {
var unseen, deleted uint32
var size int64
for i := range msgs {
if !msgs[i].IsRead {
unseen++
}
if msgs[i].IsDeleted {
deleted++
}
size += int64(len(messageRawData(&msgs[i])))
}
if options.NumMessages {
n := uint32(len(msgs))
data.NumMessages = &n
}
if options.NumUnseen {
data.NumUnseen = &unseen
}
if options.NumDeleted {
data.NumDeleted = &deleted
}
if options.Size {
data.Size = &size
}
}
if options.NumRecent {
zero := uint32(0)
data.NumRecent = &zero
}
if options.UIDNext {
maxID, err := s.stores.Mails.MaxIDByUserAndFolder(userID, name)
if err != nil {
return nil, err
}
data.UIDNext = imap.UID(maxID + 1)
}
if options.UIDValidity {
uidValidity, err := s.stores.MailboxState.UidValidity(userID, name)
if err != nil {
return nil, err
}
data.UIDValidity = uidValidity
}
return data, nil
}
// validateMailboxName 校验自定义文件夹名(IMAP CREATE/RENAME 用)。
func validateMailboxName(name string) error {
if name == "" || len(name) > 64 {
return store.ErrMailboxInvalid
}
if strings.Contains(name, "/") || name == "." || name == ".." {
return store.ErrMailboxInvalid
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return store.ErrMailboxInvalid
}
}
return nil
}
// Create 创建自定义文件夹。
func (s *MailboxService) Create(userID uint, name string) error {
name = strings.TrimSpace(name)
if err := validateMailboxName(name); err != nil {
return err
}
if strings.EqualFold(name, "INBOX") {
return store.ErrMailboxExists
}
if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil {
return err
}
if _, err := s.stores.Mailboxes.GetByName(userID, name); err == nil {
return store.ErrMailboxExists
}
return s.stores.Mailboxes.Create(&db.Mailbox{
UserID: userID,
Name: name,
IsSubscribed: true,
})
}
// Delete 删除空的自定义文件夹(系统文件夹拒绝)。
func (s *MailboxService) Delete(userID uint, name string) error {
name, ok := s.Canonical(userID, name)
if !ok {
return store.ErrMailboxNotFound
}
if isSystemMailboxName(name) {
return store.ErrMailboxSystem
}
return s.stores.Mailboxes.Delete(userID, name)
}
// Rename 重命名自定义文件夹(系统文件夹拒绝)。
func (s *MailboxService) Rename(userID uint, oldName, newName string) error {
oldName, ok := s.Canonical(userID, oldName)
if !ok {
return store.ErrMailboxNotFound
}
if isSystemMailboxName(oldName) {
return store.ErrMailboxSystem
}
newName = strings.TrimSpace(newName)
if err := validateMailboxName(newName); err != nil {
return err
}
if strings.EqualFold(newName, "INBOX") || isSystemMailboxName(newName) {
return store.ErrMailboxInvalid
}
if _, err := s.stores.Mailboxes.GetByName(userID, newName); err == nil {
return store.ErrMailboxExists
}
return s.stores.Mailboxes.Rename(userID, oldName, newName)
}
// SetSubscribed 更新文件夹订阅状态(LSUB 过滤用)。
func (s *MailboxService) SetSubscribed(userID uint, name string, subscribed bool) error {
name, ok := s.Canonical(userID, name)
if !ok {
return store.ErrMailboxNotFound
}
return s.stores.Mailboxes.SetSubscribed(userID, name, subscribed)
}
// Move 把多封邮件移动到目标文件夹(web 删除=移入 Trash、恢复等共用)。
// 不属于该用户的邮件被跳过。
func (s *MailboxService) Move(userID uint, msgIDs []uint, dest string) error {
if len(msgIDs) == 0 {
return nil
}
if _, ok := s.Canonical(userID, dest); !ok {
return store.ErrMailboxNotFound
}
for _, id := range msgIDs {
msg, err := s.stores.Mails.GetByID(id)
if err != nil || msg.UserID != userID {
continue
}
if err := s.stores.Mails.MoveToFolder(id, dest); err != nil {
return err
}
}
return nil
}
// Purge 永久删除文件夹中的邮件;msgIDs 为空时删除全部。
// 返回被删除前的邮件列表(调用方据此推送 EXPUNGE)。
func (s *MailboxService) Purge(userID uint, name string, msgIDs []uint) ([]db.Message, error) {
var msgs []db.Message
var err error
if len(msgIDs) == 0 {
msgs, err = s.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil {
return nil, err
}
msgIDs = make([]uint, 0, len(msgs))
for i := range msgs {
msgIDs = append(msgIDs, msgs[i].ID)
}
} else {
for _, id := range msgIDs {
msg, err := s.stores.Mails.GetByID(id)
if err != nil || msg.UserID != userID || msg.Folder != name {
continue
}
msgs = append(msgs, *msg)
}
}
if len(msgIDs) == 0 {
return nil, nil
}
if err := s.stores.Mails.DeleteMany(msgIDs); err != nil {
return nil, err
}
return msgs, nil
}
// isSystemMailboxName reports whether name is one of the system mailboxes.
func isSystemMailboxName(name string) bool {
for _, def := range db.SystemMailboxes {
if def.Name == name {
return true
}
}
return false
}
// mailboxAttrs 把 SpecialUse 映射为 RFC 6154 的 IMAP 属性。
func mailboxAttrs(mb db.Mailbox) []imap.MailboxAttr {
switch mb.SpecialUse {
case "Sent":
return []imap.MailboxAttr{imap.MailboxAttrSent}
case "Drafts":
return []imap.MailboxAttr{imap.MailboxAttrDrafts}
case "Trash":
return []imap.MailboxAttr{imap.MailboxAttrTrash}
default:
return nil
}
}
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -54,6 +54,7 @@ type RelayConfig struct {
Username string // AUTH PLAIN credentials (empty = no authentication)
Password string
StartTLS bool // use STARTTLS on non-465 ports
TLSInsecure bool // skip certificate verification (test-only, credentials leak risk)
}
// Mailer performs direct MX delivery (or smarthost relay) of a single message.
@@ -131,6 +132,8 @@ func (m *Mailer) Deliver(from, to string, data []byte) (string, error) {
}
// deliverViaRelay sends the message through the configured smarthost.
// The relay carries AUTH credentials, so its TLS certificate is verified
// unless RelayTLSInsecure is explicitly enabled.
func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
port := m.Relay.Port
if port == 0 {
@@ -139,7 +142,8 @@ func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
implicitTLS := port == 465
return m.smtpTransaction(m.Relay.Host, port, implicitTLS,
m.Relay.StartTLS && !implicitTLS,
m.Relay.Username, m.Relay.Password, from, to, data)
m.Relay.Username, m.Relay.Password, from, to, data,
m.Relay.TLSInsecure)
}
// smtpClient wraps a textproto connection to a remote SMTP server.
@@ -240,13 +244,17 @@ func (c *smtpClient) authPlain(username, password string) error {
}
// deliverToHost performs a full SMTP transaction with a single MX host.
// Direct MX delivery is opportunistic TLS: certificates are not verified
// because most MX certificates cannot be validated over a cold connection.
func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, error) {
return m.smtpTransaction(host, m.port(), false, false, "", "", from, to, data)
return m.smtpTransaction(host, m.port(), false, false, "", "", from, to, data, true)
}
// smtpTransaction performs one complete SMTP session: connect, greeting,
// optional implicit TLS / STARTTLS, optional AUTH PLAIN, MAIL/RCPT/DATA/QUIT.
func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bool, username, password, from, to string, data []byte) (string, error) {
// tlsInsecure 控制 TLS 握手时是否跳过证书验证:直投 MX 用 true(机会式
// TLS),relay 用配置值(默认 false,保护中继凭据)。
func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bool, username, password, from, to string, data []byte, tlsInsecure bool) (string, error) {
addr := net.JoinHostPort(host, strconv.Itoa(port))
ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout)
@@ -267,11 +275,12 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo
tlsServerName := host
if ip := net.ParseIP(host); ip != nil {
tlsServerName = "" // no SNI for IP literals
// IP literal 同样作为 ServerName:证书验证模式下校验其 IP SAN
tlsServerName = ip.String()
}
if implicitTLS {
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
if err != nil {
return "", err
}
@@ -294,7 +303,7 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo
if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
return "", err
}
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
if err != nil {
return "", err
}
@@ -354,10 +363,13 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo
}
// tlsClientHandshake upgrades a plain connection to TLS.
func tlsClientHandshake(ctx context.Context, conn net.Conn, serverName, host string) (net.Conn, error) {
// Direct MX delivery passes insecure=true (opportunistic TLS: remote MX
// certificates often cannot be verified). Relays with AUTH credentials must
// pass insecure=false so the connection cannot be MITM'd.
func tlsClientHandshake(ctx context.Context, conn net.Conn, serverName, host string, insecure bool) (net.Conn, error) {
tlsConn := tls.Client(conn, &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, // remote MX certificates often cannot be verified
InsecureSkipVerify: insecure,
})
if err := tlsConn.HandshakeContext(ctx); err != nil {
return nil, newTempError("TLS handshake with %s failed: %v", host, err)
+177
View File
@@ -2,8 +2,16 @@ package outbound
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"math/big"
"net"
"strconv"
"strings"
"testing"
"time"
@@ -340,3 +348,172 @@ func TestMailerSmarthostRelay(t *testing.T) {
t.Fatalf("relay data mismatch.\ngot: %q\nwant: %q", res.gotData, input)
}
}
// startTLSSMTPServer 起一个支持 STARTTLS 的 SMTP 服务器(自签证书),
// 供 relay TLS 验证测试使用:未升级 TLS 时广告 STARTTLS 能力,
// 收到 STARTTLS 后升级为 TLS 并重新 EHLO。
func startTLSSMTPServer(t *testing.T) (addr string, cleanup func()) {
t.Helper()
cert, err := tls.X509KeyPair(makeSelfSignedCertPEM(t))
if err != nil {
t.Fatalf("load self-signed cert: %v", err)
}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
_, _ = w.WriteString("220 relay.test ESMTP ready\r\n")
_ = w.Flush()
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
up := strings.ToUpper(strings.TrimRight(line, "\r\n"))
switch {
case strings.HasPrefix(up, "STARTTLS"):
_, _ = w.WriteString("220 2.0.0 ready to start TLS\r\n")
_ = w.Flush()
tlsConn := tls.Server(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
return
}
conn = tlsConn
r = bufio.NewReader(conn)
w = bufio.NewWriter(conn)
case strings.HasPrefix(up, "EHLO"), strings.HasPrefix(up, "HELO"):
_, _ = w.WriteString("250-relay.test\r\n250-STARTTLS\r\n250 8BITMIME\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "AUTH PLAIN"):
_, _ = w.WriteString("235 2.0.0 ok\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "DATA"):
_, _ = w.WriteString("354 go ahead\r\n")
_ = w.Flush()
for {
dl, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dl, "\r\n") == "." {
break
}
}
_, _ = w.WriteString("250 2.0.0 queued\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "QUIT"):
_, _ = w.WriteString("221 bye\r\n")
_ = w.Flush()
return
default:
_, _ = w.WriteString("250 ok\r\n")
_ = w.Flush()
}
}
}()
}
}()
addr = ln.Addr().String()
return addr, func() { ln.Close() }
}
// makeSelfSignedCertPEM 生成一对自签证书(CN=relay.test)。
func makeSelfSignedCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "relay.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"relay.test"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
return certPEM, keyPEM
}
// TestMailerRelayRejectsUntrustedCert 中继使用自签证书时默认必须拒绝
// (证书验证开启,防止凭据被中间人截获)。
func TestMailerRelayRejectsUntrustedCert(t *testing.T) {
addr, cleanup := startTLSSMTPServer(t)
defer cleanup()
m := NewMailer("mail.lmve.net", 5*time.Second)
m.Relay = &RelayConfig{
Host: "127.0.0.1",
Port: mustPort(t, addr),
Username: "relay-user",
Password: "relay-pass",
TLSInsecure: false,
}
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
_, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
if err == nil {
t.Fatal("relay with untrusted self-signed cert should be rejected")
}
if !strings.Contains(err.Error(), "certificate") {
t.Fatalf("expected certificate verification error, got: %v", err)
}
}
// TestMailerRelayInsecureSkipsVerification 显式开启 relay_tls_insecure
// 后,自签证书的中继可以完成 TLS 握手并进入 SMTP 会话。
func TestMailerRelayInsecureSkipsVerification(t *testing.T) {
addr, cleanup := startTLSSMTPServer(t)
defer cleanup()
m := NewMailer("mail.lmve.net", 5*time.Second)
m.Relay = &RelayConfig{
Host: "127.0.0.1",
Port: mustPort(t, addr),
Username: "relay-user",
Password: "relay-pass",
TLSInsecure: true,
}
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
resp, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
if err != nil {
t.Fatalf("relay with TLSInsecure should proceed: %v", err)
}
if !strings.HasPrefix(resp, "250") {
t.Fatalf("unexpected response: %q", resp)
}
}
func mustPort(t *testing.T, addr string) int {
t.Helper()
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split %q: %v", addr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("port %q: %v", portStr, err)
}
return port
}
+108 -17
View File
@@ -16,8 +16,13 @@ import (
)
// Manager orchestrates the outbound delivery queue: enqueueing messages,
// background delivery worker, exponential backoff retries, DKIM signing,
// per-user rate limits and failure bounces.
// concurrent background delivery workers, exponential backoff retries, DKIM
// signing, per-user rate limits and failure bounces.
//
// 并发模型:一个 dispatcher goroutine 周期性扫描队列并原子抢占(Claim)
// 待投递项,投递给 worker 池(默认 4 个 goroutine)并行发送;同一收件域
// (或中继)的连接数受 max_concurrent_per_domain 限制。workers=0/1 时退
// 化为串行投递(旧行为)。
type Manager struct {
cfg config.OutboundConfig
hostname string // EHLO hostname
@@ -31,7 +36,12 @@ type Manager struct {
wg sync.WaitGroup
mu sync.Mutex
lim map[uint]*userWindow
batch int
jobs chan *db.OutboundMessage
domMu sync.Mutex
dom map[string]chan struct{} // 每域并发信号量
// deliver 执行单封投递,默认走 m.mailer.Deliver;测试可注入替换。
deliver func(from, to string, data []byte) (string, error)
}
// userWindow tracks a user's sending rate within fixed windows.
@@ -45,6 +55,14 @@ type userWindow struct {
// NewManager creates an outbound delivery Manager.
// hostname is the EHLO name presented to remote servers (defaults to "localhost").
func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores) *Manager {
batchSize := cfg.BatchSize
if batchSize <= 0 {
batchSize = 50
}
workers := cfg.Workers
if workers <= 1 {
workers = 1
}
m := &Manager{
cfg: cfg,
hostname: hostname,
@@ -54,8 +72,10 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
stop: make(chan struct{}),
done: make(chan struct{}),
lim: make(map[uint]*userWindow),
batch: 50,
jobs: make(chan *db.OutboundMessage, batchSize),
dom: make(map[string]chan struct{}),
}
m.deliver = m.mailer.Deliver
m.mailer.IPFamily = cfg.IPFamily
m.mailer.SourceIP = cfg.SourceIP
if cfg.SourceIP != "" {
@@ -69,25 +89,31 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
Username: cfg.RelayUser,
Password: cfg.RelayPassword,
StartTLS: cfg.RelayStartTLS,
TLSInsecure: cfg.RelayTLSInsecure,
}
log.Printf("outbound: using smarthost relay %s:%d", cfg.RelayHost, cfg.RelayPort)
}
log.Printf("outbound: %d delivery workers, batch=%d, per-domain concurrency=%d",
workers, batchSize, cfg.MaxConcurrentPerDomain)
return m
}
// Start launches the background delivery worker.
// Start launches the dispatcher and the delivery worker pool.
func (m *Manager) Start() {
interval := time.Duration(m.cfg.PollInterval) * time.Second
if interval <= 0 {
interval = 15 * time.Second
}
// 调度者:启动时立即扫描一次(清空积压),之后按周期扫描 +
// 原子抢占待投递项,投递给 worker 池。
m.wg.Add(1)
go func() {
defer m.wg.Done()
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Printf("outbound: delivery worker started (interval=%s, max_attempts=%d)", interval, m.cfg.MaxAttempts)
log.Printf("outbound: dispatcher started (interval=%s, max_attempts=%d)", interval, m.cfg.MaxAttempts)
m.processDue()
for {
select {
case <-ticker.C:
@@ -95,14 +121,30 @@ func (m *Manager) Start() {
case <-m.kick:
m.processDue()
case <-m.stop:
close(m.jobs)
close(m.done)
return
}
}
}()
// Worker 池:并行投递。
workers := m.cfg.Workers
if workers <= 1 {
workers = 1
}
for i := 0; i < workers; i++ {
m.wg.Add(1)
go func() {
defer m.wg.Done()
for job := range m.jobs {
m.deliverOne(job)
}
}()
}
}
// Stop gracefully stops the delivery worker.
// Stop gracefully stops the dispatcher and workers.
func (m *Manager) Stop() {
m.once.Do(func() {
close(m.stop)
@@ -242,28 +284,77 @@ func (m *Manager) checkRateLimit(userID uint) error {
return nil
}
// processDue attempts delivery of all due queue items.
// processDue scans the queue and dispatches due items to the worker pool.
// Each item is atomically claimed (status -> sending) before dispatch so
// that concurrent workers never deliver the same message twice. When all
// workers are busy the dispatcher blocks here, naturally throttling claims;
// remaining due items are picked up on the next scan.
func (m *Manager) processDue() {
items, err := m.stores.Outbound.ListDue(time.Now(), m.batch)
batchSize := m.cfg.BatchSize
if batchSize <= 0 {
batchSize = 50
}
items, err := m.stores.Outbound.ListDue(time.Now(), batchSize)
if err != nil {
log.Printf("outbound: loading due queue failed: %v", err)
return
}
for i := range items {
m.deliverOne(&items[i])
claimed, err := m.stores.Outbound.Claim(items[i].ID)
if err != nil {
log.Printf("outbound: claim item %d failed: %v", items[i].ID, err)
continue
}
if !claimed {
// 已被其他调度周期抢占(并发下应不会发生,防御性跳过)
continue
}
item := items[i]
item.Status = db.OutboundStatusSending
m.jobs <- &item
}
}
// acquireDomain 获取收件域(或中继)的并发信号量,限制对同一目标域同时
// 打开的 SMTP 连接数。limit <= 0 表示不限制。
func (m *Manager) acquireDomain(domain string) func() {
limit := m.cfg.MaxConcurrentPerDomain
if limit <= 0 {
return func() {}
}
m.domMu.Lock()
sem := m.dom[domain]
if sem == nil {
sem = make(chan struct{}, limit)
m.dom[domain] = sem
}
m.domMu.Unlock()
sem <- struct{}{}
return func() { <-sem }
}
// deliverKey 返回并发限制使用的目标标识:配置了中继时所有连接都打向同一
// smarthost,统一按 "relay" 限制;否则按收件域名限制。
func (m *Manager) deliverKey(to string) string {
if m.mailer.Relay != nil && m.mailer.Relay.Host != "" {
return "relay"
}
at := strings.LastIndex(to, "@")
if at < 0 || at == len(to)-1 {
return ""
}
return strings.ToLower(to[at+1:])
}
// deliverOne performs a single delivery attempt for a queue item.
// 调用前该项已被原子抢占为 sending,此处不再重复置位。
func (m *Manager) deliverOne(item *db.OutboundMessage) {
// Mark as sending to avoid concurrent workers double-delivering.
item.Status = db.OutboundStatusSending
if err := m.stores.Outbound.Update(item); err != nil {
log.Printf("outbound: update item %d to sending failed: %v", item.ID, err)
return
}
release := m.acquireDomain(item.ToAddr)
defer release()
resp, err := m.mailer.Deliver(item.FromAddr, item.ToAddr, []byte(item.RawData))
resp, err := m.deliver(item.FromAddr, item.ToAddr, []byte(item.RawData))
now := time.Now()
item.Attempts++
+332
View File
@@ -0,0 +1,332 @@
package outbound
import (
"errors"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"mail_go/config"
"mail_go/internal/db"
"mail_go/internal/store"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// newTestManagerStores 创建带 outbound_messages 表的测试数据库。
func newTestManagerStores(t *testing.T) *store.Stores {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.OutboundMessage{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return store.NewStores(gdb)
}
// seedPending 写入 n 条立即可投递的 pending 队列项。
func seedPending(t *testing.T, stores *store.Stores, n int) []uint {
t.Helper()
ids := make([]uint, 0, n)
for i := 0; i < n; i++ {
item := &db.OutboundMessage{
MessageID: "<seed@test>",
FromAddr: "sender@test.local",
ToAddr: "rcpt@fake.test",
RecipientDom: "fake.test",
RawData: "From: sender@test.local\r\nTo: rcpt@fake.test\r\nSubject: t\r\n\r\nbody\r\n",
Status: db.OutboundStatusPending,
Attempts: 0,
NextAttemptAt: time.Now(),
}
if err := stores.Outbound.Create(item); err != nil {
t.Fatalf("create item: %v", err)
}
ids = append(ids, item.ID)
}
return ids
}
// countByStatus 统计队列中指定状态的项数。
func countByStatus(t *testing.T, stores *store.Stores, status string) int64 {
t.Helper()
n, err := stores.Outbound.CountByStatus(status)
if err != nil {
t.Fatalf("count %s: %v", status, err)
}
return n
}
// waitFor 轮询等待条件满足或超时。
func waitFor(t *testing.T, timeout time.Duration, desc string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", desc)
}
// concurrencyTracker 统计并发调用数(用于验证 worker 池与每域信号量)。
type concurrencyTracker struct {
mu sync.Mutex
active int
peak int
deliveries int
err error
}
// deliverWithDelay 返回一个带固定延迟的注入投递函数,并统计并发峰值。
func deliverWithDelay(tr *concurrencyTracker, delay time.Duration) func(string, string, []byte) (string, error) {
return func(from, to string, data []byte) (string, error) {
tr.mu.Lock()
tr.active++
if tr.active > tr.peak {
tr.peak = tr.active
}
tr.mu.Unlock()
time.Sleep(delay)
tr.mu.Lock()
tr.active--
tr.deliveries++
tr.mu.Unlock()
return "250 2.0.0 queued", nil
}
}
// TestManagerConcurrentDelivery 验证 worker 池真并发投递且每封只投一次。
func TestManagerConcurrentDelivery(t *testing.T) {
stores := newTestManagerStores(t)
seedPending(t, stores, 12)
tr := &concurrencyTracker{}
cfg := config.OutboundConfig{
PollInterval: 1,
MaxAttempts: 5,
RetryBaseMin: 1,
MaxPerDay: 10000,
Workers: 4,
BatchSize: 50,
ConnectTimeout: 10,
}
m := NewManager(cfg, "test.local", stores)
m.deliver = deliverWithDelay(tr, 150*time.Millisecond)
m.Start()
t.Cleanup(m.Stop)
waitFor(t, 15*time.Second, "all items sent", func() bool {
return countByStatus(t, stores, db.OutboundStatusSent) == 12
})
if tr.peak < 2 {
t.Fatalf("expected concurrent deliveries (peak=%d), got serial behavior", tr.peak)
}
if tr.deliveries != 12 {
t.Fatalf("deliveries = %d, want 12 (each message exactly once)", tr.deliveries)
}
if n := countByStatus(t, stores, db.OutboundStatusPending) + countByStatus(t, stores, db.OutboundStatusDeferred); n != 0 {
t.Fatalf("%d items still pending/deferred", n)
}
}
// TestManagerSerialFallback 验证 workers=1 时退化为串行(旧行为)。
func TestManagerSerialFallback(t *testing.T) {
stores := newTestManagerStores(t)
seedPending(t, stores, 6)
tr := &concurrencyTracker{}
cfg := config.OutboundConfig{
PollInterval: 1,
MaxAttempts: 5,
RetryBaseMin: 1,
MaxPerDay: 10000,
Workers: 1,
BatchSize: 50,
ConnectTimeout: 10,
}
m := NewManager(cfg, "test.local", stores)
m.deliver = deliverWithDelay(tr, 50*time.Millisecond)
m.Start()
t.Cleanup(m.Stop)
waitFor(t, 15*time.Second, "all items sent", func() bool {
return countByStatus(t, stores, db.OutboundStatusSent) == 6
})
if tr.peak > 1 {
t.Fatalf("workers=1 must be serial, peak=%d", tr.peak)
}
}
// TestManagerDomainLimit 验证同一收件域的并发连接数不超过上限。
func TestManagerDomainLimit(t *testing.T) {
stores := newTestManagerStores(t)
seedPending(t, stores, 8)
tr := &concurrencyTracker{}
cfg := config.OutboundConfig{
PollInterval: 1,
MaxAttempts: 5,
RetryBaseMin: 1,
MaxPerDay: 10000,
Workers: 8,
BatchSize: 50,
MaxConcurrentPerDomain: 1,
ConnectTimeout: 10,
}
m := NewManager(cfg, "test.local", stores)
m.deliver = deliverWithDelay(tr, 100*time.Millisecond)
m.Start()
t.Cleanup(m.Stop)
waitFor(t, 15*time.Second, "all items sent", func() bool {
return countByStatus(t, stores, db.OutboundStatusSent) == 8
})
if tr.peak > 1 {
t.Fatalf("per-domain limit 1 violated: peak=%d", tr.peak)
}
if tr.deliveries != 8 {
t.Fatalf("deliveries = %d, want 8", tr.deliveries)
}
}
// TestManagerRetriesTemporaryFailure 验证临时失败进入退避重试(deferred)。
func TestManagerRetriesTemporaryFailure(t *testing.T) {
stores := newTestManagerStores(t)
seedPending(t, stores, 1)
cfg := config.OutboundConfig{
PollInterval: 1,
MaxAttempts: 3,
RetryBaseMin: 1,
MaxPerDay: 10000,
Workers: 2,
BatchSize: 50,
ConnectTimeout: 10,
}
m := NewManager(cfg, "test.local", stores)
m.deliver = func(from, to string, data []byte) (string, error) {
return "", newTempError("connection refused")
}
m.Start()
t.Cleanup(m.Stop)
waitFor(t, 15*time.Second, "item deferred", func() bool {
return countByStatus(t, stores, db.OutboundStatusDeferred) == 1
})
items, _, err := stores.Outbound.List(1, 10, db.OutboundStatusDeferred)
if err != nil || len(items) != 1 {
t.Fatalf("list deferred: %v (n=%d)", err, len(items))
}
if items[0].Attempts != 1 {
t.Fatalf("attempts = %d, want 1", items[0].Attempts)
}
if items[0].LastError == "" {
t.Fatal("expected last error recorded")
}
}
// TestManagerPermanentFailureBouncesAndFails 验证永久失败直接标记 failed。
func TestManagerPermanentFailureBouncesAndFails(t *testing.T) {
stores := newTestManagerStores(t)
seedPending(t, stores, 1)
cfg := config.OutboundConfig{
PollInterval: 1,
MaxAttempts: 3,
RetryBaseMin: 1,
MaxPerDay: 10000,
Workers: 2,
BatchSize: 50,
ConnectTimeout: 10,
}
m := NewManager(cfg, "test.local", stores)
m.deliver = func(from, to string, data []byte) (string, error) {
return "", newPermError("550 recipient rejected")
}
m.Start()
t.Cleanup(m.Stop)
waitFor(t, 15*time.Second, "item failed", func() bool {
return countByStatus(t, stores, db.OutboundStatusFailed) == 1
})
if n := countByStatus(t, stores, db.OutboundStatusDeferred); n != 0 {
t.Fatalf("permanent failure must not defer: %d deferred", n)
}
}
// TestManagerDomainLimitNoLimit 验证 max_concurrent_per_domain=0 不限制并发。
func TestManagerDomainLimitNoLimit(t *testing.T) {
stores := newTestManagerStores(t)
seedPending(t, stores, 8)
tr := &concurrencyTracker{}
cfg := config.OutboundConfig{
PollInterval: 1,
MaxAttempts: 5,
RetryBaseMin: 1,
MaxPerDay: 10000,
Workers: 8,
BatchSize: 50,
ConnectTimeout: 10,
}
m := NewManager(cfg, "test.local", stores)
m.deliver = deliverWithDelay(tr, 80*time.Millisecond)
m.Start()
t.Cleanup(m.Stop)
waitFor(t, 15*time.Second, "all items sent", func() bool {
return countByStatus(t, stores, db.OutboundStatusSent) == 8
})
if tr.peak < 2 {
t.Fatalf("expected concurrent deliveries without domain limit, peak=%d", tr.peak)
}
}
// TestManagerDeliverErrorsPropagate 防御:投递函数报错不影响 worker 存活,
// 错误项进入重试(deferred)。
func TestManagerDeliverErrorsPropagate(t *testing.T) {
stores := newTestManagerStores(t)
seedPending(t, stores, 3)
var calls atomic.Int32
cfg := config.OutboundConfig{
PollInterval: 1,
MaxAttempts: 2,
RetryBaseMin: 1,
MaxPerDay: 10000,
Workers: 2,
BatchSize: 50,
ConnectTimeout: 10,
}
m := NewManager(cfg, "test.local", stores)
m.deliver = func(from, to string, data []byte) (string, error) {
n := calls.Add(1)
if n%2 == 0 {
return "", errors.New("boom")
}
return "250 ok", nil
}
m.Start()
t.Cleanup(m.Stop)
waitFor(t, 15*time.Second, "queue settled", func() bool {
return countByStatus(t, stores, db.OutboundStatusSent)+countByStatus(t, stores, db.OutboundStatusDeferred) == 3
})
if calls.Load() != 3 {
t.Fatalf("deliver calls = %d, want 3 (once per item)", calls.Load())
}
if n := countByStatus(t, stores, db.OutboundStatusFailed); n != 0 {
t.Fatalf("unexpected failed items: %d", n)
}
}
+166 -22
View File
@@ -12,8 +12,11 @@ import (
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
)
// POP3Server implements a simple POP3 mail server over TCP.
@@ -21,23 +24,25 @@ type POP3Server struct {
listener net.Listener
stores *store.Stores
cfg config.POP3Config
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
hub *connhub.Hub
pusher imap_server.Pusher // 邮件删除推送(IMAP 客户端同步),可空
wg sync.WaitGroup
}
// NewPOP3Server creates a new POP3 server instance.
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores) *POP3Server {
return &POP3Server{stores: stores, cfg: cfg}
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
// when TLS is not configured.
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher) *POP3Server {
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
}
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
if s.tlsLoader == nil {
return nil, fmt.Errorf("POP3 TLS certificate or key not configured")
}
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
if err != nil {
return nil, fmt.Errorf("load POP3 TLS certificate failed: %w", err)
}
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
// GetCertificate 每次握手按需重载证书,证书更新后无需重启服务
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
}
// Start starts the POP3 server on the configured plain-text port.
@@ -47,6 +52,7 @@ func (s *POP3Server) Start() error {
if err != nil {
return fmt.Errorf("POP3 listen failed: %w", err)
}
port := parseAddrPort(s.cfg.Addr)
log.Printf("POP3 server listening on %s", s.cfg.Addr)
@@ -61,7 +67,7 @@ func (s *POP3Server) Start() error {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn)
s.handleConn(conn, port)
}()
}
}()
@@ -80,6 +86,7 @@ func (s *POP3Server) StartTLS() error {
if err != nil {
return fmt.Errorf("POP3 TLS listen failed: %w", err)
}
port := parseAddrPort(s.cfg.TLSAddr)
log.Printf("POP3 TLS server listening on %s", s.cfg.TLSAddr)
@@ -94,7 +101,7 @@ func (s *POP3Server) StartTLS() error {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn)
s.handleConn(conn, port)
}()
}
}()
@@ -102,23 +109,67 @@ func (s *POP3Server) StartTLS() error {
return nil
}
// parseAddrPort 从监听地址解析端口号,失败返回 0。
func parseAddrPort(addr string) int {
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return 0
}
return port
}
// handleConn handles a single POP3 client connection.
func (s *POP3Server) handleConn(conn net.Conn) {
func (s *POP3Server) handleConn(conn net.Conn, port int) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Minute))
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
startedAt := time.Now()
// 已封禁 IP 直接拒绝(防协议层暴力破解)
if banned, _ := s.stores.Bans.IsBanned(clientIP); banned {
sendResponse(conn, "-ERR access denied")
s.writeProtocolLog(port, clientIP, "", false, "IP已被封禁", "连接被拒绝", 0, 0, startedAt)
return
}
// 连接追踪:注册到当前连接中心,连接结束时注销;
// 强制断开:关闭底层连接(STLS 后 conn 变量已指向 tlsConn,同样生效)。
activeConn := s.hub.Register("pop3", clientIP, port, false)
if activeConn != nil {
activeConn.SetDisconnect(func() { _ = conn.Close() })
}
// 会话状态(供协议日志汇总)
var (
authUser *db.User
authUsername string
authFailReason string
commandCount = make(map[string]int)
deletedCount int
)
reader := bufio.NewReader(conn)
var user *db.User
var messages []pop3Message
var deleted map[int]bool
tlsActive := false
defer func() {
if activeConn != nil {
activeConn.Close()
}
}()
sendResponse(conn, "+OK MailGo POP3 server ready")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
break
}
line = strings.TrimSpace(line)
@@ -128,12 +179,14 @@ func (s *POP3Server) handleConn(conn net.Conn) {
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
commandCount[cmd]++
activeConn.Touch()
arg := ""
if len(parts) > 1 {
arg = strings.TrimSpace(parts[1])
}
authenticated := user != nil && user.ID != 0
authenticated := authUser != nil && authUser.ID != 0
if !authenticated && requiresAuth(cmd) {
sendResponse(conn, "-ERR authentication required")
continue
@@ -141,9 +194,18 @@ func (s *POP3Server) handleConn(conn net.Conn) {
switch cmd {
case "USER":
user, messages, deleted = s.handleUSER(conn, arg, user)
authUsername = arg
authUser, messages, deleted = s.handleUSER(conn, arg, authUser)
case "PASS":
user, messages, deleted = s.handlePASS(conn, arg, user)
authUser, messages, deleted = s.handlePASS(conn, arg, authUser)
if authUser == nil || authUser.ID == 0 {
if authFailReason == "" {
authFailReason = "用户名或密码错误"
}
} else {
authFailReason = ""
activeConn.SetUser(authUsername)
}
case "STAT":
s.handleSTAT(conn, messages, deleted)
case "LIST":
@@ -158,8 +220,11 @@ func (s *POP3Server) handleConn(conn net.Conn) {
deleted = make(map[int]bool)
sendResponse(conn, "+OK")
case "QUIT":
s.expungeDeleted(messages, deleted, user)
deletedCount = s.expungeDeleted(messages, deleted, authUser)
sendResponse(conn, "+OK MailGo POP3 server signing off")
s.writeProtocolLog(port, clientIP, authUsername, authUser != nil && authUser.ID != 0, authFailReason,
pop3CommandDetail(commandCount, deletedCount), deletedCount,
time.Since(startedAt).Milliseconds(), time.Now())
return
case "CAPA":
s.handleCAPA(conn, tlsActive)
@@ -180,11 +245,14 @@ func (s *POP3Server) handleConn(conn net.Conn) {
sendResponse(conn, "+OK Begin TLS negotiation")
tlsConn := tls.Server(conn, tlsConfig)
if err := tlsConn.Handshake(); err != nil {
s.writeProtocolLog(port, clientIP, authUsername, false, "TLS 握手失败",
pop3CommandDetail(commandCount, 0), 0, time.Since(startedAt).Milliseconds(), time.Now())
return
}
conn = tlsConn
reader = bufio.NewReader(conn)
tlsActive = true
activeConn.SetTLS(true)
case "TOP":
s.handleTOP(conn, arg, messages, deleted)
case "UIDL":
@@ -193,6 +261,56 @@ func (s *POP3Server) handleConn(conn net.Conn) {
sendResponse(conn, "-ERR unknown command")
}
}
// 连接异常结束(未 QUIT
success := authUser != nil && authUser.ID != 0 && authFailReason == ""
if success && authFailReason == "" && authUsername == "" && len(commandCount) == 0 {
success = true
}
s.writeProtocolLog(port, clientIP, authUsername, success, authFailReason,
pop3CommandDetail(commandCount, 0), 0, time.Since(startedAt).Milliseconds(), time.Now())
}
// pop3CommandDetail 汇总会话中执行的命令为可读摘要(计数,忽略 NOOP/CAPA)。
func pop3CommandDetail(counts map[string]int, deletedCount int) string {
var parts []string
for _, c := range []string{"USER", "PASS", "STAT", "LIST", "RETR", "TOP", "UIDL", "DELE", "RSET", "STLS", "QUIT"} {
n := counts[c]
if n == 0 {
continue
}
if n == 1 {
parts = append(parts, c)
} else {
parts = append(parts, fmt.Sprintf("%s×%d", c, n))
}
}
if deletedCount > 0 {
parts = append(parts, fmt.Sprintf("删除%d", deletedCount))
}
if len(parts) == 0 {
return "连接建立,无命令"
}
return strings.Join(parts, " ")
}
// writeProtocolLog 写入一条 POP3 协议调用日志。
func (s *POP3Server) writeProtocolLog(port int, ip, username string, success bool, failReason, detail string, msgCount int, durationMs int64, at time.Time) {
entry := &db.ProtocolLog{
Protocol: db.ProtocolPOP3,
Port: port,
ClientIP: ip,
Username: username,
Success: success,
FailReason: failReason,
Detail: detail,
MsgCount: msgCount,
DurationMs: durationMs,
CreatedAt: at,
}
if err := s.stores.ProtocolLogs.Create(entry); err != nil {
log.Printf("POP3: 写入协议日志失败: %v", err)
}
}
func requiresAuth(cmd string) bool {
@@ -261,6 +379,9 @@ func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db.
return &db.User{Username: username}, nil, nil
}
// 保留完整的邮箱地址作为登录标识(PASS 阶段用 Authenticate 校验),
// user.ID 用于后续加载邮件。
user.Username = username
sendResponse(conn, "+OK")
return user, nil, nil
}
@@ -272,12 +393,24 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
return nil, nil, nil
}
authUser, err := s.stores.Users.Authenticate(user.Username, password)
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password)
if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)。
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多", s.stores.Users.LoginExists(user.Username))
sendResponse(conn, "-ERR authentication failed")
return nil, nil, nil
}
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.stores.Bans.ResetFail(clientIP)
// 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用
authUser.Username = user.Username
messages := s.loadMessages(authUser)
deleted := make(map[int]bool)
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
@@ -412,18 +545,29 @@ func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Messag
sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id))
}
// expungeDeleted actually deletes messages that were marked for deletion.
func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) {
// expungeDeleted actually deletes messages that were marked for deletion,
// returning the number of messages deleted. 删除成功后向 IMAP 客户端推送
// Expunge 通知(序号为删除前在 INBOX 中的位置)。
func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) int {
if deleted == nil || user == nil || user.ID == 0 {
return
return 0
}
count := 0
var seqs []uint32
for seqNum, msgDeleted := range deleted {
if msgDeleted && seqNum >= 1 && seqNum <= len(messages) {
if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil {
log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err)
continue
}
count++
seqs = append(seqs, uint32(seqNum))
}
}
if s.pusher != nil && len(seqs) > 0 && user.Username != "" {
s.pusher.PushExpunged(user.Username, "INBOX", seqs)
}
return count
}
// sendResponse writes a POP3 response line to the connection.
+249
View File
@@ -0,0 +1,249 @@
package pop3_server
import (
"bufio"
"net"
"strings"
"testing"
"time"
"mail_go/config"
"mail_go/internal/db"
"mail_go/internal/store"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newTestServer(t *testing.T) *POP3Server {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
return &POP3Server{
stores: stores,
cfg: config.POP3Config{},
banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30},
}
}
// TestHandleConnLogsAuthFailure 验证认证失败的 POP3 会话写入协议日志。
func TestHandleConnLogsAuthFailure(t *testing.T) {
s := newTestServer(t)
server, client := net.Pipe()
defer server.Close()
defer client.Close()
done := make(chan struct{})
go func() {
defer close(done)
s.handleConn(server, 110)
}()
br := bufio.NewReader(client)
// 等待 greeting
if _, err := br.ReadString('\n'); err != nil {
t.Fatalf("greeting: %v", err)
}
client.Write([]byte("USER no-such-user\r\n"))
br.ReadString('\n')
client.Write([]byte("PASS wrong-pass\r\n"))
br.ReadString('\n')
client.Write([]byte("QUIT\r\n"))
br.ReadString('\n')
client.Close()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("handleConn did not return")
}
logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 {
t.Fatalf("expected 1 log, got %d", total)
}
log := logs[0]
if log.Protocol != db.ProtocolPOP3 || log.Port != 110 {
t.Fatalf("unexpected log: %+v", log)
}
if log.Success {
t.Fatalf("expected failure, got %+v", log)
}
if log.Username != "no-such-user" {
t.Fatalf("username = %q, want no-such-user", log.Username)
}
if !strings.Contains(log.Detail, "USER") || !strings.Contains(log.Detail, "PASS") {
t.Fatalf("detail missing commands: %q", log.Detail)
}
}
// TestHandleConnLogsSuccess 验证认证成功的 POP3 会话写入成功日志。
func TestHandleConnLogsSuccess(t *testing.T) {
s := newTestServer(t)
domain := &db.Domain{Name: "example.com"}
if err := s.stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
hashed, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash: %v", err)
}
user.PasswordHash = string(hashed)
if err := s.stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
done := make(chan struct{})
go func() {
defer close(done)
s.handleConn(server, 110)
}()
br := bufio.NewReader(client)
br.ReadString('\n')
client.Write([]byte("USER alice@example.com\r\n"))
br.ReadString('\n')
client.Write([]byte("PASS secret123\r\n"))
br.ReadString('\n')
client.Write([]byte("STAT\r\n"))
br.ReadString('\n')
client.Write([]byte("QUIT\r\n"))
br.ReadString('\n')
client.Close()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("handleConn did not return")
}
logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 {
t.Fatalf("expected 1 log, got %d", total)
}
if !logs[0].Success {
t.Fatalf("expected success, got %+v", logs[0])
}
if logs[0].Username != "alice@example.com" {
t.Fatalf("username = %q", logs[0].Username)
}
}
// TestPop3CommandDetail 验证命令摘要生成。
func TestPop3CommandDetail(t *testing.T) {
counts := map[string]int{"USER": 1, "PASS": 1, "RETR": 3, "DELE": 1, "NOOP": 2}
got := pop3CommandDetail(counts, 2)
if got != "USER PASS RETR×3 DELE 删除2" {
t.Fatalf("detail = %q", got)
}
if empty := pop3CommandDetail(nil, 0); empty != "连接建立,无命令" {
t.Fatalf("empty detail = %q", empty)
}
}
// mockPusher 记录推送调用的测试桩。
type mockPusher struct {
expunged []struct {
Email string
Mailbox string
Seqs []uint32
}
}
func (m *mockPusher) PushNewMessage(string, *db.Message) {}
func (m *mockPusher) PushFlagsChanged(string, string, *db.Message) {}
func (m *mockPusher) PushExpunged(email, mailbox string, seqs []uint32) {
m.expunged = append(m.expunged, struct {
Email string
Mailbox string
Seqs []uint32
}{email, mailbox, seqs})
}
// TestExpungePushesIMAPUpdate 验证 POP3 删除邮件后向 IMAP 推送 Expunge。
func TestExpungePushesIMAPUpdate(t *testing.T) {
s := newTestServer(t)
pusher := &mockPusher{}
s.pusher = pusher
domain := &db.Domain{Name: "example.com"}
if err := s.stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
user.PasswordHash = string(hashed)
if err := s.stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
for i := 0; i < 3; i++ {
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "m", Date: time.Now()}
if err := s.stores.Mails.Create(msg); err != nil {
t.Fatalf("create message %d: %v", i, err)
}
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
done := make(chan struct{})
go func() {
defer close(done)
s.handleConn(server, 110)
}()
br := bufio.NewReader(client)
br.ReadString('\n')
client.Write([]byte("USER alice@example.com\r\n"))
br.ReadString('\n')
client.Write([]byte("PASS secret123\r\n"))
br.ReadString('\n')
// 删除第 1 封后退出
client.Write([]byte("DELE 1\r\n"))
br.ReadString('\n')
client.Write([]byte("QUIT\r\n"))
br.ReadString('\n')
client.Close()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("handleConn did not return")
}
if len(pusher.expunged) != 1 {
t.Fatalf("expunge pushes = %d, want 1", len(pusher.expunged))
}
p := pusher.expunged[0]
if p.Email != "alice@example.com" || p.Mailbox != "INBOX" {
t.Fatalf("push target = %s/%s", p.Email, p.Mailbox)
}
if len(p.Seqs) != 1 || p.Seqs[0] != 1 {
t.Fatalf("seqs = %v, want [1]", p.Seqs)
}
// 邮件确实已删除
if n, _ := s.stores.Mails.CountByUserAndFolder(user.ID, "INBOX"); n != 2 {
t.Fatalf("inbox count = %d, want 2", n)
}
}
+184 -17
View File
@@ -6,15 +6,20 @@ import (
"fmt"
"io"
"log"
"net"
"strconv"
"strings"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/mailutil"
"mail_go/internal/outbound"
"mail_go/internal/storage"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-sasl"
@@ -35,23 +40,24 @@ type SMTPServer struct {
storage *storage.AttachmentStorage
outbound *outbound.Manager
cfg config.SMTPConfig
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
hub *connhub.Hub
pusher imap_server.Pusher // 本地投递成功推送(IMAP 新邮件),可空
}
// NewSMTPServer creates a new SMTP server instance.
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager) *SMTPServer {
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg}
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
// when TLS is not configured.
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher) *SMTPServer {
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
}
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
if s.tlsLoader == nil {
return nil, fmt.Errorf("SMTP TLS certificate or key not configured")
}
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
if err != nil {
return nil, fmt.Errorf("failed to load SMTP TLS certificate: %w", err)
}
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
// GetCertificate 每次握手按需重载证书,证书更新后无需重启服务
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
}
func (s *SMTPServer) newServer(addr string, mode smtpMode, tlsConfig *tls.Config) *smtp.Server {
@@ -108,13 +114,50 @@ type smtpBackend struct {
// NewSession creates a new SMTP session for the incoming connection.
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
clientIP := store.ClientIPFromAddr(c.Conn().RemoteAddr())
conn := be.server.hub.Register("smtp", clientIP, be.server.sessionPort(be.mode), be.server.tlsActive(c))
if conn != nil {
// 强制断开:关闭底层连接后 go-smtp 读到 EOF,正常走 Logout 收尾
raw := c.Conn()
conn.SetDisconnect(func() { _ = raw.Close() })
}
return &smtpSession{
backend: be,
mode: be.mode,
rcpts: make([]string, 0),
clientIP: clientIP,
startedAt: time.Now(),
port: be.server.sessionPort(be.mode),
conn: conn,
}, nil
}
// tlsActive 判断当前连接是否处于 TLS 加密状态(implicit TLS 或 STARTTLS)。
func (s *SMTPServer) tlsActive(c *smtp.Conn) bool {
_, ok := c.TLSConnectionState()
return ok
}
// sessionPort 返回该会话监听的端口号(区分明文/TLS/提交端口),解析失败返回 0。
func (s *SMTPServer) sessionPort(mode smtpMode) int {
addr := s.cfg.Addr
switch mode {
case smtpModeSubmission:
addr = s.cfg.SubmissionAddr
case smtpModeImplicitTLS:
addr = s.cfg.TLSAddr
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return 0
}
return port
}
// smtpSession implements the smtp.Session interface for handling a single connection.
type smtpSession struct {
backend *smtpBackend
@@ -127,6 +170,20 @@ type smtpSession struct {
userID uint
email string
user *db.User
clientIP string
// 会话日志累积状态
startedAt time.Time
port int
authTried bool
authOK bool
authUsername string
failReason string // 首个失败原因
msgCount int // 成功处理的邮件数(本地投递 + 外发队列)
detailParts []string
// 连接追踪
conn *connhub.Conn
}
// AuthMechanisms returns supported SMTP AUTH mechanisms.
@@ -134,17 +191,53 @@ func (s *smtpSession) AuthMechanisms() []string {
return []string{sasl.Plain}
}
// recordFail 记录会话中第一个失败原因(日志用途,不改变协议行为)。
func (s *smtpSession) recordFail(reason string) {
if s.failReason == "" {
s.failReason = reason
}
}
// recordDetail 追加一条操作摘要。
func (s *smtpSession) recordDetail(part string) {
s.detailParts = append(s.detailParts, part)
}
// Auth authenticates the user with SASL PLAIN credentials.
func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
if mech != sasl.Plain {
s.recordFail("不支持的认证机制")
return nil, smtp.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
user, err := s.backend.server.stores.Users.Authenticate(username, password)
if err != nil {
s.authTried = true
s.authUsername = username
s.conn.Touch()
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned {
s.recordFail("IP已被封禁")
return smtp.ErrAuthFailed
}
user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password)
if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)。
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
s.backend.server.stores.RecordAuthFailure(
s.clientIP,
s.backend.server.banCfg.MaxFailAttempts,
s.backend.server.banCfg.BanDurationMin,
"邮件协议认证失败次数过多",
s.backend.server.stores.Users.LoginExists(username),
)
s.recordFail("用户名或密码错误")
return smtp.ErrAuthFailed
}
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.backend.server.stores.Bans.ResetFail(s.clientIP)
domainName := user.Domain.Name
if domainName == "" {
domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID)
@@ -153,13 +246,18 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
}
}
if domainName == "" {
s.recordFail("用户名或密码错误")
return smtp.ErrAuthFailed
}
s.authenticated = true
s.authOK = true
s.userID = user.ID
s.user = user
s.email = user.Username + "@" + domainName
if s.conn != nil {
s.conn.SetUser(s.email)
}
return nil
}), nil
}
@@ -167,10 +265,12 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
// Mail records the sender address (MAIL FROM command).
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
if s.mode != smtpModeInbound && !s.authenticated {
s.recordFail("未认证用户尝试发信")
return smtp.ErrAuthRequired
}
// Authenticated users may only send as themselves, preventing spoofing.
if s.authenticated && !strings.EqualFold(strings.TrimSpace(from), s.email) {
s.recordFail("发件人地址与登录用户不一致")
return fmt.Errorf("sender address must match authenticated user")
}
@@ -188,6 +288,7 @@ func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
to = strings.TrimSpace(to)
if to == "" {
s.recordFail("无效的收件人地址")
return fmt.Errorf("invalid recipient address: %s", to)
}
@@ -199,12 +300,14 @@ func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
// External recipient: only authenticated local users may relay.
if !s.authenticated {
s.recordFail("中继访问被拒绝")
return fmt.Errorf("relay access denied: %s", to)
}
// Sender verification must have been enforced in Mail() already.
ob := s.backend.server.outbound
if ob == nil || !ob.Enabled() {
s.recordFail("外部投递未启用")
return fmt.Errorf("external delivery is disabled: %s", to)
}
@@ -221,58 +324,78 @@ func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
// External recipients (authenticated sessions only) are queued for
// outbound delivery.
func (s *smtpSession) Data(r io.Reader) error {
s.conn.Touch()
if len(s.rcpts) == 0 {
s.recordFail("未指定收件人")
return fmt.Errorf("no accepted recipients")
}
data, err := io.ReadAll(r)
if err != nil {
s.recordFail("读取邮件数据失败")
return fmt.Errorf("failed to read message data: %w", err)
}
parsed, err := parseSMTPMessage(data)
if err != nil {
s.recordFail("邮件格式解析失败")
return err
}
// Local recipients: deliver to INBOX.
localDelivered := 0
for _, rcpt := range s.localRcpts {
user, err := s.localUserByEmail(rcpt)
if err != nil {
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
continue
}
if err := s.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
msg, err := s.saveMessage(user.ID, "INBOX", parsed, data, false)
if err != nil {
log.Printf("SMTP: failed to create message for %s: %v", rcpt, err)
continue
}
log.Printf("SMTP: message delivered to %s", rcpt)
localDelivered++
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
if pusher := s.backend.server.pusher; pusher != nil && msg != nil {
pusher.PushNewMessage(user.Username+"@"+user.Domain.Name, msg)
}
}
s.msgCount += localDelivered
// External recipients: queue for outbound delivery.
externalQueued := 0
if len(s.externalRcpts) > 0 {
ob := s.backend.server.outbound
if ob == nil {
s.recordFail("外部投递服务不可用")
return fmt.Errorf("outbound delivery is unavailable")
}
maxRcpt := ob.MaxRecipients()
if maxRcpt > 0 && len(s.externalRcpts) > maxRcpt {
s.recordFail("外部收件人数量超出限制")
return fmt.Errorf("too many external recipients: %d (max %d)", len(s.externalRcpts), maxRcpt)
}
for _, rcpt := range s.externalRcpts {
if _, err := ob.Enqueue(s.user, s.email, rcpt, data); err != nil {
s.recordFail("外发队列投递失败")
return fmt.Errorf("failed to queue external recipient %s: %v", rcpt, err)
}
log.Printf("SMTP: external message queued for %s", rcpt)
externalQueued++
}
}
s.msgCount += externalQueued
if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound {
if err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
if _, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err)
}
}
s.recordDetail(fmt.Sprintf("MAIL FROM:<%s> RCPT×%d 本地投递%d 外发%d",
s.from, len(s.rcpts), localDelivered, externalQueued))
return nil
}
@@ -367,7 +490,7 @@ func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) {
return msg, nil
}
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) error {
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) (*db.Message, error) {
msg := &db.Message{
UserID: userID,
MessageID: parsed.messageID,
@@ -384,7 +507,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
Date: parsed.date,
}
if err := s.backend.server.stores.Mails.Create(msg); err != nil {
return err
return nil, err
}
// Persist attachments to disk and link them to the message so that the
@@ -408,7 +531,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
}
_ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize)
}
return nil
return msg, nil
}
// Reset clears the session state for the next message on the same connection.
@@ -421,5 +544,49 @@ func (s *smtpSession) Reset() {
// Logout is called when the SMTP connection is closed.
func (s *smtpSession) Logout() error {
s.writeProtocolLog()
if s.conn != nil {
s.conn.Close()
}
return nil
}
// writeProtocolLog 汇总本会话状态写入协议调用日志(供后台分析攻击/滥用)。
func (s *smtpSession) writeProtocolLog() {
success := s.failReason == ""
detail := strings.Join(s.detailParts, "; ")
username := s.authUsername
if username == "" && s.email != "" {
username = s.email
}
if detail == "" {
if s.authTried {
if s.authOK {
detail = "AUTH 成功"
} else {
detail = "AUTH 失败"
}
} else if success {
detail = "连接建立,无邮件操作"
}
}
if success && s.authTried && !s.authOK {
success = false
}
entry := &db.ProtocolLog{
Protocol: db.ProtocolSMTP,
Port: s.port,
ClientIP: s.clientIP,
Username: username,
Success: success,
FailReason: s.failReason,
Detail: detail,
MsgCount: s.msgCount,
DurationMs: time.Since(s.startedAt).Milliseconds(),
CreatedAt: time.Now(),
}
if err := s.backend.server.stores.ProtocolLogs.Create(entry); err != nil {
log.Printf("SMTP: 写入协议日志失败: %v", err)
}
}
+101 -2
View File
@@ -4,11 +4,14 @@ import (
"bytes"
"fmt"
"testing"
"time"
"mail_go/config"
"mail_go/internal/db"
"mail_go/internal/storage"
"mail_go/internal/store"
"github.com/emersion/go-sasl"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -63,7 +66,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil {
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
@@ -83,7 +86,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
t.Fatalf("create user: %v", err)
}
if err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
if _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
t.Fatalf("saveMessage: %v", err)
}
@@ -113,3 +116,99 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
t.Fatalf("attachment content mismatch: %q", content)
}
}
// TestSessionLoggingRecordsAuthFailure 验证认证失败的会话在 Logout 时写入协议日志。
func TestSessionLoggingRecordsAuthFailure(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
srv := &SMTPServer{stores: stores, banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30}}
sess := &smtpSession{
backend: &smtpBackend{server: srv, mode: smtpModeSubmission},
clientIP: "203.0.113.7",
startedAt: time.Now(),
port: 587,
}
// 触发一次认证(用户名不存在 → 失败)
mech, err := sess.Auth(sasl.Plain)
if err != nil {
t.Fatalf("Auth: %v", err)
}
// SASL PLAIN 凭据格式: authzid\0authcid\0passwd
if _, _, err := mech.Next([]byte("\x00no-such-user\x00wrong-pass")); err == nil {
t.Fatal("expected auth failure for unknown user")
}
// 直接调用 Logout 模拟连接结束
if err := sess.Logout(); err != nil {
t.Fatalf("Logout: %v", err)
}
logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 {
t.Fatalf("expected 1 log, got %d", total)
}
log := logs[0]
if log.Protocol != db.ProtocolSMTP || log.Port != 587 || log.ClientIP != "203.0.113.7" {
t.Fatalf("unexpected log: %+v", log)
}
if log.Success {
t.Fatalf("expected failure, got %+v", log)
}
if log.FailReason == "" {
t.Fatal("expected fail reason")
}
if log.Username != "no-such-user" {
t.Fatalf("username = %q", log.Username)
}
}
// TestSessionLoggingRecordsDelivery 验证投递成功的会话写入成功日志。
func TestSessionLoggingRecordsDelivery(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
attStorage := storage.NewAttachmentStorage(t.TempDir())
srv := &SMTPServer{stores: stores, storage: attStorage}
sess := &smtpSession{
backend: &smtpBackend{server: srv, mode: smtpModeInbound},
clientIP: "203.0.113.8",
startedAt: time.Now(),
port: 25,
rcpts: make([]string, 0),
}
if err := sess.Mail("sender@example.com", nil); err != nil {
t.Fatalf("Mail: %v", err)
}
if err := sess.Logout(); err != nil {
t.Fatalf("Logout: %v", err)
}
logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 {
t.Fatalf("expected 1 log, got %d", total)
}
if !logs[0].Success {
t.Fatalf("expected success, got %+v", logs[0])
}
}
+47 -11
View File
@@ -4,11 +4,16 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/google/uuid"
)
// savedFileRe 匹配 Save 生成的文件名:UUID(小写十六进制)+ 可选白名单扩展名。
// 只允许这种格式的路径进入文件系统,杜绝路径遍历(../)、绝对路径等。
var savedFileRe = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(\.[A-Za-z0-9._-]{1,32})?$`)
// AttachmentStorage handles file operations for email attachments on disk.
type AttachmentStorage struct {
baseDir string // cfg.Storage.AttachDir
@@ -19,6 +24,24 @@ func NewAttachmentStorage(baseDir string) *AttachmentStorage {
return &AttachmentStorage{baseDir: baseDir}
}
// safeExt 提取并白名单化文件扩展名:只保留字母数字与 ._-,最长 32 字符。
// 非法字符(含 CR/LF、路径分隔符)直接丢弃扩展名。
func safeExt(filename string) string {
ext := filepath.Ext(filename)
if len(ext) > 33 {
return ""
}
for _, r := range ext {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '.', r == '_', r == '-':
default:
return ""
}
}
return ext
}
// Save writes attachment data to disk and returns the relative file path.
// The filename is generated as {uuid}{ext} to avoid collisions.
func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
@@ -27,8 +50,8 @@ func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
return "", fmt.Errorf("创建附件目录失败: %w", err)
}
// Generate a unique filename with the original extension
ext := filepath.Ext(filename)
// Generate a unique filename with a sanitized extension
ext := safeExt(filename)
uniqueName := uuid.New().String() + ext
fullPath := filepath.Join(s.baseDir, uniqueName)
@@ -41,7 +64,10 @@ func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
// Read reads attachment data from disk given a relative path.
func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
fullPath := s.FullPath(relPath)
fullPath, err := s.FullPath(relPath)
if err != nil {
return nil, err
}
data, err := os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("读取附件文件失败: %w", err)
@@ -51,19 +77,29 @@ func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
// Delete removes an attachment file from disk given a relative path.
func (s *AttachmentStorage) Delete(relPath string) error {
fullPath := s.FullPath(relPath)
fullPath, err := s.FullPath(relPath)
if err != nil {
return err
}
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除附件文件失败: %w", err)
}
return nil
}
// FullPath returns the absolute path for a given relative path.
func (s *AttachmentStorage) FullPath(relPath string) string {
// Prevent directory traversal attacks
cleanRel := filepath.Clean(relPath)
if strings.HasPrefix(cleanRel, "..") {
cleanRel = strings.TrimPrefix(cleanRel, "../")
// FullPath returns the absolute path for a relative path produced by Save.
// Paths that do not match the saved-file format (traversal attempts,
// absolute paths, unrelated names) are rejected with an error so they can
// never escape baseDir.
func (s *AttachmentStorage) FullPath(relPath string) (string, error) {
if !savedFileRe.MatchString(relPath) {
return "", fmt.Errorf("非法的附件路径: %q", relPath)
}
return filepath.Join(s.baseDir, cleanRel)
// 兜底校验:解析后的路径必须仍在 baseDir 内
fullPath := filepath.Join(s.baseDir, relPath)
if !strings.HasPrefix(fullPath, filepath.Clean(s.baseDir)+string(os.PathSeparator)) {
return "", fmt.Errorf("附件路径越界: %q", relPath)
}
return fullPath, nil
}
+97
View File
@@ -0,0 +1,97 @@
package storage
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
)
// TestFullPathRejectsTraversal 验证路径遍历/绝对路径等恶意输入被拒绝。
func TestFullPathRejectsTraversal(t *testing.T) {
s := NewAttachmentStorage(filepath.Join(t.TempDir(), "attachments"))
valid := uuid.New().String() + ".pdf"
bad := []string{
"../secret.txt",
"../../etc/passwd",
"..",
"....//x",
"/etc/passwd",
"a/../b.txt",
"sub/file.png",
"",
".", "..\\..\\x", // windows style
"00000000-0000-0000-0000-000000000000.exe\r\nBcc: x@y.com",
"garbage",
"00000000-0000-0000-0000-000000000000.%2e%2e",
}
for _, p := range bad {
if _, err := s.FullPath(p); err == nil {
t.Errorf("FullPath(%q) should be rejected", p)
}
}
// 合法文件名必须通过
full, err := s.FullPath(valid)
if err != nil {
t.Fatalf("FullPath(%q) rejected: %v", valid, err)
}
if !strings.HasPrefix(full, s.baseDir+string(os.PathSeparator)) {
t.Fatalf("FullPath(%q) = %q escapes baseDir", valid, full)
}
}
// TestSaveSanitizesExtension 验证恶意扩展名不会进入文件名。
func TestSaveSanitizesExtension(t *testing.T) {
s := NewAttachmentStorage(filepath.Join(t.TempDir(), "attachments"))
// 换行/路径分隔符等非法字符的扩展名应被丢弃
rel, err := s.Save("evil.pdf\r\nBcc: x@y.com", []byte("data"))
if err != nil {
t.Fatalf("Save: %v", err)
}
if strings.ContainsAny(rel, "\r\n/\\") {
t.Fatalf("saved name contains dangerous chars: %q", rel)
}
if !savedFileRe.MatchString(rel) {
t.Fatalf("saved name %q does not match allowed pattern", rel)
}
// 后续 Read 应能按返回的路径读取
if _, err := s.Read(rel); err != nil {
t.Fatalf("Read after Save: %v", err)
}
// 正常扩展名保留
rel2, err := s.Save("report.pdf", []byte("data"))
if err != nil {
t.Fatalf("Save: %v", err)
}
if !strings.HasSuffix(rel2, ".pdf") {
t.Fatalf("extension lost: %q", rel2)
}
}
// TestReadDeleteRoundTrip 正常读写删流程。
func TestReadDeleteRoundTrip(t *testing.T) {
dir := t.TempDir()
s := NewAttachmentStorage(filepath.Join(dir, "attachments"))
rel, err := s.Save("a.txt", []byte("hello"))
if err != nil {
t.Fatalf("Save: %v", err)
}
data, err := s.Read(rel)
if err != nil || string(data) != "hello" {
t.Fatalf("Read = %q, %v", data, err)
}
if err := s.Delete(rel); err != nil {
t.Fatalf("Delete: %v", err)
}
// 删除后路径仍然合法(删除不存在文件不算错误)
if err := s.Delete(rel); err != nil {
t.Fatalf("Delete again: %v", err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"fmt"
"net"
"time"
)
// ClientIPFromAddr 从 net.Addr 提取客户端 IP 字符串(去掉端口)。
// 解析失败返回空字符串,调用方应据此跳过封禁逻辑(不误封)。
func ClientIPFromAddr(addr net.Addr) string {
if addr == nil {
return ""
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
// RecordAuthFailure 记录一次登录/认证失败(Web 表单、LDAP 与 SMTP/IMAP/POP3
// 协议层统一入口):
// - 失败计数累加(每 IP 一条记录,upsert);
// - 达到 maxFail 阈值时触发次数 BanCount+1
// knownUser(用户名存在、疑似真实用户输错)前 freeTriggers3)次
// 只计数不封禁(防误封);
// !knownUser(用户名不存在,枚举型爆破)跳过宽限,首次触发即按
// 第 1 档封禁;
// 封禁时长按档位递增(stageDuration),上限半年;
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
//
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string, knownUser bool) (banned bool, failCount int) {
if ip == "" || maxFail <= 0 {
return false, 0
}
failCount, _ = s.Bans.IncrementFail(ip)
if failCount < maxFail {
return false, failCount
}
entry, err := s.Bans.GetByIP(ip)
if err != nil || entry == nil {
return false, failCount
}
// 已处于封禁中(例如并发请求竞态)不重复触发、不重设档位
if entry.ExpiresAt.After(time.Now()) {
return true, failCount
}
banCount := entry.BanCount + 1
entry.BanCount = banCount
entry.FailCount = failCount
// 未知用户名(枚举型爆破)跳过宽限档:等效于已用完 3 次宽限
effectiveCount := banCount
if !knownUser {
effectiveCount += freeTriggers
}
// 宽限期内只计数,不封禁(保留零到期时间与空原因)
if effectiveCount <= freeTriggers {
if err := s.Bans.Update(entry); err != nil {
return false, failCount
}
return false, failCount
}
banNum := effectiveCount - freeTriggers
if knownUser {
entry.Reason = fmt.Sprintf("第%d次封禁:%s(第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
} else {
entry.Reason = fmt.Sprintf("第%d次封禁:%s(未知用户名,跳过宽限;第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
}
entry.ExpiresAt = time.Now().Add(stageDuration(banNum, firstBanMin))
if err := s.Bans.Update(entry); err != nil {
return false, failCount
}
return true, failCount
}
+550
View File
@@ -0,0 +1,550 @@
package store
import (
"sync"
"net"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/internal/db"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newTestStores(t *testing.T) *Stores {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewStores(gdb)
}
// TestRecordAuthFailureFreeTriggers 验证前 3 次达到阈值只计数不封禁,
// 第 4 次起封禁(第 1 次封禁 = 配置时长)。
func TestRecordAuthFailureFreeTriggers(t *testing.T) {
s := newTestStores(t)
const ip = "203.0.113.10"
const maxFail = 2
failOnce := func() bool {
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
return banned
}
// 第 1 次触发需要 maxFail 次失败
for f := 0; f < maxFail; f++ {
if failOnce() {
t.Fatalf("trigger 1 (fail %d) should not ban yet", f+1)
}
}
// 达到阈值后失败计数持续累计,之后每次失败都会再次触发
for i := 2; i <= 3; i++ {
if failOnce() {
t.Fatalf("trigger %d should not ban yet", i)
}
}
entry, err := s.Bans.GetByIP(ip)
if err != nil {
t.Fatalf("get entry: %v", err)
}
if entry.BanCount != 3 {
t.Fatalf("ban_count = %d, want 3", entry.BanCount)
}
if !entry.ExpiresAt.IsZero() {
t.Fatal("observation record must not have expiry")
}
// 第 4 次触发封禁,时长 = firstBanMin30 分钟)
if !failOnce() {
t.Fatal("4th trigger should ban the IP")
}
entry, err = s.Bans.GetByIP(ip)
if err != nil {
t.Fatalf("get entry: %v", err)
}
wantExpiry := time.Now().Add(30 * time.Minute)
if entry.ExpiresAt.Before(wantExpiry.Add(-time.Minute)) || entry.ExpiresAt.After(wantExpiry.Add(time.Minute)) {
t.Fatalf("ban expiry = %v, want ~%v", entry.ExpiresAt, wantExpiry)
}
if !strings.Contains(entry.Reason, "第1次封禁") {
t.Fatalf("reason = %q, want 第1次封禁", entry.Reason)
}
if entry.BanCount != 4 {
t.Fatalf("ban_count = %d, want 4", entry.BanCount)
}
// IP 现在处于封禁状态
if banned, _ := s.Bans.IsBanned(ip); !banned {
t.Fatal("IP should be banned")
}
}
// TestStagedBanEscalation 验证封禁档位递增:30分钟 → 3小时 → 3个月 → 半年(上限)。
func TestStagedBanEscalation(t *testing.T) {
s := newTestStores(t)
const ip = "203.0.113.11"
const maxFail = 2
failOnce := func() bool {
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
return banned
}
// 第 1 次触发需要 maxFail 次失败;此后每次失败即触发下一轮
if failOnce() {
t.Fatal("fail 1 must not trigger")
}
if failOnce() { // 触发 1
t.Fatal("trigger 1 must not ban")
}
if failOnce() { // 触发 2
t.Fatal("trigger 2 must not ban")
}
if failOnce() { // 触发 3
t.Fatal("trigger 3 must not ban")
}
// 第 4 次触发:30 分钟
if !failOnce() {
t.Fatal("trigger 4 should ban")
}
expectBanDuration(t, s, ip, 4, 30*time.Minute)
// 第 5 次:3 小时
expireBan(t, s, ip)
if !failOnce() {
t.Fatal("trigger 5 should ban")
}
expectBanDuration(t, s, ip, 5, 3*time.Hour)
// 第 6 次:3 个月
expireBan(t, s, ip)
if !failOnce() {
t.Fatal("trigger 6 should ban")
}
expectBanDuration(t, s, ip, 6, 90*24*time.Hour)
// 第 7 次:半年
expireBan(t, s, ip)
if !failOnce() {
t.Fatal("trigger 7 should ban")
}
expectBanDuration(t, s, ip, 7, 180*24*time.Hour)
// 第 8 次:仍为半年(上限)
expireBan(t, s, ip)
if !failOnce() {
t.Fatal("trigger 8 should ban")
}
expectBanDuration(t, s, ip, 8, 180*24*time.Hour)
entry, _ := s.Bans.GetByIP(ip)
if !strings.Contains(entry.Reason, "第5次封禁") {
t.Fatalf("reason = %q, want 第5次封禁", entry.Reason)
}
}
// expectBanDuration 断言该 IP 当前封禁时长约为 min(允许 2 分钟误差)。
func expectBanDuration(t *testing.T, s *Stores, ip string, trigger int, min time.Duration) {
t.Helper()
entry, err := s.Bans.GetByIP(ip)
if err != nil {
t.Fatalf("trigger %d: %v", trigger, err)
}
diff := entry.ExpiresAt.Sub(time.Now())
if diff < min-2*time.Minute || diff > min+2*time.Minute {
t.Fatalf("trigger %d: ban duration = %v, want ~%v", trigger, diff, min)
}
}
// expireBan 把该 IP 的封禁记录改成已过期(模拟时间流逝)。
func expireBan(t *testing.T, s *Stores, ip string) {
t.Helper()
entry, err := s.Bans.GetByIP(ip)
if err != nil {
t.Fatalf("get entry: %v", err)
}
entry.ExpiresAt = time.Now().Add(-time.Minute)
if err := s.Bans.Update(entry); err != nil {
t.Fatalf("update entry: %v", err)
}
}
// TestBanListOnlyBannedOrExpired 验证列表只返回封禁(含已过期)记录,
// 仅计数的观察记录不出现。
func TestBanListOnlyBannedOrExpired(t *testing.T) {
s := newTestStores(t)
// 观察记录:失败计数,未封禁(无到期时间)
if _, err := s.Bans.IncrementFail("203.0.113.20"); err != nil {
t.Fatalf("increment: %v", err)
}
// 当前生效的封禁
if err := s.Bans.Create(&db.BanEntry{
IPAddress: "198.51.100.21",
Reason: "第1次封禁:登录失败次数过多(第4次触发,失败5次)",
FailCount: 5,
BanCount: 4,
ExpiresAt: time.Now().Add(30 * time.Minute),
}); err != nil {
t.Fatalf("create ban: %v", err)
}
// 已过期的封禁(历史)
if err := s.Bans.Create(&db.BanEntry{
IPAddress: "198.51.100.22",
Reason: "第2次封禁:登录失败次数过多(第5次触发,失败6次)",
FailCount: 6,
BanCount: 5,
ExpiresAt: time.Now().Add(-24 * time.Hour),
}); err != nil {
t.Fatalf("create expired ban: %v", err)
}
entries, total, err := s.Bans.List(1, 10)
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 2 {
t.Fatalf("total = %d, want 2", total)
}
if len(entries) != 2 {
t.Fatalf("len = %d, want 2", len(entries))
}
for _, e := range entries {
if e.ExpiresAt.Before(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("observation record leaked into list: %+v", e)
}
}
}
// TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。
func TestRecordAuthFailureEmptyIPSafe(t *testing.T) {
s := newTestStores(t)
banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多", true)
if banned || count != 0 {
t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count)
}
if _, err := s.Bans.GetByIP(""); err == nil {
t.Fatal("empty IP should not be recorded")
}
}
// TestRecordAuthFailureWebAndProtocolShared 协议层与 Web 层共用封禁记录。
func TestRecordAuthFailureWebAndProtocolShared(t *testing.T) {
s := newTestStores(t)
const ip = "198.51.100.20"
// Web 层已封禁(直接建记录模拟),协议层认证必须被拒绝
s.Bans.Create(&db.BanEntry{
IPAddress: ip,
Reason: "web login failures",
FailCount: 5,
ExpiresAt: time.Now().Add(30 * time.Minute),
})
if banned, _ := s.Bans.IsBanned(ip); !banned {
t.Fatal("IP should be banned for both web and protocol auth")
}
}
func TestClientIPFromAddr(t *testing.T) {
cases := []struct {
addr net.Addr
want string
}{
{nil, ""},
{addrMock("203.0.113.5:12345"), "203.0.113.5"},
{addrMock("[2001:db8::1]:993"), "2001:db8::1"},
{addrMock("bad-format"), "bad-format"},
}
for _, tc := range cases {
if got := ClientIPFromAddr(tc.addr); got != tc.want {
t.Errorf("ClientIPFromAddr(%v) = %q, want %q", tc.addr, got, tc.want)
}
}
}
// addrMock 实现 net.Addr 的最小桩。
type addrMock string
func (a addrMock) Network() string { return "tcp" }
func (a addrMock) String() string { return string(a) }
// P3 #13:配额原子预扣——并发/超额场景下不得绕过配额。
func TestTryReserveQuota(t *testing.T) {
s := newTestStores(t)
// 用户配额 1000
user := &db.User{
Username: "quota_user",
PasswordHash: "x",
DomainID: 0,
QuotaBytes: 1000,
IsActive: true,
}
if err := s.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
if user.ID == 0 {
t.Fatal("user ID must be assigned")
}
// 预扣 600 成功
ok, err := s.Users.TryReserveQuota(user.ID, 600)
if err != nil || !ok {
t.Fatalf("reserve 600: ok=%v err=%v", ok, err)
}
// 再扣 400 正好用完
ok, err = s.Users.TryReserveQuota(user.ID, 400)
if err != nil || !ok {
t.Fatalf("reserve 400: ok=%v err=%v", ok, err)
}
// 超出配额被拒且不改变 used_bytes
ok, err = s.Users.TryReserveQuota(user.ID, 1)
if err != nil {
t.Fatalf("reserve beyond quota: %v", err)
}
if ok {
t.Fatal("reserve beyond quota must fail")
}
got, _ := s.Users.GetByID(user.ID)
if got.UsedBytes != 1000 {
t.Fatalf("used_bytes = %d, want 1000 (no partial charge)", got.UsedBytes)
}
// 释放后可以再次预扣
if err := s.Users.UpdateUsedBytes(user.ID, -500); err != nil {
t.Fatalf("release: %v", err)
}
ok, err = s.Users.TryReserveQuota(user.ID, 500)
if err != nil || !ok {
t.Fatalf("reserve after release: ok=%v err=%v", ok, err)
}
}
// P3 #13:非正 delta 不允许(防御)。
func TestTryReserveQuotaNonPositiveDelta(t *testing.T) {
s := newTestStores(t)
user := &db.User{Username: "u", PasswordHash: "x", QuotaBytes: 100}
if err := s.Users.Create(user); err != nil {
t.Fatal(err)
}
for _, delta := range []int64{0, -10} {
ok, err := s.Users.TryReserveQuota(user.ID, delta)
if err != nil {
t.Fatalf("delta %d: %v", delta, err)
}
if ok {
t.Fatalf("delta %d must not reserve", delta)
}
}
got, _ := s.Users.GetByID(user.ID)
if got.UsedBytes != 0 {
t.Fatalf("used_bytes = %d, want 0", got.UsedBytes)
}
}
// P5 #18 方案 A:未知用户名(枚举型爆破)跳过宽限,首次触发即封。
func TestRecordAuthFailureUnknownUserSkipsGrace(t *testing.T) {
s := newTestStores(t)
const ip = "203.0.113.77"
const maxFail = 3
// 未知用户名:第 1 次触发(累计失败 3 次)即封第 1 档(30 分钟)
for i := 1; i <= maxFail; i++ {
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", false)
if i < maxFail && banned {
t.Fatalf("attempt %d should not ban before threshold", i)
}
if i == maxFail && !banned {
t.Fatal("unknown user: first trigger must ban immediately")
}
}
banned, entry := s.Bans.IsBanned(ip)
if !banned {
t.Fatal("IP should be banned")
}
// 第 1 档 = 30 分钟
if entry.ExpiresAt.Before(time.Now().Add(29 * time.Minute)) {
t.Fatalf("first-stage ban duration wrong: expires %v", entry.ExpiresAt)
}
if !strings.Contains(entry.Reason, "未知用户名") {
t.Fatalf("reason should note unknown-user skip: %q", entry.Reason)
}
}
// P5 #18 方案 A:已知用户名(真实用户输错)保留前 3 次宽限(回归)。
// 触发语义与 TestRecordAuthFailureFreeTriggers 一致:达到阈值后每次失败
// 都会触发一次,前 3 次触发(第 2-4 次失败)不封禁,第 4 次触发
// (第 5 次失败)封第 1 档。
func TestRecordAuthFailureKnownUserKeepsGrace(t *testing.T) {
s := newTestStores(t)
const ip = "198.51.100.88"
const maxFail = 2
// 第 1 次失败:计数,未达阈值
if banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true); banned {
t.Fatal("failure 1 must not ban")
}
// 第 2-4 次失败 = 触发 1-3,宽限期内不封禁
for i := 2; i <= 4; i++ {
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
if banned {
t.Fatalf("failure %d (trigger within grace) should not ban", i)
}
}
if banned, _ := s.Bans.IsBanned(ip); banned {
t.Fatal("known user must not be banned within 3 free triggers")
}
entry, _ := s.Bans.GetByIP(ip)
if entry.BanCount != 3 {
t.Fatalf("ban_count = %d, want 3", entry.BanCount)
}
// 第 5 次失败 = 触发 4 -> 第 1 档封禁
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
if !banned {
t.Fatal("4th trigger should ban (stage 1)")
}
entry, _ = s.Bans.GetByIP(ip)
if entry.BanCount != 4 {
t.Fatalf("ban count = %d, want 4", entry.BanCount)
}
if !strings.Contains(entry.Reason, "第1次封禁") {
t.Fatalf("reason = %q, want 第1次封禁", entry.Reason)
}
}
// LoginExists:完整邮箱与裸用户名两种形态。
func TestLoginExists(t *testing.T) {
s := newTestStores(t)
domain := &db.Domain{Name: "example.com"}
if err := s.Domains.Create(domain); err != nil {
t.Fatal(err)
}
if err := s.Users.Create(&db.User{Username: "alice", PasswordHash: "x", DomainID: domain.ID}); err != nil {
t.Fatal(err)
}
cases := []struct {
login string
want bool
}{
{"alice@example.com", true},
{"alice", true},
{"bob@example.com", false},
{"bob", false},
{"", false},
}
for _, tc := range cases {
if got := s.Users.LoginExists(tc.login); got != tc.want {
t.Errorf("LoginExists(%q) = %v, want %v", tc.login, got, tc.want)
}
}
}
// P4 #17BanIP 为 upsert 语义,已有观察记录的 IP 手动封禁后仅一条记录。
func TestBanIPUpsertSingleRow(t *testing.T) {
s := newTestStores(t)
const ip = "203.0.113.99"
// 先产生观察计数记录(未封禁)
for i := 0; i < 2; i++ {
_, _ = s.RecordAuthFailure(ip, 10, 30, "登录失败次数过多", true)
}
if banned, _ := s.Bans.IsBanned(ip); banned {
t.Fatal("should be observation-only at this point")
}
// 手动封禁 180 天
if err := s.Bans.BanIP(ip, "管理员手动封禁", 180*24*time.Hour); err != nil {
t.Fatalf("BanIP: %v", err)
}
// 仅一条记录,且处于封禁状态、计数清零
entry, err := s.Bans.GetByIP(ip)
if err != nil {
t.Fatalf("GetByIP: %v", err)
}
if entry.Reason != "管理员手动封禁" {
t.Fatalf("reason = %q", entry.Reason)
}
if entry.FailCount != 0 || entry.BanCount != 0 {
t.Fatalf("manual ban should reset counters, got fail=%d ban=%d", entry.FailCount, entry.BanCount)
}
if !entry.ExpiresAt.After(time.Now().Add(179 * 24 * time.Hour)) {
t.Fatalf("manual ban duration wrong: %v", entry.ExpiresAt)
}
}
// P4 #17ip_address 唯一索引生效——同一 IP 不允许第二条记录
// (历史重复行由 InitDB 的 dedupeBanEntries 在 AutoMigrate 前清理,
// BanIP 的事务内“先删后插”兼容既有脏数据)。
func TestBanEntryUniqueIndex(t *testing.T) {
s := newTestStores(t)
const ip = "198.51.100.3"
if err := s.Bans.Create(&db.BanEntry{IPAddress: ip, Reason: "first", ExpiresAt: time.Time{}}); err != nil {
t.Fatal(err)
}
// 第二条同 IP 记录必须被唯一约束拒绝
if err := s.Bans.Create(&db.BanEntry{IPAddress: ip, Reason: "second", ExpiresAt: time.Time{}}); err == nil {
t.Fatal("duplicate ban entry for same IP should be rejected by unique index")
}
// 唯一记录上的 BanIP/IncrementFail 读写一致(无错位)
if err := s.Bans.BanIP(ip, "管理员手动封禁", time.Hour); err != nil {
t.Fatalf("BanIP: %v", err)
}
entry, err := s.Bans.GetByIP(ip)
if err != nil {
t.Fatal(err)
}
if entry.Reason != "管理员手动封禁" || entry.FailCount != 0 {
t.Fatalf("unexpected entry: %+v", entry)
}
cnt, err := s.Bans.IncrementFail(ip)
if err != nil || cnt != 1 {
t.Fatalf("IncrementFail after BanIP = %d, %v; want 1, nil", cnt, err)
}
}
// P4 #17IncrementFail 并发安全(-race 下不产生重复行、计数准确)。
func TestIncrementFailConcurrent(t *testing.T) {
s := newTestStores(t)
const ip = "203.0.113.100"
const goroutines = 16
const perG = 5
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perG; i++ {
if _, err := s.Bans.IncrementFail(ip); err != nil {
t.Errorf("IncrementFail: %v", err)
return
}
}
}()
}
wg.Wait()
entry, err := s.Bans.GetByIP(ip)
if err != nil {
t.Fatalf("GetByIP: %v", err)
}
want := goroutines * perG
if entry.FailCount != want {
t.Fatalf("fail count = %d, want %d (lost updates or duplicate rows)", entry.FailCount, want)
}
}
+116 -28
View File
@@ -1,23 +1,62 @@
package store
import (
"fmt"
"strings"
"time"
"mail_go/internal/db"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 阶段封禁档位(分钟/天),从第 4 次触发阈值开始封禁:
// 第 4 次 = ban_duration_min(默认 30 分钟)→ 第 5 次 = 3 小时 →
// 第 6 次 = 3 个月 → 第 7 次起 = 半年(上限)。
const (
// freeTriggers 达到失败阈值但暂不封禁的触发次数(前 3 次只计数)。
freeTriggers = 3
banStage2Min = 3 * 60 // 3 小时
banStage3Day = 90 // 3 个月
banStage4Day = 180 // 半年(上限)
banMaxDay = banStage4Day
)
// stageDuration 返回第 banCount 次封禁(banCount 从 1 开始)的时长。
// firstBanMin 是第一次封禁的分钟数(来自配置 [ban] ban_duration_min)。
func stageDuration(banCount int, firstBanMin int) time.Duration {
switch banCount {
case 1:
if firstBanMin <= 0 {
firstBanMin = 30
}
return time.Duration(firstBanMin) * time.Minute
case 2:
return time.Duration(banStage2Min) * time.Minute
case 3:
return time.Duration(banStage3Day) * 24 * time.Hour
default:
return time.Duration(banMaxDay) * 24 * time.Hour
}
}
// BanStore defines the interface for IP ban operations.
type BanStore interface {
Create(entry *db.BanEntry) error
// BanIP 手动/直接封禁某 IP 指定时长:该 IP 只保留一条记录
// (既有观察记录一并清理,计数清零),避免产生重复行导致
// IncrementFail 与 GetByIP 读写错位。
BanIP(ip, reason string, duration time.Duration) error
GetByIP(ip string) (*db.BanEntry, error)
Update(entry *db.BanEntry) error
Delete(id uint) error
// List 返回已封禁或曾封禁的记录(不含仅计数未封禁的观察记录)。
List(page, size int) ([]db.BanEntry, int64, error)
IsBanned(ip string) (bool, *db.BanEntry)
// IncrementFail 累加该 IP 的失败次数(无记录时创建),保留 BanCount。
IncrementFail(ip string) (int, error)
ResetFail(ip string) error
Cleanup() error
}
// banStoreGorm implements BanStore using GORM.
@@ -35,6 +74,30 @@ func (s *banStoreGorm) Create(entry *db.BanEntry) error {
return s.db.Create(entry).Error
}
// BanIP 手动/直接封禁:事务内删除该 IP 的全部既有记录(含历史 bug
// 产生的重复行与观察计数记录)后插入一条封禁记录,计数清零。
// 与"管理员解封清零"语义一致:手动封禁视为对档位的重新评估。
func (s *banStoreGorm) BanIP(ip, reason string, duration time.Duration) error {
if ip == "" {
return fmt.Errorf("empty ip")
}
if duration <= 0 {
return fmt.Errorf("invalid ban duration: %v", duration)
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("ip_address = ?", ip).Delete(&db.BanEntry{}).Error; err != nil {
return err
}
return tx.Create(&db.BanEntry{
IPAddress: ip,
Reason: reason,
FailCount: 0,
BanCount: 0,
ExpiresAt: time.Now().Add(duration),
}).Error
})
}
// GetByIP retrieves the most recent ban entry for a given IP address.
func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
var entry db.BanEntry
@@ -44,22 +107,33 @@ func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
return &entry, nil
}
// Update saves changes to an existing ban entry record.
func (s *banStoreGorm) Update(entry *db.BanEntry) error {
return s.db.Save(entry).Error
}
// Delete removes a ban entry by ID.
func (s *banStoreGorm) Delete(id uint) error {
return s.db.Delete(&db.BanEntry{}, id).Error
}
// List retrieves a paginated list of ban entries.
// banEpochSentinel 用于区分“未封禁的计数记录”(expires_at 为零值):
// 所有实际封禁记录的到期时间都晚于 2000 年。
var banEpochSentinel = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// List retrieves a paginated list of ban entries that are or have been
// banned (expires_at set). 仅计数的观察记录(零到期时间、无原因)不返回。
func (s *banStoreGorm) List(page, size int) ([]db.BanEntry, int64, error) {
var entries []db.BanEntry
var total int64
if err := s.db.Model(&db.BanEntry{}).Count(&total).Error; err != nil {
query := s.db.Model(&db.BanEntry{}).Where("expires_at > ?", banEpochSentinel)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * size
if err := s.db.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil {
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil {
return nil, 0, err
}
return entries, total, nil
@@ -75,41 +149,55 @@ func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) {
return true, &entry
}
// IncrementFail increments the fail count for an IP address.
// If no record exists, it creates one with fail_count=1 and a zero expires_at.
// Returns the updated fail count.
// IncrementFail increments the fail count for an IP address atomically
// (SQL-side increment, avoiding read-modify-write races). If no record
// exists it creates one with fail_count=1, ban_count=0 and a zero
// expires_at (not yet banned); a concurrent creator wins and the loser's
// insert becomes a no-op via the unique index. Existing BanCount is
// preserved. Returns the current fail count.
func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
var entry db.BanEntry
err := s.db.Where("ip_address = ?", ip).First(&entry).Error
if err != nil {
// No record exists, create a new one
entry = db.BanEntry{
res := s.db.Model(&db.BanEntry{}).
Where("ip_address = ?", ip).
Update("fail_count", gorm.Expr("fail_count + 1"))
if res.Error != nil {
return 0, res.Error
}
if res.RowsAffected == 0 {
// 无记录:插入首条;ip_address 唯一索引下并发插入用
// OnConflict DoNothing 兜底,失败方继续走下面的回读。
err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&db.BanEntry{
IPAddress: ip,
FailCount: 1,
BanCount: 0,
ExpiresAt: time.Time{}, // Zero time, not yet banned
}).Error
if err != nil && !isUniqueConflictErr(err) {
return 0, err
}
if createErr := s.db.Create(&entry).Error; createErr != nil {
return 0, createErr
}
return 1, nil
}
// Record exists, increment fail count
newCount := entry.FailCount + 1
if updateErr := s.db.Model(&entry).Update("fail_count", newCount).Error; updateErr != nil {
return 0, updateErr
// 回读计数(并发下取数据库最终值)
var count int64
if err := s.db.Model(&db.BanEntry{}).Where("ip_address = ?", ip).
Select("fail_count").Scan(&count).Error; err != nil {
return 0, err
}
return newCount, nil
return int(count), nil
}
// isUniqueConflictErr 判断是否为唯一约束冲突(并发插入竞态的预期结果)。
func isUniqueConflictErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "UNIQUE constraint") || // SQLite
strings.Contains(msg, "Duplicate entry") || // MySQL
strings.Contains(msg, "unique constraint") // generic
}
// ResetFail resets the fail count for an IP address by deleting its record.
// 成功登录(或管理员解封)后调用:封禁档位历史随之清零。
func (s *banStoreGorm) ResetFail(ip string) error {
return s.db.Where("ip_address = ?", ip).Delete(&db.BanEntry{}).Error
}
// Cleanup removes expired ban entries.
// It deletes records where expires_at is in the past and is not zero
// (preserving records that have fail counts but are not yet banned).
func (s *banStoreGorm) Cleanup() error {
return s.db.Where("expires_at < ? AND expires_at > ?", time.Now(), time.Time{}).Delete(&db.BanEntry{}).Error
}
+67 -2
View File
@@ -28,7 +28,18 @@ type MailStore interface {
MarkRead(id uint) error
MarkReadState(id uint, read bool) error
MarkFlagged(id uint, flagged bool) error
// SetReadStates 批量设置多封邮件的已读状态(单条 UPDATE ... IN)。
SetReadStates(ids []uint, read bool) error
// SetFlaggedStates 批量设置多封邮件的星标状态(单条 UPDATE ... IN)。
SetFlaggedStates(ids []uint, flagged bool) error
MoveToFolder(id uint, folder string) error
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
SetDeletedStates(ids []uint, deleted bool) error
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
// (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
DeleteMany(ids []uint) error
Delete(id uint) error
CountUnread(userID uint, folder string) (int64, error)
CountByFolder(folder string) (int64, error)
@@ -52,6 +63,13 @@ func newMailStore(database *gorm.DB) MailStore {
// Create inserts a new message record.
func (s *mailStoreGorm) Create(msg *db.Message) error {
// 日期统一为 UTC 存储:date 列在 SQLite 中是文本,混合时区偏移
// +08:00/-04:00 等)会让 ORDER BY date 变成错误的字典序(Web 列表
// 排序错乱、IMAP 序号与客户端日期视图不一致)。统一 UTC 后字典序
// 即时间序,所有排序路径(Web/IMAP/seqOf)全链路一致。
if msg != nil {
msg.Date = msg.Date.UTC()
}
return s.db.Create(msg).Error
}
@@ -96,11 +114,55 @@ func (s *mailStoreGorm) MarkFlagged(id uint, flagged bool) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_flagged", flagged).Error
}
// SetReadStates 批量设置多封邮件的已读状态。
// 客户端整批标记已读(手机同步后 STORE +FLAGS \Seen)时,逐条 UPDATE
// 会产生大量写事务并占住连接,这里合并为单条 SQL。
func (s *mailStoreGorm) SetReadStates(ids []uint, read bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_read", read).Error
}
// SetFlaggedStates 批量设置多封邮件的星标状态。
func (s *mailStoreGorm) SetFlaggedStates(ids []uint, flagged bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_flagged", flagged).Error
}
// MoveToFolder changes the folder of a message.
func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error
}
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记。
func (s *mailStoreGorm) SetDeletedStates(ids []uint, deleted bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
}
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件。
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
var messages []db.Message
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
return nil, err
}
return messages, nil
}
// DeleteMany 批量硬删除多封邮件。
func (s *mailStoreGorm) DeleteMany(ids []uint) error {
if len(ids) == 0 {
return nil
}
return s.db.Where("id IN ?", ids).Delete(&db.Message{}).Error
}
// Delete removes a message by ID.
func (s *mailStoreGorm) Delete(id uint) error {
return s.db.Delete(&db.Message{}, id).Error
@@ -118,11 +180,14 @@ func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) {
}
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
// Messages are ordered by ID ascending so that sequence numbers are stable.
// 按 date DESC, id DESC 排序(最新在前):与主流邮件客户端(Thunderbird、
// 手机客户端等)默认视图一致,客户端自行按日期编号的 seq 式 STORE 不会
// 错位标错邮件。所有 IMAP 序号相关路径(Status/ListMessages/推送/seqOf
// 共用本排序,保证序号全链路一致。
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
var messages []db.Message
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
Order("id ASC").Find(&messages).Error; err != nil {
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
return nil, err
}
return messages, nil
+56
View File
@@ -0,0 +1,56 @@
package store
import (
"crypto/rand"
"encoding/binary"
"mail_go/internal/db"
"gorm.io/gorm"
)
// MailboxStateStore 提供 IMAP 邮箱持久化状态(UIDVALIDITY)的存取。
type MailboxStateStore interface {
// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。
UidValidity(userID uint, folder string) (uint32, error)
}
type mailboxStateStoreGorm struct {
db *gorm.DB
}
func newMailboxStateStore(database *gorm.DB) *mailboxStateStoreGorm {
return &mailboxStateStoreGorm{db: database}
}
// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。
// 随机值保证:邮箱内容身份变化(如数据库重建导致消息 ID 空间变化)时,
// 新库生成的新值会让客户端丢弃旧缓存全量重同步(RFC 3501 UIDVALIDITY
// 语义)。绝不返回 00 不是合法 UIDVALIDITY)。
func (s *mailboxStateStoreGorm) UidValidity(userID uint, folder string) (uint32, error) {
var st db.MailboxState
err := s.db.Where("user_id = ? AND folder = ?", userID, folder).First(&st).Error
if err == nil {
if st.UidValidity != 0 {
return st.UidValidity, nil
}
} else if err != gorm.ErrRecordNotFound {
return 0, err
}
// 首次访问(或旧数据为 0):随机生成并持久化
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0, err
}
v := binary.BigEndian.Uint32(buf[:])
if v == 0 {
v = 1
}
st = db.MailboxState{UserID: userID, Folder: folder, UidValidity: v}
if err := s.db.Save(&st).Error; err != nil {
return 0, err
}
return v, nil
}
@@ -0,0 +1,56 @@
package store
import (
"testing"
"mail_go/internal/db"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestMailboxStateUidValidity 验证 UIDVALIDITY:首次访问随机生成、重复访问
// 稳定返回、0 值被修正、不同邮箱互不影响。
func TestMailboxStateUidValidity(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.MailboxState{}); err != nil {
t.Fatal(err)
}
s := newMailboxStateStore(gdb)
// 首次访问:随机非 0
v1, err := s.UidValidity(1, "INBOX")
if err != nil {
t.Fatal(err)
}
if v1 == 0 {
t.Fatal("UIDVALIDITY 不应为 0")
}
// 重复访问:稳定
v2, err := s.UidValidity(1, "INBOX")
if err != nil {
t.Fatal(err)
}
if v1 != v2 {
t.Fatalf("UIDVALIDITY 不稳定: %d != %d", v1, v2)
}
// 不同邮箱:独立
v3, err := s.UidValidity(1, "Sent")
if err != nil {
t.Fatal(err)
}
if v3 == v1 {
t.Fatal("不同邮箱的 UIDVALIDITY 不应相同")
}
// 不同用户:独立
v4, err := s.UidValidity(2, "INBOX")
if err != nil {
t.Fatal(err)
}
if v4 == v1 {
t.Fatal("不同用户的 UIDVALIDITY 不应相同")
}
}
+169
View File
@@ -0,0 +1,169 @@
package store
import (
"errors"
"mail_go/internal/db"
"gorm.io/gorm"
)
// Mailbox store errors(英文文案:IMAP 响应直接使用,Web 侧另行提示)。
var (
ErrMailboxNotFound = errors.New("No such mailbox")
ErrMailboxExists = errors.New("mailbox already exists")
ErrMailboxInvalid = errors.New("invalid mailbox name")
ErrMailboxSystem = errors.New("system mailbox cannot be deleted or renamed")
ErrMailboxNotEmpty = errors.New("mailbox is not empty")
)
// MailboxStore defines the interface for mailbox (folder) operations.
type MailboxStore interface {
// EnsureSystem 幂等创建用户的系统文件夹(INBOX/Sent/Drafts/Trash)。
EnsureSystem(userID uint) error
// List 返回用户全部文件夹:系统文件夹按规范顺序在前,
// 自定义文件夹按名称升序在后。
List(userID uint) ([]db.Mailbox, error)
GetByName(userID uint, name string) (*db.Mailbox, error)
Create(mb *db.Mailbox) error
// Delete 删除空的自定义文件夹(含对应的 mailbox_states 记录)。
Delete(userID uint, name string) error
// Rename 重命名文件夹并同步迁移其邮件与 mailbox_states 记录。
Rename(userID uint, oldName, newName string) error
SetSubscribed(userID uint, name string, subscribed bool) error
}
// mailboxStoreGorm implements MailboxStore using GORM.
type mailboxStoreGorm struct {
db *gorm.DB
}
// newMailboxStore creates a new GORM-backed MailboxStore.
func newMailboxStore(database *gorm.DB) MailboxStore {
return &mailboxStoreGorm{db: database}
}
// isSystemMailboxName reports whether name is one of the system mailboxes.
func isSystemMailboxName(name string) bool {
for _, def := range db.SystemMailboxes {
if def.Name == name {
return true
}
}
return false
}
// EnsureSystem creates missing system mailboxes for the user.
// 已存在的行不覆盖:用户此前若已退订某系统文件夹(LSUB),状态保留。
func (s *mailboxStoreGorm) EnsureSystem(userID uint) error {
var existing []db.Mailbox
if err := s.db.Where("user_id = ?", userID).Find(&existing).Error; err != nil {
return err
}
have := make(map[string]bool, len(existing))
for _, mb := range existing {
have[mb.Name] = true
}
for _, def := range db.SystemMailboxes {
if have[def.Name] {
continue
}
mb := &db.Mailbox{
UserID: userID,
Name: def.Name,
SpecialUse: def.SpecialUse,
IsSubscribed: true,
}
if err := s.db.Create(mb).Error; err != nil {
return err
}
}
return nil
}
// List returns all folders for a user with system folders first.
func (s *mailboxStoreGorm) List(userID uint) ([]db.Mailbox, error) {
var mbs []db.Mailbox
if err := s.db.Where("user_id = ?", userID).Order("name").Find(&mbs).Error; err != nil {
return nil, err
}
byName := make(map[string]db.Mailbox, len(mbs))
for _, mb := range mbs {
byName[mb.Name] = mb
}
sys := make([]db.Mailbox, 0, len(db.SystemMailboxes))
for _, def := range db.SystemMailboxes {
if mb, ok := byName[def.Name]; ok {
sys = append(sys, mb)
}
}
custom := make([]db.Mailbox, 0, len(mbs))
for _, mb := range mbs {
if !isSystemMailboxName(mb.Name) {
custom = append(custom, mb)
}
}
return append(sys, custom...), nil
}
// GetByName returns a folder by its exact name.
func (s *mailboxStoreGorm) GetByName(userID uint, name string) (*db.Mailbox, error) {
var mb db.Mailbox
if err := s.db.Where("user_id = ? AND name = ?", userID, name).First(&mb).Error; err != nil {
return nil, err
}
return &mb, nil
}
// Create inserts a new mailbox row.
func (s *mailboxStoreGorm) Create(mb *db.Mailbox) error {
return s.db.Create(mb).Error
}
// Delete removes an empty mailbox and its mailbox_states record.
func (s *mailboxStoreGorm) Delete(userID uint, name string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, name).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrMailboxNotEmpty
}
if err := tx.Where("user_id = ? AND name = ?", userID, name).
Delete(&db.Mailbox{}).Error; err != nil {
return err
}
return tx.Where("user_id = ? AND folder = ?", userID, name).
Delete(&db.MailboxState{}).Error
})
}
// Rename renames a mailbox, moving its messages along with it.
func (s *mailboxStoreGorm) Rename(userID uint, oldName, newName string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&db.Mailbox{}).
Where("user_id = ? AND name = ?", userID, oldName).
Update("name", newName).Error; err != nil {
return err
}
if err := tx.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, oldName).
Update("folder", newName).Error; err != nil {
return err
}
// 旧 UIDVALIDITY 不再适用:删除状态记录,下次访问重新生成,
// 客户端据此丢弃缓存并全量重同步。
return tx.Where("user_id = ? AND folder = ?", userID, oldName).
Delete(&db.MailboxState{}).Error
})
}
// SetSubscribed updates the subscription flag of a mailbox.
func (s *mailboxStoreGorm) SetSubscribed(userID uint, name string, subscribed bool) error {
return s.db.Model(&db.Mailbox{}).
Where("user_id = ? AND name = ?", userID, name).
Update("is_subscribed", subscribed).Error
}
+141
View File
@@ -0,0 +1,141 @@
package store
import (
"testing"
"mail_go/internal/db"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// newMailboxTestStore 返回基于内存库的 MailboxStore(含 mailboxes 表)。
func newMailboxTestStore(t *testing.T) (MailboxStore, *gorm.DB) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.Mailbox{}, &db.Message{}, &db.MailboxState{}); err != nil {
t.Fatal(err)
}
return newMailboxStore(gdb), gdb
}
// TestMailboxEnsureSystem 验证系统文件夹幂等创建与规范排序。
func TestMailboxEnsureSystem(t *testing.T) {
s, _ := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatalf("EnsureSystem: %v", err)
}
// 幂等:再次调用不报错、不产生重复行
if err := s.EnsureSystem(1); err != nil {
t.Fatalf("EnsureSystem 2nd: %v", err)
}
mbs, err := s.List(1)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(mbs) != 4 {
t.Fatalf("len(List) = %d, want 4", len(mbs))
}
want := []string{"INBOX", "Sent", "Drafts", "Trash"}
for i, name := range want {
if mbs[i].Name != name {
t.Fatalf("List[%d] = %q, want %q", i, mbs[i].Name, name)
}
}
if mbs[3].SpecialUse != "Trash" {
t.Fatalf("Trash SpecialUse = %q, want Trash", mbs[3].SpecialUse)
}
}
// TestMailboxCustomFolderLifecycle 验证自定义文件夹:创建/排序/非空删除拒绝。
func TestMailboxCustomFolderLifecycle(t *testing.T) {
s, gdb := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.Create(&db.Mailbox{UserID: 1, Name: "工作", IsSubscribed: true}); err != nil {
t.Fatalf("Create: %v", err)
}
mbs, err := s.List(1)
if err != nil {
t.Fatal(err)
}
if len(mbs) != 5 || mbs[4].Name != "工作" {
t.Fatalf("custom folder not last: %+v", mbs)
}
// 放一封邮件进去 → 非空文件夹不可删除
msg := &db.Message{UserID: 1, Folder: "工作", FromAddr: "a@b", Subject: "s"}
if err := gdb.Create(msg).Error; err != nil {
t.Fatal(err)
}
if err := s.Delete(1, "工作"); err != ErrMailboxNotEmpty {
t.Fatalf("Delete non-empty mailbox = %v, want ErrMailboxNotEmpty", err)
}
// 清空后可删除
if err := gdb.Where("id = ?", msg.ID).Delete(&db.Message{}).Error; err != nil {
t.Fatal(err)
}
if err := s.Delete(1, "工作"); err != nil {
t.Fatalf("Delete empty mailbox: %v", err)
}
if _, err := s.GetByName(1, "工作"); err == nil {
t.Fatal("mailbox should be gone after Delete")
}
}
// TestMailboxRenameMovesMessages 验证重命名同步迁移邮件。
func TestMailboxRenameMovesMessages(t *testing.T) {
s, gdb := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.Create(&db.Mailbox{UserID: 1, Name: "旧名字", IsSubscribed: true}); err != nil {
t.Fatal(err)
}
msg := &db.Message{UserID: 1, Folder: "旧名字", FromAddr: "a@b", Subject: "s"}
if err := gdb.Create(msg).Error; err != nil {
t.Fatal(err)
}
if err := s.Rename(1, "旧名字", "新名字"); err != nil {
t.Fatalf("Rename: %v", err)
}
var count int64
if err := gdb.Model(&db.Message{}).
Where("user_id = 1 AND folder = ?", "新名字").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("messages in 新名字 = %d, want 1", count)
}
if _, err := s.GetByName(1, "旧名字"); err == nil {
t.Fatal("old mailbox name should be gone")
}
}
// TestMailboxSubscribed 验证订阅状态写入。
func TestMailboxSubscribed(t *testing.T) {
s, _ := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.SetSubscribed(1, "Trash", false); err != nil {
t.Fatalf("SetSubscribed: %v", err)
}
mb, err := s.GetByName(1, "Trash")
if err != nil {
t.Fatal(err)
}
if mb.IsSubscribed {
t.Fatal("Trash should be unsubscribed")
}
}
+12
View File
@@ -15,6 +15,9 @@ type OutboundStore interface {
ListDue(now time.Time, limit int) ([]db.OutboundMessage, error)
List(page, size int, status string) ([]db.OutboundMessage, int64, error)
Update(msg *db.OutboundMessage) error
// Claim 原子地将一项待投递邮件置为 sending;仅当该项仍处于
// pending/deferred 时成功(并发 worker 抢占,防重复投递)。
Claim(id uint) (bool, error)
Delete(id uint) error
CountByStatus(status string) (int64, error)
}
@@ -85,6 +88,15 @@ func (s *outboundStoreGorm) Update(msg *db.OutboundMessage) error {
return s.db.Save(msg).Error
}
// Claim 原子抢占:把 pending/deferred 项置为 sending。
// 返回是否抢占成功(false 表示已被其他 worker 抢先或状态已变化)。
func (s *outboundStoreGorm) Claim(id uint) (bool, error) {
res := s.db.Model(&db.OutboundMessage{}).
Where("id = ? AND status IN (?, ?)", id, db.OutboundStatusPending, db.OutboundStatusDeferred).
Update("status", db.OutboundStatusSending)
return res.RowsAffected == 1, res.Error
}
// Delete removes an outbound queue record by ID.
func (s *outboundStoreGorm) Delete(id uint) error {
return s.db.Delete(&db.OutboundMessage{}, id).Error
+102
View File
@@ -0,0 +1,102 @@
package store
import (
"sync"
"sync/atomic"
"testing"
"time"
"mail_go/internal/db"
)
// TestOutboundClaimAtomic 验证并发抢占同一队列项恰好只有一次成功。
func TestOutboundClaimAtomic(t *testing.T) {
s := newTestStores(t)
item := &db.OutboundMessage{
MessageID: "<t@test>",
FromAddr: "a@test.local",
ToAddr: "b@fake.test",
RecipientDom: "fake.test",
RawData: "raw",
Status: db.OutboundStatusPending,
NextAttemptAt: time.Now(),
}
if err := s.Outbound.Create(item); err != nil {
t.Fatalf("create: %v", err)
}
const n = 10
var wins int64
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ok, err := s.Outbound.Claim(item.ID)
if err != nil {
t.Errorf("claim: %v", err)
return
}
if ok {
atomic.AddInt64(&wins, 1)
}
}()
}
wg.Wait()
if wins != 1 {
t.Fatalf("claims won = %d, want exactly 1", wins)
}
got, err := s.Outbound.GetByID(item.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Status != db.OutboundStatusSending {
t.Fatalf("status = %s, want sending", got.Status)
}
}
// TestOutboundClaimStatuses 验证只有 pending/deferred 可被抢占。
func TestOutboundClaimStatuses(t *testing.T) {
s := newTestStores(t)
cases := []struct {
status string
want bool
}{
{db.OutboundStatusPending, true},
{db.OutboundStatusDeferred, true},
{db.OutboundStatusSending, false},
{db.OutboundStatusSent, false},
{db.OutboundStatusFailed, false},
{db.OutboundStatusCanceled, false},
}
for _, tc := range cases {
item := &db.OutboundMessage{
MessageID: "<t@test>",
FromAddr: "a@test.local",
ToAddr: "b@fake.test",
RecipientDom: "fake.test",
RawData: "raw",
Status: tc.status,
NextAttemptAt: time.Now(),
}
if err := s.Outbound.Create(item); err != nil {
t.Fatalf("create %s: %v", tc.status, err)
}
ok, err := s.Outbound.Claim(item.ID)
if err != nil {
t.Fatalf("claim %s: %v", tc.status, err)
}
if ok != tc.want {
t.Fatalf("claim %s = %v, want %v", tc.status, ok, tc.want)
}
if tc.want {
got, _ := s.Outbound.GetByID(item.ID)
if got.Status != db.OutboundStatusSending {
t.Fatalf("claimed %s item must become sending", tc.status)
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package store
import (
"time"
"mail_go/internal/db"
"gorm.io/gorm"
)
// ProtocolLogFilter holds optional filters for listing protocol logs.
type ProtocolLogFilter struct {
Protocol string // smtp | imap | pop3,空表示全部
Success *bool // nil 表示全部
IP string // 客户端 IP 模糊匹配
Username string // 用户名模糊匹配
From time.Time // 起(含)
To time.Time // 止(含)
}
// ProtocolLogStore defines the interface for protocol call log operations.
type ProtocolLogStore interface {
Create(log *db.ProtocolLog) error
// UpdateDuration 回填会话时长(登录后连接关闭时调用)。
UpdateDuration(id uint, durationMs int64) error
List(page, size int, filter ProtocolLogFilter) ([]db.ProtocolLog, int64, error)
// CountStats 汇总各协议的失败/成功记录数(用于页面统计卡片)。
CountStats(from time.Time) (map[string]map[string]int64, error)
// CleanupBefore 删除 created_at 早于 before 的记录。
CleanupBefore(before time.Time) (int64, error)
}
// protocolLogStoreGorm implements ProtocolLogStore using GORM.
type protocolLogStoreGorm struct {
db *gorm.DB
}
// newProtocolLogStore creates a new GORM-backed ProtocolLogStore.
func newProtocolLogStore(database *gorm.DB) ProtocolLogStore {
return &protocolLogStoreGorm{db: database}
}
// Create inserts a new protocol log record.
func (s *protocolLogStoreGorm) Create(log *db.ProtocolLog) error {
return s.db.Create(log).Error
}
// UpdateDuration 回填会话时长,仅更新 duration_ms 字段。
func (s *protocolLogStoreGorm) UpdateDuration(id uint, durationMs int64) error {
return s.db.Model(&db.ProtocolLog{}).Where("id = ?", id).Update("duration_ms", durationMs).Error
}
// List retrieves a paginated list of protocol logs, newest first.
func (s *protocolLogStoreGorm) List(page, size int, filter ProtocolLogFilter) ([]db.ProtocolLog, int64, error) {
var logs []db.ProtocolLog
var total int64
query := s.db.Model(&db.ProtocolLog{})
query = s.applyFilter(query, filter)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * size
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
func (s *protocolLogStoreGorm) applyFilter(query *gorm.DB, filter ProtocolLogFilter) *gorm.DB {
if filter.Protocol != "" {
query = query.Where("protocol = ?", filter.Protocol)
}
if filter.Success != nil {
query = query.Where("success = ?", *filter.Success)
}
if filter.IP != "" {
query = query.Where("client_ip LIKE ?", "%"+filter.IP+"%")
}
if filter.Username != "" {
query = query.Where("username LIKE ?", "%"+filter.Username+"%")
}
if !filter.From.IsZero() {
query = query.Where("created_at >= ?", filter.From)
}
if !filter.To.IsZero() {
query = query.Where("created_at <= ?", filter.To)
}
return query
}
// CountStats 返回自 from 以来的记录数,按 protocol 再按 success 分组:
// map[protocol]map[successKey]count。successKey 为 "success"/"fail"。
func (s *protocolLogStoreGorm) CountStats(from time.Time) (map[string]map[string]int64, error) {
stats := make(map[string]map[string]int64)
for _, proto := range []string{db.ProtocolSMTP, db.ProtocolIMAP, db.ProtocolPOP3} {
stats[proto] = map[string]int64{"success": 0, "fail": 0}
}
type row struct {
Protocol string
Success bool
Count int64
}
var rows []row
query := s.db.Model(&db.ProtocolLog{}).
Select("protocol, success, COUNT(*) AS count").
Group("protocol, success")
if !from.IsZero() {
query = query.Where("created_at >= ?", from)
}
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
proto := stats[r.Protocol]
if proto == nil {
proto = map[string]int64{"success": 0, "fail": 0}
stats[r.Protocol] = proto
}
key := "fail"
if r.Success {
key = "success"
}
proto[key] = r.Count
}
return stats, nil
}
// CleanupBefore deletes records older than the given time and returns the
// number of deleted rows.
func (s *protocolLogStoreGorm) CleanupBefore(before time.Time) (int64, error) {
res := s.db.Where("created_at < ?", before).Delete(&db.ProtocolLog{})
return res.RowsAffected, res.Error
}
+171
View File
@@ -0,0 +1,171 @@
package store
import (
"testing"
"time"
"mail_go/internal/db"
)
func TestProtocolLogCreateAndUpdateDuration(t *testing.T) {
s := newTestStores(t)
entry := &db.ProtocolLog{
Protocol: db.ProtocolIMAP,
Port: 143,
ClientIP: "203.0.113.9",
Username: "alice",
Success: true,
Detail: "LOGIN 成功",
CreatedAt: time.Now(),
}
if err := s.ProtocolLogs.Create(entry); err != nil {
t.Fatalf("create: %v", err)
}
if entry.ID == 0 {
t.Fatal("expected generated ID")
}
if err := s.ProtocolLogs.UpdateDuration(entry.ID, 3210); err != nil {
t.Fatalf("update duration: %v", err)
}
logs, total, err := s.ProtocolLogs.List(1, 10, ProtocolLogFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 || len(logs) != 1 {
t.Fatalf("expected 1 log, got total=%d len=%d", total, len(logs))
}
if logs[0].DurationMs != 3210 {
t.Fatalf("duration = %d, want 3210", logs[0].DurationMs)
}
if logs[0].Success != true || logs[0].Username != "alice" {
t.Fatalf("unexpected log: %+v", logs[0])
}
}
func TestProtocolLogListFilters(t *testing.T) {
s := newTestStores(t)
now := time.Now()
entries := []*db.ProtocolLog{
{Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "10.0.0.1", Username: "", Success: true, FailReason: "", Detail: "投递", CreatedAt: now.Add(-3 * time.Hour)},
{Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "10.0.0.2", Username: "admin", Success: false, FailReason: "中继访问被拒绝", Detail: "RCPT", CreatedAt: now.Add(-2 * time.Hour)},
{Protocol: db.ProtocolIMAP, Port: 993, ClientIP: "10.0.0.2", Username: "admin", Success: false, FailReason: "用户名或密码错误", Detail: "LOGIN 失败", CreatedAt: now.Add(-1 * time.Hour)},
{Protocol: db.ProtocolPOP3, Port: 110, ClientIP: "10.0.0.3", Username: "bob", Success: true, FailReason: "", Detail: "STAT", CreatedAt: now},
}
for _, e := range entries {
if err := s.ProtocolLogs.Create(e); err != nil {
t.Fatalf("create: %v", err)
}
}
cases := []struct {
name string
filter ProtocolLogFilter
want int64
}{
{"全部", ProtocolLogFilter{}, 4},
{"按协议", ProtocolLogFilter{Protocol: db.ProtocolSMTP}, 2},
{"按失败", ProtocolLogFilter{Success: boolPtr(false)}, 2},
{"按成功", ProtocolLogFilter{Success: boolPtr(true)}, 2},
{"协议+失败", ProtocolLogFilter{Protocol: db.ProtocolIMAP, Success: boolPtr(false)}, 1},
{"按IP模糊", ProtocolLogFilter{IP: "10.0.0.2"}, 2},
{"按用户名", ProtocolLogFilter{Username: "admin"}, 2},
{"按时间起", ProtocolLogFilter{From: now.Add(-90 * time.Minute)}, 2},
{"按时间止", ProtocolLogFilter{To: now.Add(-2 * time.Hour)}, 2},
{"无匹配", ProtocolLogFilter{Protocol: db.ProtocolPOP3, Success: boolPtr(false)}, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, total, err := s.ProtocolLogs.List(1, 50, tc.filter)
if err != nil {
t.Fatalf("list: %v", err)
}
if total != tc.want {
t.Fatalf("total = %d, want %d", total, tc.want)
}
})
}
}
func TestProtocolLogListPagination(t *testing.T) {
s := newTestStores(t)
for i := 0; i < 5; i++ {
if err := s.ProtocolLogs.Create(&db.ProtocolLog{
Protocol: db.ProtocolSMTP, ClientIP: "10.0.0.1",
Success: true, CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create: %v", err)
}
}
page1, total, err := s.ProtocolLogs.List(1, 2, ProtocolLogFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 5 || len(page1) != 2 {
t.Fatalf("page1 total=%d len=%d", total, len(page1))
}
// 新记录在前
if page1[0].ID < page1[1].ID {
t.Fatal("expected newest first")
}
page3, _, err := s.ProtocolLogs.List(3, 2, ProtocolLogFilter{})
if err != nil {
t.Fatalf("list page3: %v", err)
}
if len(page3) != 1 {
t.Fatalf("page3 len = %d, want 1", len(page3))
}
}
func TestProtocolLogCountStatsAndCleanup(t *testing.T) {
s := newTestStores(t)
now := time.Now()
entries := []*db.ProtocolLog{
{Protocol: db.ProtocolSMTP, ClientIP: "a", Success: true, CreatedAt: now.Add(-10 * time.Minute)},
{Protocol: db.ProtocolSMTP, ClientIP: "b", Success: false, CreatedAt: now.Add(-20 * time.Minute)},
{Protocol: db.ProtocolIMAP, ClientIP: "c", Success: false, CreatedAt: now.Add(-30 * time.Minute)},
{Protocol: db.ProtocolIMAP, ClientIP: "d", Success: false, CreatedAt: now.AddDate(0, 0, -40)},
}
for _, e := range entries {
if err := s.ProtocolLogs.Create(e); err != nil {
t.Fatalf("create: %v", err)
}
}
stats, err := s.ProtocolLogs.CountStats(now.Add(-24 * time.Hour))
if err != nil {
t.Fatalf("stats: %v", err)
}
if stats[db.ProtocolSMTP]["success"] != 1 || stats[db.ProtocolSMTP]["fail"] != 1 {
t.Fatalf("smtp stats: %+v", stats[db.ProtocolSMTP])
}
if stats[db.ProtocolIMAP]["fail"] != 1 {
t.Fatalf("imap fail stats: %+v", stats[db.ProtocolIMAP])
}
// 清理 30 天前的记录
n, err := s.ProtocolLogs.CleanupBefore(now.AddDate(0, 0, -30))
if err != nil {
t.Fatalf("cleanup: %v", err)
}
if n != 1 {
t.Fatalf("deleted = %d, want 1", n)
}
_, total, err := s.ProtocolLogs.List(1, 50, ProtocolLogFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 3 {
t.Fatalf("total = %d, want 3", total)
}
}
func boolPtr(v bool) *bool {
return &v
}
+9
View File
@@ -14,6 +14,9 @@ type Stores struct {
Attachments AttachmentStore
Bans BanStore
Outbound OutboundStore
ProtocolLogs ProtocolLogStore
MailboxState MailboxStateStore
Mailboxes MailboxStore
}
// NewStores creates a new Stores instance with all GORM-backed implementations.
@@ -25,6 +28,9 @@ func NewStores(database *gorm.DB) *Stores {
Attachments: newAttachmentStore(database),
Bans: newBanStore(database),
Outbound: newOutboundStore(database),
ProtocolLogs: newProtocolLogStore(database),
MailboxState: newMailboxStateStore(database),
Mailboxes: newMailboxStore(database),
}
}
@@ -34,3 +40,6 @@ var _ = db.Domain{}
var _ = db.Message{}
var _ = db.Attachment{}
var _ = db.BanEntry{}
var _ = db.ProtocolLog{}
var _ = db.MailboxState{}
var _ = db.Mailbox{}
+82 -2
View File
@@ -16,12 +16,23 @@ type UserStore interface {
GetByUsername(username string, domainID uint) (*db.User, error)
GetByEmail(email string) (*db.User, error)
Authenticate(email, password string) (*db.User, error)
// AuthenticateLogin 协议层登录(IMAP/SMTP/POP3):与 Authenticate 相同,
// 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下
// 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。
AuthenticateLogin(login, password string) (*db.User, error)
// LoginExists 判断登录名(完整邮箱或裸用户名)是否对应系统中的用户,
// 供封禁逻辑区分“真实用户输错密码”(保留宽限)与“枚举型爆破”
// (跳过宽限,见 RecordAuthFailure 的 knownUser 参数)。
LoginExists(login string) bool
Update(user *db.User) error
Delete(id uint) error
List(domainID uint, page, size int) ([]db.User, int64, error)
ListAll(page, size int) ([]db.User, int64, error)
UpdateUsedBytes(id uint, delta int64) error
UpdatePassword(userID uint, hashedPassword string) error
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
TryReserveQuota(userID uint, delta int64) (bool, error)
}
// userStoreGorm implements UserStore using GORM.
@@ -91,6 +102,54 @@ func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) {
return user, nil
}
// AuthenticateLogin 协议层登录:优先按完整邮箱认证;裸用户名(无 @)时
// 按用户名全局查找,仅在唯一归属时接受(多域名同名视为歧义,返回失败,
// 客户端应改用完整邮箱)。密码校验与 IsActive 逻辑与 Authenticate 一致。
func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, error) {
if strings.Contains(login, "@") {
return s.Authenticate(login, password)
}
var users []db.User
if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id").
Where("users.username = ?", login).
Preload("Domain").
Find(&users).Error; err != nil {
return nil, ErrInvalidCredentials
}
if len(users) != 1 {
// 0 个:用户不存在;多个:跨域名同名歧义,要求完整邮箱
return nil, ErrInvalidCredentials
}
user := users[0]
if !user.IsActive {
return nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return &user, nil
}
// LoginExists 判断登录名是否对应系统中的用户:完整邮箱按邮箱查,
// 裸用户名按用户名全局查(存在即算,歧义不影响存在性判定)。
// 仅用于封禁分级(knownUser),不做认证。
func (s *userStoreGorm) LoginExists(login string) bool {
login = strings.TrimSpace(login)
if login == "" {
return false
}
if strings.Contains(login, "@") {
_, err := s.GetByEmail(login)
return err == nil
}
var count int64
if err := s.db.Model(&db.User{}).Where("username = ?", login).Count(&count).Error; err != nil {
return false
}
return count > 0
}
// Update saves changes to an existing user record.
func (s *userStoreGorm) Update(user *db.User) error {
return s.db.Save(user).Error
@@ -124,9 +183,30 @@ func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error
}
// UpdatePassword updates the password hash for a user.
// TryReserveQuota atomically reserves delta bytes for a user within quota.
// The reservation is applied (used_bytes incremented) only when it does not
// exceed quota_bytes; otherwise no change is made and false is returned.
func (s *userStoreGorm) TryReserveQuota(userID uint, delta int64) (bool, error) {
if delta <= 0 {
return false, nil
}
res := s.db.Model(&db.User{}).
Where("id = ? AND used_bytes + ? <= quota_bytes", userID, delta).
Update("used_bytes", gorm.Expr("used_bytes + ?", delta))
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected == 1, nil
}
// UpdatePassword updates the password hash for a user and clears the
// must-change-password flag (the user has now set their own password).
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
return s.db.Model(&db.User{}).Where("id = ?", userID).Update("password_hash", hashedPassword).Error
return s.db.Model(&db.User{}).Where("id = ?", userID).
Updates(map[string]interface{}{
"password_hash": hashedPassword,
"must_change_password": false,
}).Error
}
// ListAll retrieves a paginated list of all users across all domains.
@@ -0,0 +1,73 @@
package store
import (
"testing"
"mail_go/internal/db"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestAuthenticateLoginBareUsername 验证协议层登录支持裸用户名:
// 唯一归属时可用,密码错误/用户不存在/跨域名同名歧义时拒绝。
func TestAuthenticateLoginBareUsername(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}); err != nil {
t.Fatal(err)
}
us := newUserStore(gdb)
ds := newDomainStore(gdb)
dom1 := &db.Domain{Name: "example.com"}
if err := ds.Create(dom1); err != nil {
t.Fatal(err)
}
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
u1 := &db.User{Username: "alice", DomainID: dom1.ID, PasswordHash: string(hashed), IsActive: true}
if err := us.Create(u1); err != nil {
t.Fatal(err)
}
// 裸用户名 + 正确密码 → 成功
u, err := us.AuthenticateLogin("alice", "secret123")
if err != nil {
t.Fatalf("bare username should succeed: %v", err)
}
if u.ID != u1.ID {
t.Fatalf("wrong user: %d != %d", u.ID, u1.ID)
}
// 裸用户名 + 错误密码 → 失败
if _, err := us.AuthenticateLogin("alice", "wrong"); err == nil {
t.Fatal("wrong password should fail")
}
// 不存在 → 失败
if _, err := us.AuthenticateLogin("nobody", "secret123"); err == nil {
t.Fatal("unknown user should fail")
}
// 完整邮箱仍然可用
if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil {
t.Fatalf("full email should succeed: %v", err)
}
// 跨域名同名 → 歧义拒绝
dom2 := &db.Domain{Name: "other.com"}
if err := ds.Create(dom2); err != nil {
t.Fatal(err)
}
u2 := &db.User{Username: "alice", DomainID: dom2.ID, PasswordHash: string(hashed), IsActive: true}
if err := us.Create(u2); err != nil {
t.Fatal(err)
}
if _, err := us.AuthenticateLogin("alice", "secret123"); err == nil {
t.Fatal("ambiguous bare username should fail")
}
// 歧义时完整邮箱仍可用
if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil {
t.Fatalf("full email should still work: %v", err)
}
}
+127
View File
@@ -0,0 +1,127 @@
// Package tlsutil 提供 TLS 证书热加载:每次 TLS 握手时按需检查
// 证书路径与文件内容是否变化,变化则自动重载,证书更新后无需重启
// 服务即可生效。
package tlsutil
import (
"crypto/tls"
"fmt"
"os"
"sync"
"time"
)
// retryInterval 是重载失败后的最小重试间隔,避免证书文件损坏时
// 每个握手都重复做无意义的磁盘读取。
const retryInterval = 5 * time.Second
// Source 返回当前应使用的证书路径。路径可能随时间变化(例如管理后台
// 一键导入证书后切换到新的域名证书);返回空路径表示暂无可用证书。
type Source func() (certPath, keyPath string)
// Loader 管理一对可热加载的证书。所有方法均并发安全。
type Loader struct {
mu sync.Mutex
source Source
certPath string
keyPath string
cert *tls.Certificate
certMod time.Time
keyMod time.Time
lastTry time.Time
logf func(format string, args ...interface{})
}
// NewLoader 立即加载并校验证书,失败返回错误(保持启动时 fail-fast)。
// source 为 nil 时证书路径固定不变,仅检测文件内容变化。
func NewLoader(certPath, keyPath string, source Source, logf func(string, ...interface{})) (*Loader, error) {
if certPath == "" || keyPath == "" {
return nil, fmt.Errorf("TLS 证书路径为空")
}
l := &Loader{
source: source,
certPath: certPath,
keyPath: keyPath,
logf: logf,
}
if err := l.load(); err != nil {
return nil, err
}
return l, nil
}
// GetCertificate 实现 tls.Config.GetCertificate
// 每次 TLS 握手时检查证书路径与文件是否有变化,有则自动重载;
// 重载失败时继续使用上一次成功加载的证书,避免中断现有服务。
func (l *Loader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
l.mu.Lock()
defer l.mu.Unlock()
certPath, keyPath := l.certPath, l.keyPath
if l.source != nil {
certPath, keyPath = l.source()
}
if certPath == "" || keyPath == "" {
// 暂无证书可用:继续使用旧证书(若有)
return l.current()
}
changed := certPath != l.certPath || keyPath != l.keyPath
if !changed {
changed = l.filesChanged(certPath, keyPath)
}
// 仅在重载失败后节流(避免证书文件损坏时每个握手都重复读盘);
// 成功后清零节流,保证正常的连续更新立即生效。
if changed && time.Since(l.lastTry) >= retryInterval {
l.lastTry = time.Now()
l.certPath, l.keyPath = certPath, keyPath
if err := l.load(); err != nil {
if l.logf != nil {
l.logf("TLS 证书重载失败 (%s, %s): %v,继续使用旧证书", certPath, keyPath, err)
}
} else {
l.lastTry = time.Time{}
if l.logf != nil {
l.logf("TLS 证书已热加载: %s", certPath)
}
}
}
return l.current()
}
// current 返回当前已加载的证书。
func (l *Loader) current() (*tls.Certificate, error) {
if l.cert == nil {
return nil, fmt.Errorf("TLS 证书不可用")
}
return l.cert, nil
}
// filesChanged 判断证书/私钥文件自上次加载后是否被修改。
// 文件暂时不可读(如正在原子替换)时视为已变化,触发重载尝试。
func (l *Loader) filesChanged(certPath, keyPath string) bool {
stC, errC := os.Stat(certPath)
stK, errK := os.Stat(keyPath)
if errC != nil || errK != nil {
return true
}
return !stC.ModTime().Equal(l.certMod) || !stK.ModTime().Equal(l.keyMod)
}
// load 从当前路径加载证书对并记录文件修改时间。
func (l *Loader) load() error {
cert, err := tls.LoadX509KeyPair(l.certPath, l.keyPath)
if err != nil {
return err
}
if stC, err := os.Stat(l.certPath); err == nil {
l.certMod = stC.ModTime()
}
if stK, err := os.Stat(l.keyPath); err == nil {
l.keyMod = stK.ModTime()
}
l.cert = &cert
return nil
}
+241
View File
@@ -0,0 +1,241 @@
package tlsutil
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// writeCertPair 生成一对自签名证书并写入文件,返回叶子证书序列号。
func writeCertPair(t *testing.T, certPath, keyPath string, serial int64) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("生成私钥失败: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(serial),
Subject: pkix.Name{CommonName: "test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"localhost"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("生成证书失败: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
t.Fatalf("写入证书失败: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("写入私钥失败: %v", err)
}
}
func leafSerial(t *testing.T, cert *x509.Certificate) *big.Int {
t.Helper()
return cert.SerialNumber
}
func TestNewLoaderFailsFast(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
os.WriteFile(certPath, []byte("garbage"), 0600)
os.WriteFile(keyPath, []byte("garbage"), 0600)
if _, err := NewLoader(certPath, keyPath, nil, nil); err == nil {
t.Fatal("无效证书应返回错误")
}
}
func TestReloadOnFileChange(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
writeCertPair(t, certPath, keyPath, 1)
l, err := NewLoader(certPath, keyPath, nil, nil)
if err != nil {
t.Fatalf("NewLoader 失败: %v", err)
}
c1, err := l.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate 失败: %v", err)
}
if c1.Leaf == nil {
if parsed, err := x509.ParseCertificate(c1.Certificate[0]); err == nil {
c1.Leaf = parsed
}
}
if leafSerial(t, c1.Leaf).Int64() != 1 {
t.Fatalf("初始证书序列号应为 1")
}
// 替换文件内容(模拟证书更新),无需重启
time.Sleep(10 * time.Millisecond) // 确保 mtime 变化
writeCertPair(t, certPath, keyPath, 2)
c2, err := l.GetCertificate(nil)
if err != nil {
t.Fatalf("更新后 GetCertificate 失败: %v", err)
}
if parsed, err := x509.ParseCertificate(c2.Certificate[0]); err == nil {
c2.Leaf = parsed
}
if leafSerial(t, c2.Leaf).Int64() != 2 {
t.Fatal("文件更新后应自动加载新证书(序列号 2)")
}
}
func TestStaleCertOnInvalidReload(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
writeCertPair(t, certPath, keyPath, 1)
l, err := NewLoader(certPath, keyPath, nil, nil)
if err != nil {
t.Fatalf("NewLoader 失败: %v", err)
}
// 写入损坏的证书:重载失败时应继续使用旧证书
time.Sleep(10 * time.Millisecond)
os.WriteFile(certPath, []byte("broken"), 0600)
c, err := l.GetCertificate(nil)
if err != nil {
t.Fatalf("重载失败时不应返回错误: %v", err)
}
if parsed, err := x509.ParseCertificate(c.Certificate[0]); err == nil {
c.Leaf = parsed
}
if leafSerial(t, c.Leaf).Int64() != 1 {
t.Fatal("重载失败时应继续使用旧证书")
}
}
func TestSourcePathSwitch(t *testing.T) {
dir := t.TempDir()
certA := filepath.Join(dir, "a", "cert.pem")
keyA := filepath.Join(dir, "a", "key.pem")
certB := filepath.Join(dir, "b", "cert.pem")
keyB := filepath.Join(dir, "b", "key.pem")
writeCertPair(t, certA, keyA, 1)
writeCertPair(t, certB, keyB, 2)
// 初始用 A,source 后续切换到 B(模拟后台导入新域名证书)
source := func() (string, string) { return certB, keyB }
l, err := NewLoader(certA, keyA, source, nil)
if err != nil {
t.Fatalf("NewLoader 失败: %v", err)
}
c, err := l.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate 失败: %v", err)
}
if parsed, err := x509.ParseCertificate(c.Certificate[0]); err == nil {
c.Leaf = parsed
}
if leafSerial(t, c.Leaf).Int64() != 2 {
t.Fatal("source 切换路径后应自动加载新证书")
}
}
func TestRapidSuccessiveChanges(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
writeCertPair(t, certPath, keyPath, 1)
l, err := NewLoader(certPath, keyPath, nil, nil)
if err != nil {
t.Fatalf("NewLoader 失败: %v", err)
}
serialOf := func() int64 {
t.Helper()
c, err := l.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate 失败: %v", err)
}
parsed, err := x509.ParseCertificate(c.Certificate[0])
if err != nil {
t.Fatalf("解析证书失败: %v", err)
}
return parsed.SerialNumber.Int64()
}
// 5 秒内连续两次更新,两次都应立即生效(节流只针对失败重载)
time.Sleep(10 * time.Millisecond)
writeCertPair(t, certPath, keyPath, 2)
if got := serialOf(); got != 2 {
t.Fatalf("第一次更新后应加载序列号 2,实际 %d", got)
}
time.Sleep(10 * time.Millisecond)
writeCertPair(t, certPath, keyPath, 3)
if got := serialOf(); got != 3 {
t.Fatalf("第二次快速更新后应立即加载序列号 3,实际 %d", got)
}
}
func TestConcurrentGetCertificate(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
writeCertPair(t, certPath, keyPath, 1)
l, err := NewLoader(certPath, keyPath, nil, nil)
if err != nil {
t.Fatalf("NewLoader 失败: %v", err)
}
var wg sync.WaitGroup
stop := make(chan struct{})
// 并发读
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
if _, err := l.GetCertificate(nil); err != nil {
t.Errorf("并发 GetCertificate 失败: %v", err)
return
}
}
}
}()
}
// 同时反复替换证书文件(模拟续期)
for i := int64(2); i < 6; i++ {
writeCertPair(t, certPath, keyPath, i)
time.Sleep(20 * time.Millisecond)
}
close(stop)
wg.Wait()
}
+306 -15
View File
@@ -5,12 +5,15 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"mail_go/internal/caddycert"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/dkim"
"mail_go/internal/outbound"
@@ -26,13 +29,92 @@ type AdminHandler struct {
stores *store.Stores
storage *storage.AttachmentStorage
tlsDir string
caddyDataDir string
outbound *outbound.Manager
// protocolLogKeepDays SMTP/IMAP/POP3 协议日志保留天数(配置文件 [web])
protocolLogKeepDays int
// hub 当前协议连接注册中心(「当前连接」页)
hub *connhub.Hub
// tz Web 展示时区(「今日」统计边界、日志日期筛选用),nil 回退本地时区
tz *time.Location
}
// NewAdminHandler creates a new AdminHandler with the given stores, attachment
// storage, TLS directory and outbound delivery manager.
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, ob *outbound.Manager) *AdminHandler {
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, outbound: ob}
// storage, TLS directory, Caddy data directory and outbound delivery manager.
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub, tz *time.Location) *AdminHandler {
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub, tz: tz}
}
// displayTZ 返回 Web 展示时区(未配置时回退本地时区)。
func (h *AdminHandler) displayTZ() *time.Location {
if h.tz != nil {
return h.tz
}
return time.Local
}
// dayStartIn 返回展示时区的「今日零点」,并转换为服务器本地时区:
// 库中 CreatedAt 按写入时服务器本地时区序列化(RFC3339 文本),
// 边界需同偏移才能保证 SQLite 文本比较正确。
func (h *AdminHandler) dayStartIn() time.Time {
now := time.Now().In(h.displayTZ())
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, h.displayTZ()).In(time.Local)
}
// manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。
const manualBanDuration = 180 * 24 * time.Hour
// DisconnectConnection 强制断开指定连接并封禁其 IP(管理后台「断开并封禁」)。
// 封禁后该 IP 的所有在线连接一并断开。
func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的连接ID")
return
}
conn, ok := h.hub.Get(id)
if !ok {
c.String(http.StatusNotFound, "连接不存在或已断开")
return
}
// 加入黑名单:180 天封禁(管理员可随时解封)。
// BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
// 避免与阶段性封禁的计数/档位记录错位。
if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
c.String(http.StatusInternalServerError, "封禁失败: %v", err)
return
}
// 断开该 IP 的全部连接(含本连接与其他协议连接)
n := h.hub.DisconnectByIP(conn.IP)
log.Printf("admin: 已封禁并断开 IP %s 的 %d 个连接", conn.IP, n)
c.Redirect(http.StatusFound, "/admin/connections")
}
// ListConnections 渲染当前协议连接页面(SMTP/IMAP/POP3 实时连接)。
func (h *AdminHandler) ListConnections(c *gin.Context) {
conns := h.hub.List()
counts := h.hub.Counts()
total := len(conns)
smtpCount := counts["smtp"]
imapCount := counts["imap"]
pop3Count := counts["pop3"]
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_connections", gin.H{
"currentUser": currentUser,
"conns": conns,
"total": total,
"smtpCount": smtpCount,
"imapCount": imapCount,
"pop3Count": pop3Count,
"now": time.Now(),
"activeFolder": "connections",
})
}
// Dashboard renders the admin dashboard with summary statistics.
@@ -51,8 +133,8 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX")
sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent")
// Today and weekly statistics
todayStart := time.Now().Truncate(24 * time.Hour)
// Today and weekly statistics(「今日」按 Web 展示时区切日)
todayStart := h.dayStartIn()
weekStart := time.Now().AddDate(0, 0, -7)
todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart)
@@ -209,6 +291,16 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
}
currentUser, _ := c.Get("currentUser")
caddyMsg, caddyMsgType := "", ""
if c.Query("caddy_err") != "" {
caddyMsg = c.Query("caddy_err")
caddyMsgType = "error"
} else if c.Query("caddy_ok") == "1" {
caddyMsg = "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。"
caddyMsgType = "success"
}
c.HTML(200, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
@@ -217,6 +309,8 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
"domain": domain,
"tlsPublicCert": readTLSCert(domain.TlsCertPath),
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
"caddyMsg": caddyMsg,
"caddyMsgType": caddyMsgType,
})
}
@@ -264,6 +358,78 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/domains")
}
// FetchCaddyCert 尝试从本机 Caddy 的证书存储中获取该域名的证书与私钥,
// 保存到 MailGo 的域名 TLS 目录并更新数据库记录。结果通过查询参数回显到
// 编辑页面(caddy_ok=1 成功 / caddy_err=<消息> 失败)。
func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
return
}
domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "域名不存在")
return
}
editURL := fmt.Sprintf("/admin/domains/%d/edit", domain.ID)
fail := func(msg string) {
log.Printf("从 Caddy 获取证书失败 domain=%s: %s", domain.Name, msg)
c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg))
}
cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots())
if err != nil {
fail(fmt.Sprintf("从 Caddy 获取证书失败: %v", err))
return
}
// 保存到域名 TLS 目录(与手动上传证书的位置一致)
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
fail(fmt.Sprintf("创建 TLS 证书目录失败: %v", err))
return
}
certPath := filepath.Join(domainTLSDir, "cert.pem")
keyPath := filepath.Join(domainTLSDir, "key.pem")
if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil {
fail(fmt.Sprintf("保存 TLS 公钥证书失败: %v", err))
return
}
if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil {
fail(fmt.Sprintf("保存 TLS 私钥失败: %v", err))
return
}
domain.TlsCertPath = certPath
domain.TlsKeyPath = keyPath
domain.TlsEnabled = true
if err := h.stores.Domains.Update(domain); err != nil {
fail(fmt.Sprintf("更新域名记录失败: %v", err))
return
}
log.Printf("已从 Caddy 导入域名 %s 的证书 (%s),热加载生效", domain.Name, cert.Source)
c.Redirect(http.StatusFound, editURL+"?caddy_ok=1")
}
// caddyCertRoots 返回按优先级排列的证书来源目录:
// 1. MailGo 的同步镜像目录 <storage>/tls/caddy —— 由 install.sh 安装的
// systemd path 同步任务以 root 权限从 Caddy 证书存储镜像而来,
// mail_go 始终可读,证书续期后自动更新;
// 2. 配置文件 caddy.data_dir 指定的目录(可选);
//
// 其余默认位置由 caddycert.Fetch 自行探测。
func (h *AdminHandler) caddyCertRoots() []string {
return []string{
filepath.Join(filepath.Dir(h.tlsDir), "caddy"),
h.caddyDataDir,
}
}
func readTLSCert(path string) string {
if path == "" {
return ""
@@ -276,7 +442,20 @@ func readTLSCert(path string) string {
return string(data)
}
// normalizePEM 统一 PEM 文本的换行为 LF:浏览器提交 textarea 时会把
// 换行规范为 CRLF,而证书文件里通常是 LF,直接比较会误判“证书已修改”
// (表现为:私钥留空保留现有私钥时仍报“必须同时填写”)。
func normalizePEM(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return s
}
func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, privateKey string) error {
// 归一化换行,保证与磁盘文件一致,避免表单往返时被误判为已修改
publicCert = normalizePEM(strings.TrimSpace(publicCert))
privateKey = normalizePEM(strings.TrimSpace(privateKey))
if !domain.TlsEnabled {
return nil
}
@@ -288,7 +467,7 @@ func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, priv
}
return fmt.Errorf("启用 TLS 时必须填写 TLS 私钥和公钥证书")
}
if hasExistingCert && privateKey == "" && strings.TrimSpace(readTLSCert(domain.TlsCertPath)) == publicCert {
if hasExistingCert && privateKey == "" && normalizePEM(strings.TrimSpace(readTLSCert(domain.TlsCertPath))) == publicCert {
return nil
}
if publicCert == "" || privateKey == "" {
@@ -596,6 +775,8 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
return
}
user.PasswordHash = string(hashedPassword)
// 管理员重置的密码必须由用户本人修改后才能正常使用
user.MustChangePassword = true
}
if err := h.stores.Users.Update(user); err != nil {
@@ -617,9 +798,6 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
// ListBans renders the IP ban list page.
func (h *AdminHandler) ListBans(c *gin.Context) {
// Clean up expired entries first
h.stores.Bans.Cleanup()
page := getPageParam(c, "page", 1)
bans, total, err := h.stores.Bans.List(page, 20)
@@ -628,6 +806,13 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
return
}
// 标记当前是否仍处于封禁中
now := time.Now()
rows := make([]banRow, 0, len(bans))
for _, b := range bans {
rows = append(rows, banRow{BanEntry: b, Active: b.ExpiresAt.After(now)})
}
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / 20
@@ -640,7 +825,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
c.HTML(200, "admin_bans", gin.H{
"currentUser": currentUser,
"bans": bans,
"rows": rows,
"total": total,
"page": page,
"pageSize": 20,
@@ -649,6 +834,12 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
})
}
// banRow 是黑名单列表行:附带了当前是否封禁中的标记。
type banRow struct {
db.BanEntry
Active bool
}
// UnbanIP removes a ban entry by ID.
func (h *AdminHandler) UnbanIP(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -665,10 +856,110 @@ func (h *AdminHandler) UnbanIP(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/bans")
}
// CleanupBans removes all expired ban entries.
func (h *AdminHandler) CleanupBans(c *gin.Context) {
h.stores.Bans.Cleanup()
c.Redirect(http.StatusFound, "/admin/bans")
// ListProtocolLogs 渲染协议调用日志页(SMTP/IMAP/POP3 调用记录,支持筛选)。
func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
// 页面访问时顺带清理过期日志,避免日志表无限增长
h.stores.ProtocolLogs.CleanupBefore(time.Now().AddDate(0, 0, -h.protocolLogKeepDays))
page := getPageParam(c, "page", 1)
pageSize := 50
var success *bool
switch c.Query("success") {
case "success":
v := true
success = &v
case "fail":
v := false
success = &v
}
from := h.parseDateQuery(c.Query("from"))
to := h.parseDateQuery(c.Query("to"))
// 日期选择到天,含当天
if !to.IsZero() {
to = to.AddDate(0, 0, 1)
}
filter := store.ProtocolLogFilter{
Protocol: c.Query("protocol"),
Success: success,
IP: strings.TrimSpace(c.Query("ip")),
Username: strings.TrimSpace(c.Query("username")),
From: from,
To: to,
}
logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter)
if err != nil {
c.String(http.StatusInternalServerError, "加载协议日志失败: %v", err)
return
}
// 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用
dayStart := h.dayStartIn()
todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart)
allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{})
normStats := func(m map[string]map[string]int64) map[string]map[string]int {
out := make(map[string]map[string]int, len(m))
for proto, counts := range m {
out[proto] = map[string]int{"success": int(counts["success"]), "fail": int(counts["fail"])}
}
return out
}
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
// 分页/筛选链接保留当前筛选条件(URL 编码防止特殊字符破坏链接)
query := map[string]string{
"protocol": url.QueryEscape(filter.Protocol),
"success": url.QueryEscape(c.Query("success")),
"ip": url.QueryEscape(filter.IP),
"username": url.QueryEscape(filter.Username),
"from": url.QueryEscape(c.Query("from")),
"to": url.QueryEscape(c.Query("to")),
}
c.HTML(200, "admin_protocol_logs", gin.H{
"currentUser": currentUser,
"logs": logs,
"total": total,
"page": page,
"pageSize": pageSize,
"totalPages": totalPages,
"filter": query,
"todayStats": normStats(todayStats),
"allStats": normStats(allStats),
"keepDays": h.protocolLogKeepDays,
"activeFolder": "protocol-logs",
})
}
// CleanupProtocolLogs 手动清理超出保留天数的协议日志。
func (h *AdminHandler) CleanupProtocolLogs(c *gin.Context) {
_, _ = h.stores.ProtocolLogs.CleanupBefore(time.Now().AddDate(0, 0, -h.protocolLogKeepDays))
c.Redirect(http.StatusFound, "/admin/protocol-logs")
}
// parseDateQuery 按 Web 展示时区解析 YYYY-MM-DD 日期,失败返回零值。
// 解析结果转换为服务器本地时区,保证与库中时间序列化偏移一致。
func (h *AdminHandler) parseDateQuery(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.ParseInLocation("2006-01-02", s, h.displayTZ())
if err != nil {
return time.Time{}
}
return t.In(time.Local)
}
// ListMails renders the admin mail list page showing all messages across all users.
@@ -760,7 +1051,7 @@ func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
c.Header("Content-Disposition", formatContentDisposition(att.FileName))
c.Data(http.StatusOK, att.ContentType, data)
}
+126
View File
@@ -0,0 +1,126 @@
package handlers
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/internal/db"
)
// makeTestCertPair 生成一对自签名证书(PEM),可选 LF/CRLF 换行。
func makeTestCertPair(t *testing.T, lineEnding string) (certPEM, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("生成私钥失败: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"mail.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("生成证书失败: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
if lineEnding == "crlf" {
certPEM = strings.ReplaceAll(certPEM, "\n", "\r\n")
}
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
if lineEnding == "crlf" {
keyPEM = strings.ReplaceAll(keyPEM, "\n", "\r\n")
}
return certPEM, keyPEM
}
// writeExistingCert 模拟已有证书文件(LF 换行)并返回 domain。
func writeExistingCert(t *testing.T, tlsDir string, certPEM, keyPEM string) *db.Domain {
t.Helper()
dir := filepath.Join(tlsDir, "1")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0644); err != nil {
t.Fatalf("写入证书失败: %v", err)
}
if err := os.WriteFile(keyPath, []byte(keyPEM+"\n"), 0600); err != nil {
t.Fatalf("写入私钥失败: %v", err)
}
return &db.Domain{
ID: 1,
Name: "mail.example.com",
TlsEnabled: true,
TlsCertPath: certPath,
TlsKeyPath: keyPath,
}
}
// TestHandleDomainTLSUpdateUnchangedCertCRLF 复现用户报告的 bug
// 浏览器把 textarea 的 LF 换成 CRLF 提交,私钥留空(保留现有私钥),
// 证书内容未变,此时保存不应报“必须同时填写”。
func TestHandleDomainTLSUpdateUnchangedCertCRLF(t *testing.T) {
certLF, keyLF := makeTestCertPair(t, "lf")
h := &AdminHandler{tlsDir: t.TempDir()}
domain := writeExistingCert(t, h.tlsDir, certLF, keyLF)
submittedCert := strings.ReplaceAll(certLF, "\n", "\r\n") // 模拟浏览器提交
if err := h.handleDomainTLSUpdate(domain, submittedCert, ""); err != nil {
t.Fatalf("证书未修改且私钥留空时应保留现有私钥,实际报错: %v", err)
}
}
// TestHandleDomainTLSUpdateNewPairCRLF 新证书+私钥(CRLF 提交)应正常保存,
// 且写入文件为 LF 换行、能组成有效密钥对。
func TestHandleDomainTLSUpdateNewPairCRLF(t *testing.T) {
certCRLF, keyCRLF := makeTestCertPair(t, "crlf")
h := &AdminHandler{tlsDir: t.TempDir()}
domain := &db.Domain{ID: 1, Name: "mail.example.com", TlsEnabled: true}
if err := h.handleDomainTLSUpdate(domain, certCRLF, keyCRLF); err != nil {
t.Fatalf("CRLF 提交的新证书应保存成功,实际报错: %v", err)
}
data, err := os.ReadFile(domain.TlsCertPath)
if err != nil {
t.Fatalf("读取保存的证书失败: %v", err)
}
if strings.Contains(string(data), "\r") {
t.Error("保存的证书文件不应包含 CR")
}
if _, err := tls.LoadX509KeyPair(domain.TlsCertPath, domain.TlsKeyPath); err != nil {
t.Fatalf("保存的证书对无效: %v", err)
}
}
// TestHandleDomainTLSUpdateChangedCertWithoutKey 证书确实被修改但私钥留空,
// 应报“必须同时填写”(防止用不匹配的私钥)。
func TestHandleDomainTLSUpdateChangedCertWithoutKey(t *testing.T) {
certA, keyA := makeTestCertPair(t, "lf")
certB, _ := makeTestCertPair(t, "lf")
h := &AdminHandler{tlsDir: t.TempDir()}
domain := writeExistingCert(t, h.tlsDir, certA, keyA)
err := h.handleDomainTLSUpdate(domain, certB, "")
if err == nil || !strings.Contains(err.Error(), "必须同时填写") {
t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err)
}
}
+84 -35
View File
@@ -1,6 +1,9 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"net/http"
@@ -8,7 +11,6 @@ import (
"mail_go/config"
"mail_go/internal/auth"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/gin-contrib/sessions"
@@ -71,19 +73,13 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
user, err := h.stores.Users.Authenticate(email, password)
if err != nil {
failCount, _ := h.stores.Bans.IncrementFail(ip)
if failCount >= h.banCfg.MaxFailAttempts {
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
banEntry := &db.BanEntry{
IPAddress: ip,
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
FailCount: failCount,
ExpiresAt: time.Now().Add(banDuration),
}
h.stores.Bans.Create(banEntry)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
// 区分失败性质:用户名存在(真实用户输错,保留宽限)vs
// 用户名不存在(枚举型爆破,跳过宽限首次触发即封)
knownUser := h.stores.Users.LoginExists(email)
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
if banned {
entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
return
}
@@ -100,11 +96,14 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
// Login successful: reset fail count
h.stores.Bans.ResetFail(ip)
// Set session values
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
// 供中间件做绝对过期与滑动续期)
session := sessions.Default(c)
session.Clear()
session.Set("userID", user.ID)
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
@@ -150,24 +149,17 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
if err != nil {
log.Printf("LDAP 认证失败: %v", err)
failCount, _ := h.stores.Bans.IncrementFail(ip)
if failCount >= h.banCfg.MaxFailAttempts {
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
banEntry := &db.BanEntry{
IPAddress: ip,
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
FailCount: failCount,
ExpiresAt: time.Now().Add(banDuration),
}
h.stores.Bans.Create(banEntry)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
// LDAP 侧用户存在性无法判定,保守按已知用户处理(保留宽限防误封)
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
if banned {
entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
return
}
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会: %v", remaining, err),
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
@@ -179,7 +171,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 用户 %s 在系统中不存在", email),
"error": "LDAP 账号未接入本系统,请联系管理员",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
@@ -200,11 +192,14 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
// Login successful: reset fail count
h.stores.Bans.ResetFail(ip)
// Set session values
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
// 供中间件做绝对过期与滑动续期)
session := sessions.Default(c)
session.Clear()
session.Set("userID", user.ID)
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
@@ -218,6 +213,36 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
c.Redirect(302, "/inbox")
}
// OAuth2 state cookie 配置。state 用于防止登录 CSRF / 授权码注入:
// 发起授权时下发随机值,回调时必须原样带回。
//
// 注意 state 不能放进主会话 cookie:主会话是 SameSite=Strict
// OAuth2 回调是从 IdP 发起的跨站顶级导航,浏览器不会携带 Strict
// cookie,因此使用独立的短期 SameSite=Lax cookie。
const (
oauth2StateCookie = "mail_go_oauth2_state"
oauth2StateMaxAge = 600 // 秒,10 分钟内完成授权流程
oauth2StateRandLen = 16 // 随机字节数(hex 编码后 32 字符)
)
// randomOAuth2State generates a hex-encoded cryptographically random state.
func randomOAuth2State() (string, error) {
buf := make([]byte, oauth2StateRandLen)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成 OAuth2 state 失败: %w", err)
}
return hex.EncodeToString(buf), nil
}
// oauth2LoginVars 是登录模板所需的公共变量。
func (h *AuthHandler) oauth2LoginVars() gin.H {
return gin.H{
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
}
}
// OAuth2Start redirects to the OAuth2 provider's authorization page.
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
@@ -226,8 +251,13 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
}
provider := auth.NewOAuth2Provider(h.authCfg)
// Use a simple state for CSRF protection (in production, use a random token)
state := "mailgo_oauth2_state"
state, err := randomOAuth2State()
if err != nil {
log.Printf("生成 OAuth2 state 失败: %v", err)
c.String(http.StatusInternalServerError, "OAuth2 登录暂不可用,请稍后重试")
return
}
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
c.Redirect(http.StatusFound, provider.GetAuthURL(state))
}
@@ -238,6 +268,22 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
return
}
// 校验 state:必须与发起授权时下发的随机值一致(常量时间比较)。
// 缺失或不匹配视为登录 CSRF / 授权码注入,直接拒绝。
cookieState, cookieErr := c.Cookie(oauth2StateCookie)
reqState := c.Query("state")
if cookieErr != nil || reqState == "" ||
subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 {
c.HTML(http.StatusForbidden, "login", func() gin.H {
v := h.oauth2LoginVars()
v["error"] = "OAuth2 state 校验失败,请重新发起登录"
return v
}())
return
}
// state 一次性使用:无论后续成败都立即失效
c.SetCookie(oauth2StateCookie, "", -1, "/auth/oauth2", "", true, true)
code := c.Query("code")
if code == "" {
c.HTML(200, "login", gin.H{
@@ -254,7 +300,7 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
if err != nil {
log.Printf("OAuth2 回调失败: %v", err)
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 认证失败: %v", err),
"error": "OAuth2 认证失败,请重试或联系管理员",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
@@ -266,7 +312,7 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 用户 %s 在系统中不存在", email),
"error": "OAuth2 账号未接入本系统,请联系管理员",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
@@ -284,11 +330,14 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
return
}
// Set session values
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
// 供中间件做绝对过期与滑动续期)
session := sessions.Default(c)
session.Clear()
session.Set("userID", user.ID)
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
+101
View File
@@ -0,0 +1,101 @@
package handlers
import (
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"time"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// performPost 发送 POST 请求并返回响应(用于处理器测试)。
func performPost(r *gin.Engine, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", path, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
// TestDisconnectConnection 验证「断开并封禁」:创建黑名单记录并断开该 IP 全部连接。
func TestDisconnectConnection(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.BanEntry{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
hub := connhub.New()
var closed atomic.Int32
// 目标 IP 两个连接(模拟多协议在线)
c1 := hub.Register("smtp", "203.0.113.77", 25, false)
c1.SetDisconnect(func() { closed.Add(1) })
c2 := hub.Register("imap", "203.0.113.77", 993, true)
c2.SetDisconnect(func() { closed.Add(1) })
// 其他 IP 不应受影响
c3 := hub.Register("pop3", "203.0.113.78", 110, false)
c3.SetDisconnect(func() { closed.Add(1) })
h := &AdminHandler{stores: stores, hub: hub}
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/admin/connections/:id/disconnect", h.DisconnectConnection)
rec := performPost(r, "/admin/connections/1/disconnect")
if rec.Code != 302 {
t.Fatalf("status = %d, want 302", rec.Code)
}
// 该 IP 的两个连接都被断开,其他连接不受影响
if closed.Load() != 2 {
t.Fatalf("closed = %d, want 2", closed.Load())
}
if n := hub.Counts()["pop3"]; n != 1 {
t.Fatalf("pop3 count = %d, want 1 (unaffected)", n)
}
// 黑名单记录:180 天封禁
banned, entry := stores.Bans.IsBanned("203.0.113.77")
if !banned {
t.Fatal("IP should be banned")
}
if entry.Reason != "管理员手动封禁(连接断开)" {
t.Fatalf("reason = %q", entry.Reason)
}
wantExpiry := time.Now().Add(180 * 24 * time.Hour)
if entry.ExpiresAt.Before(wantExpiry.Add(-time.Minute)) || entry.ExpiresAt.After(wantExpiry.Add(time.Minute)) {
t.Fatalf("expiry = %v, want ~180 days", entry.ExpiresAt)
}
}
// TestDisconnectConnectionNotFound 验证不存在的连接返回 404。
func TestDisconnectConnectionNotFound(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.BanEntry{}); err != nil {
t.Fatalf("migrate: %v", err)
}
h := &AdminHandler{stores: store.NewStores(gdb), hub: connhub.New()}
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/admin/connections/:id/disconnect", h.DisconnectConnection)
rec := performPost(r, "/admin/connections/999/disconnect")
if rec.Code != 404 {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
+448 -336
View File
@@ -4,6 +4,8 @@ import (
"encoding/base64"
"fmt"
"io"
"log"
"mime"
"net/http"
"path/filepath"
"strconv"
@@ -11,6 +13,7 @@ import (
"time"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/outbound"
"mail_go/internal/storage"
"mail_go/internal/store"
@@ -48,28 +51,88 @@ type MailHandler struct {
stores *store.Stores
storage *storage.AttachmentStorage
outbound *outbound.Manager
// svc 邮箱服务层(IMAP 层共用):文件夹目录与消息操作同源。
svc *imap_server.MailboxService
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
pusher imap_server.Pusher
}
// NewMailHandler creates a new MailHandler with the given stores, attachment
// storage and outbound delivery manager.
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager) *MailHandler {
return &MailHandler{stores: stores, storage: attStorage, outbound: ob}
// storage, mailbox service and outbound delivery manager.
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, svc *imap_server.MailboxService, pusher imap_server.Pusher) *MailHandler {
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, svc: svc, pusher: pusher}
}
// Inbox renders the inbox page showing all messages in the user's INBOX folder.
func (h *MailHandler) Inbox(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20)
// foldersFor 返回当前用户的侧边栏文件夹列表(与 IMAP LIST 同源:
// IMAP 返回什么文件夹,Web 就显示什么)。
func (h *MailHandler) foldersFor(userID uint) []imap_server.FolderInfo {
infos, err := h.svc.List(userID)
if err != nil {
c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err)
log.Printf("web: 加载文件夹列表失败 user=%d: %v", userID, err)
return nil
}
return infos
}
// userEmailOf 从 context 取当前用户完整邮箱(推送用),失败返回空串。
func userEmailOf(c *gin.Context) string {
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
return u.Username + "@" + u.Domain.Name
}
}
return ""
}
// seqOfFolder 返回消息在文件夹中的序号(1 基,与 IMAP 序号排序一致)。
func (h *MailHandler) seqOfFolder(userID uint, folder string, msgID uint) uint32 {
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, folder)
if err != nil {
return 0
}
for i := range msgs {
if msgs[i].ID == msgID {
return uint32(i + 1)
}
}
return 0
}
// purgeMessages 永久删除邮件(含附件文件与配额回退)。
func (h *MailHandler) purgeMessages(userID uint, msgs []db.Message) {
ids := make([]uint, 0, len(msgs))
for i := range msgs {
attachments, _ := h.stores.Attachments.ListByMessage(msgs[i].ID)
for _, att := range attachments {
_ = h.storage.Delete(att.FilePath)
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
}
if err := h.stores.Attachments.DeleteByMessage(msgs[i].ID); err != nil {
log.Printf("web: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err)
}
ids = append(ids, msgs[i].ID)
}
if err := h.stores.Mails.DeleteMany(ids); err != nil {
log.Printf("web: 删除邮件失败: %v", err)
}
}
// Folder renders the generic mailbox page for any folder the IMAP layer
// exposes (INBOX / Sent / Drafts / Trash / custom mailboxes).
func (h *MailHandler) Folder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
return
}
page := getPageParam(c, "page", 1)
unreadCount, _ := h.stores.Mails.CountUnread(userID, "INBOX")
currentUser, _ := c.Get("currentUser")
messages, total, err := h.svc.Messages(userID, name, page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
return
}
totalPages := int(total) / 20
if int(total)%20 > 0 {
@@ -79,16 +142,18 @@ func (h *MailHandler) Inbox(c *gin.Context) {
totalPages = 0
}
c.HTML(200, "inbox", gin.H{
currentUser, _ := c.Get("currentUser")
c.HTML(200, "folder", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"unreadCount": unreadCount,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": "INBOX",
"activeFolder": "inbox",
"folder": name,
"activeFolder": name,
"isTrash": name == "Trash",
"folders": h.foldersFor(userID),
})
}
@@ -118,7 +183,9 @@ func (h *MailHandler) View(c *gin.Context) {
// Auto mark as read
if !msg.IsRead {
_ = h.stores.Mails.MarkRead(uint(id))
if err := h.stores.Mails.MarkRead(uint(id)); err != nil {
log.Printf("web: 标记已读失败 msg=%d: %v", id, err)
}
msg.IsRead = true
}
@@ -128,7 +195,9 @@ func (h *MailHandler) View(c *gin.Context) {
"currentUser": currentUser,
"message": msg,
"attachments": attachments,
"activeFolder": resolveActiveFolder(msg.Folder),
"activeFolder": msg.Folder,
"inTrash": msg.Folder == "Trash",
"folders": h.foldersFor(userID),
})
}
@@ -155,9 +224,26 @@ func (h *MailHandler) Compose(c *gin.Context) {
"bodyContent": "",
"usedBytes": usedBytes,
"quotaBytes": quotaBytes,
"folders": h.foldersFor(userID),
})
}
// composeData builds the shared template context for the compose page.
func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
return gin.H{
"currentUser": user,
"activeFolder": "compose",
"error": errMsg,
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": body,
"usedBytes": user.UsedBytes,
"quotaBytes": user.QuotaBytes,
"folders": h.foldersFor(userID),
}
}
// DoSend processes the email composition form, sends the email via SMTP,
// and stores the message record.
func (h *MailHandler) DoSend(c *gin.Context) {
@@ -172,17 +258,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
cc := c.PostForm("cc")
if to == "" {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "请输入收件人",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody))
return
}
@@ -192,38 +268,51 @@ func (h *MailHandler) DoSend(c *gin.Context) {
if multipartErr == nil {
files := form.File["attachments"]
if len(files) > 0 {
// Check attachment quota before saving
user, _ := h.stores.Users.GetByID(userID)
if user != nil {
// 原子预扣附件配额(单条 SQLused_bytes + n <= quota_bytes 才生效),
// 防止并发提交绕过配额检查(TOCTOU)。后续保存失败会补偿回退。
var totalNewSize int64
for _, file := range files {
totalNewSize += file.Size
}
if user.UsedBytes+totalNewSize > user.QuotaBytes {
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
if err != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody))
return
}
if !reserved {
user, _ := h.stores.Users.GetByID(userID)
usedBytes, quotaBytes := currentUser.UsedBytes, currentUser.QuotaBytes
if user != nil {
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
}
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(user.UsedBytes), formatBytes(user.QuotaBytes)),
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": user.UsedBytes,
"quotaBytes": user.QuotaBytes,
"usedBytes": usedBytes,
"quotaBytes": quotaBytes,
"folders": h.foldersFor(userID),
})
return
}
}
// Read all attachment files into memory once (used for both the
// MIME message body and the stored attachment records).
// 读取失败的文件回退已预扣的配额。
for _, file := range files {
f, err := file.Open()
if err != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
continue
}
buf, readErr := io.ReadAll(f)
f.Close()
if readErr != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
continue
}
@@ -245,17 +334,166 @@ func (h *MailHandler) DoSend(c *gin.Context) {
// Build the email content
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
messageID, rawMessage := buildOutgoingMessage(fromAddr, to, cc, subject, body, htmlBody, attachments)
now := time.Now()
messageID := fmt.Sprintf("<%s@mail_go>", uuid.New().String())
// Construct the raw email message
var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: %s\r\n", fromAddr))
sb.WriteString(fmt.Sprintf("To: %s\r\n", to))
if cc != "" {
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", cc))
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
localUsers := make([]*db.User, 0, len(allRecipients))
var externalRecipients []string
for _, rcpt := range allRecipients {
user, err := h.stores.Users.GetByEmail(rcpt)
if err != nil {
externalRecipients = append(externalRecipients, rcpt)
continue
}
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
localUsers = append(localUsers, user)
}
// Queue external recipients for outbound delivery first, so that
// failures (rate limit, invalid address, disabled outbound) abort
// before any local copies are created.
if len(externalRecipients) > 0 {
ob := h.outbound
if ob == nil || !ob.Enabled() {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody))
return
}
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
return
}
for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
return
}
}
}
for _, rcptUser := range localUsers {
inboxMsg := &db.Message{
UserID: rcptUser.ID,
MessageID: messageID,
Folder: "INBOX",
FromAddr: fromAddr,
ToAddr: to,
CcAddr: cc,
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: rawMessage,
Date: now,
IsRead: false,
}
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
return
}
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
if h.pusher != nil {
h.pusher.PushNewMessage(rcptUser.Username+"@"+rcptUser.Domain.Name, inboxMsg)
}
}
// Save to Sent folder
msg := &db.Message{
UserID: userID,
MessageID: messageID,
Folder: "Sent",
FromAddr: fromAddr,
ToAddr: to,
CcAddr: cc,
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: rawMessage,
Date: now,
IsRead: true,
}
if createErr := h.stores.Mails.Create(msg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
return
}
// Save attachment records linked to the Sent copy (bytes were already
// read during message construction). 配额已在前面原子预扣,
// 保存/落库失败的附件需要补偿回退。
for _, att := range attachments {
relPath, err := h.storage.Save(att.filename, att.data)
if err != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -int64(len(att.data)))
continue
}
attRecord := &db.Attachment{
MessageID: msg.ID,
FileName: att.filename,
FilePath: relPath,
ContentType: att.contentType,
FileSize: int64(len(att.data)),
}
if err := h.stores.Attachments.Create(attRecord); err != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -attRecord.FileSize)
continue
}
}
c.Redirect(http.StatusFound, "/sent")
}
func parseAddressInput(input string) []string {
parts := strings.FieldsFunc(input, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
addresses := make([]string, 0, len(parts))
for _, part := range parts {
addr := strings.TrimSpace(part)
if addr != "" {
addresses = append(addresses, addr)
}
}
return addresses
}
// sanitizeHeaderField removes CR/LF/NUL from a value destined for an RFC 5322
// message header, preventing header injection (e.g. smuggling a Bcc or
// Reply-To header via a crafted subject or address list).
func sanitizeHeaderField(s string) string {
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, "\x00", "")
return s
}
// encodeSubject prepares a subject for safe inclusion as a message header:
// header injection characters are stripped and non-ASCII content is encoded
// per RFC 2047.
func encodeSubject(s string) string {
return mime.QEncoding.Encode("utf-8", sanitizeHeaderField(s))
}
// formatContentDisposition builds a Content-Disposition header value for the
// given filename, quoting/encoding it per RFC 2183/2231 (also neutralizes
// CR/LF injection through crafted filenames).
func formatContentDisposition(filename string) string {
return mime.FormatMediaType("attachment", map[string]string{"filename": filename})
}
// buildOutgoingMessage constructs the raw RFC 5322 message for the web
// compose form and returns its Message-ID. All header values derived from
// user input are sanitized to prevent CRLF header injection.
func buildOutgoingMessage(from, to, cc, subject, body, htmlBody string, attachments []pendingAttachment) (messageID, raw string) {
now := time.Now()
messageID = fmt.Sprintf("<%s@mail_go>", uuid.New().String())
var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderField(from)))
sb.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderField(to)))
if cc != "" {
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", sanitizeHeaderField(cc)))
}
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", encodeSubject(subject)))
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
sb.WriteString("MIME-Version: 1.0\r\n")
@@ -291,10 +529,11 @@ func (h *MailHandler) DoSend(c *gin.Context) {
// Append attachment parts to the multipart/mixed container.
for _, att := range attachments {
contentType := mime.FormatMediaType(att.contentType, map[string]string{"name": att.filename})
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
sb.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", att.contentType, att.filename))
sb.WriteString(fmt.Sprintf("Content-Type: %s\r\n", contentType))
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
sb.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", att.filename))
sb.WriteString(fmt.Sprintf("Content-Disposition: %s\r\n\r\n", formatContentDisposition(att.filename)))
sb.WriteString(base64LineWrap(att.data))
sb.WriteString("\r\n")
}
@@ -302,166 +541,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
}
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
localUsers := make([]*db.User, 0, len(allRecipients))
var externalRecipients []string
for _, rcpt := range allRecipients {
user, err := h.stores.Users.GetByEmail(rcpt)
if err != nil {
externalRecipients = append(externalRecipients, rcpt)
continue
}
localUsers = append(localUsers, user)
}
// Queue external recipients for outbound delivery first, so that
// failures (rate limit, invalid address, disabled outbound) abort
// before any local copies are created.
if len(externalRecipients) > 0 {
ob := h.outbound
if ob == nil || !ob.Enabled() {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "外部投递未启用",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(sb.String())); err != nil {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
}
}
for _, rcptUser := range localUsers {
inboxMsg := &db.Message{
UserID: rcptUser.ID,
MessageID: messageID,
Folder: "INBOX",
FromAddr: fromAddr,
ToAddr: to,
CcAddr: cc,
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: sb.String(),
Date: now,
IsRead: false,
}
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("投递邮件失败: %v", createErr),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
}
// Save to Sent folder
msg := &db.Message{
UserID: userID,
MessageID: messageID,
Folder: "Sent",
FromAddr: fromAddr,
ToAddr: to,
CcAddr: cc,
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: sb.String(),
Date: now,
IsRead: true,
}
if createErr := h.stores.Mails.Create(msg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("保存邮件失败: %v", createErr),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
// Save attachment records linked to the Sent copy (bytes were already
// read during message construction).
for _, att := range attachments {
relPath, err := h.storage.Save(att.filename, att.data)
if err != nil {
continue
}
attRecord := &db.Attachment{
MessageID: msg.ID,
FileName: att.filename,
FilePath: relPath,
ContentType: att.contentType,
FileSize: int64(len(att.data)),
}
_ = h.stores.Attachments.Create(attRecord)
// Update user used bytes
_ = h.stores.Users.UpdateUsedBytes(userID, attRecord.FileSize)
}
c.Redirect(http.StatusFound, "/sent")
}
func parseAddressInput(input string) []string {
parts := strings.FieldsFunc(input, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
addresses := make([]string, 0, len(parts))
for _, part := range parts {
addr := strings.TrimSpace(part)
if addr != "" {
addresses = append(addresses, addr)
}
}
return addresses
return messageID, sb.String()
}
// mimeTypes maps common file extensions to MIME types.
@@ -483,40 +563,18 @@ var mimeTypes = map[string]string{
".csv": "text/csv",
}
// Sent renders the sent mail folder page.
func (h *MailHandler) Sent(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Sent", page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载发件箱失败: %v", err)
return
// safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //),
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
// 调用方应回退到默认路径。
func safeRedirectPath(referer string) string {
if referer == "" || !strings.HasPrefix(referer, "/") || strings.HasPrefix(referer, "//") {
return ""
}
return referer
}
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "sent", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": "Sent",
"activeFolder": "sent",
})
}
// Delete removes a message by ID after verifying ownership.
// Delete 删除邮件(IMAP 语义):非 Trash 文件夹 → 移入 Trash
// 已在 Trash → 彻底删除。
func (h *MailHandler) Delete(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -530,24 +588,128 @@ func (h *MailHandler) Delete(c *gin.Context) {
c.String(http.StatusForbidden, "禁止访问")
return
}
userEmail := userEmailOf(c)
// Delete attachments on disk and in DB, and decrease UsedBytes
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
for _, att := range attachments {
_ = h.storage.Delete(att.FilePath)
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
if msg.Folder == "Trash" {
// 垃圾箱中删除 = 彻底删除
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
h.purgeMessages(userID, []db.Message{*msg})
if h.pusher != nil && userEmail != "" {
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
}
} else {
// 其余文件夹删除 = 移入垃圾箱(与 IMAP MOVE 同源语义)
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil {
log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "删除失败")
return
}
if h.pusher != nil && userEmail != "" {
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
h.pusher.PushNewMessage(userEmail, &db.Message{UserID: userID, Folder: "Trash"})
}
}
_ = h.stores.Attachments.DeleteByMessage(uint(id))
_ = h.stores.Mails.Delete(uint(id))
// Redirect back based on the folder
referer := c.GetHeader("Referer")
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/inbox"
referer = "/folder/" + msg.Folder
}
c.Redirect(http.StatusFound, referer)
}
// Restore 把垃圾箱中的邮件恢复到收件箱。
func (h *MailHandler) Restore(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
if msg.Folder != "Trash" {
c.Redirect(http.StatusFound, "/folder/"+msg.Folder)
return
}
seq := h.seqOfFolder(userID, "Trash", msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil {
log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "恢复失败")
return
}
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, "Trash", []uint32{seq})
h.pusher.PushNewMessage(email, &db.Message{UserID: userID, Folder: "INBOX"})
}
}
c.Redirect(http.StatusFound, "/folder/Trash")
}
// Purge 彻底删除一封邮件(任意文件夹)。
func (h *MailHandler) Purge(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
h.purgeMessages(userID, []db.Message{*msg})
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, msg.Folder, []uint32{seq})
}
}
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/folder/" + msg.Folder
}
c.Redirect(http.StatusFound, referer)
}
// EmptyFolder 清空文件夹(永久删除其中全部邮件)。
func (h *MailHandler) EmptyFolder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
return
}
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil {
c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err)
return
}
seqs := make([]uint32, 0, len(msgs))
for i := range msgs {
seqs = append(seqs, uint32(i+1))
}
h.purgeMessages(userID, msgs)
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, name, seqs)
}
}
c.Redirect(http.StatusFound, "/folder/"+name)
}
// MarkRead marks a message as read.
func (h *MailHandler) MarkRead(c *gin.Context) {
userID := c.GetUint("userID")
@@ -565,9 +727,22 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
_ = h.stores.Mails.MarkRead(uint(id))
referer := c.GetHeader("Referer")
// 已读变化 → 推送给该用户的其他 IMAP 客户端
if h.pusher != nil {
msg.IsRead = true
userEmail := ""
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
userEmail = u.Username + "@" + u.Domain.Name
}
}
h.pusher.PushFlagsChanged(userEmail, msg.Folder, msg)
}
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/inbox"
referer = "/folder/INBOX"
}
c.Redirect(http.StatusFound, referer)
}
@@ -600,7 +775,7 @@ func (h *MailHandler) DownloadAttachment(c *gin.Context) {
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
c.Header("Content-Disposition", formatContentDisposition(att.FileName))
c.Data(http.StatusOK, att.ContentType, data)
}
@@ -618,50 +793,31 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int {
return page
}
// Drafts renders the drafts folder page.
func (h *MailHandler) Drafts(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err)
return
}
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "drafts", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": "Drafts",
"activeFolder": "drafts",
})
}
// Settings renders the user settings page.
func (h *MailHandler) Settings(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
userID := c.GetUint("userID")
c.HTML(200, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "",
"success": "",
"mustChange": c.Query("force") == "1",
"folders": h.foldersFor(userID),
})
}
// settingsData builds the shared template context for the settings page.
func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H {
return gin.H{
"currentUser": user,
"activeFolder": "settings",
"error": errMsg,
"success": success,
"folders": h.foldersFor(userID),
}
}
// UpdateSettings handles the password change form.
func (h *MailHandler) UpdateSettings(c *gin.Context) {
userID := c.GetUint("userID")
@@ -674,76 +830,32 @@ func (h *MailHandler) UpdateSettings(c *gin.Context) {
// Verify old password
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
c.HTML(http.StatusBadRequest, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "当前密码不正确",
"success": "",
})
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", ""))
return
}
if newPassword == "" {
c.HTML(http.StatusBadRequest, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "新密码不能为空",
"success": "",
})
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", ""))
return
}
if newPassword != confirmPassword {
c.HTML(http.StatusBadRequest, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "两次输入的密码不一致",
"success": "",
})
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", ""))
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusInternalServerError, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "密码加密失败",
"success": "",
})
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", ""))
return
}
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
c.HTML(http.StatusInternalServerError, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "密码更新失败",
"success": "",
})
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", ""))
return
}
c.HTML(http.StatusOK, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "",
"success": "密码修改成功",
})
}
// resolveActiveFolder maps a folder name to a sidebar active state key.
func resolveActiveFolder(folder string) string {
switch folder {
case "INBOX":
return "inbox"
case "Sent":
return "sent"
case "Drafts":
return "drafts"
default:
return folder
}
c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功"))
}
// formatBytes converts a file size in bytes to a human-readable string.
+188
View File
@@ -0,0 +1,188 @@
package handlers
// Web 删除语义回归测试:删除=移入垃圾箱(IMAP 语义)、垃圾箱中删除=彻底
// 删除、恢复=回到收件箱、清空=永久删除。文件夹操作全部经由 MailboxService
// (与 IMAP 会话共用同一份实现)。
import (
"html/template"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newMailTestHandler(t *testing.T) (*MailHandler, *store.Stores) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
t.Fatal(err)
}
stores := store.NewStores(gdb)
if err := stores.Users.Create(&db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}, DomainID: 1}); err != nil {
t.Fatal(err)
}
return NewMailHandler(stores, nil, nil, imap_server.NewMailboxService(stores), nil), stores
}
// newMailTestRouter 注册删除/恢复/清空路由并注入认证上下文。
func newMailTestRouter(h *MailHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// Folder 页渲染需要模板(与 oauth2 测试共用同一份测试函数表)
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
r.SetHTMLTemplate(tmpl)
r.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Set("currentUser", &db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}})
c.Next()
})
r.POST("/mail/delete/:id", h.Delete)
r.POST("/mail/restore/:id", h.Restore)
r.POST("/mail/purge/:id", h.Purge)
r.POST("/folder/:name/empty", h.EmptyFolder)
r.GET("/folder/:name", h.Folder)
return r
}
func seedWebMsg(t *testing.T, stores *store.Stores, folder string) *db.Message {
t.Helper()
msg := &db.Message{
UserID: 1,
Folder: folder,
FromAddr: "sender@other.com",
ToAddr: "alice@example.com",
Subject: "测试邮件",
Date: time.Now(),
}
if err := stores.Mails.Create(msg); err != nil {
t.Fatal(err)
}
return msg
}
func msgFolder(t *testing.T, stores *store.Stores, id uint) (string, bool) {
t.Helper()
msg, err := stores.Mails.GetByID(id)
if err != nil {
return "", false
}
return msg.Folder, true
}
func TestWebDeleteMovesToTrash(t *testing.T) {
h, stores := newMailTestHandler(t)
msg := seedWebMsg(t, stores, "INBOX")
r := newMailTestRouter(h)
// 收件箱删除 → 移入垃圾箱(非物理删除)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
req.Header.Set("Referer", "/folder/INBOX")
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("delete status = %d, want 302", w.Code)
}
folder, ok := msgFolder(t, stores, msg.ID)
if !ok || folder != "Trash" {
t.Fatalf("deleted msg folder = %q, want Trash", folder)
}
// 垃圾箱中再删除 → 彻底删除
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
req2.Header.Set("Referer", "/folder/Trash")
r.ServeHTTP(w2, req2)
if _, ok := msgFolder(t, stores, msg.ID); ok {
t.Fatal("trash delete should purge the message")
}
}
func TestWebRestoreToInbox(t *testing.T) {
h, stores := newMailTestHandler(t)
msg := seedWebMsg(t, stores, "Trash")
r := newMailTestRouter(h)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mail/restore/"+itoa(msg.ID), nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("restore status = %d, want 302", w.Code)
}
folder, ok := msgFolder(t, stores, msg.ID)
if !ok || folder != "INBOX" {
t.Fatalf("restored msg folder = %q, want INBOX", folder)
}
}
func TestWebEmptyFolderPurgesAll(t *testing.T) {
h, stores := newMailTestHandler(t)
seedWebMsg(t, stores, "Trash")
seedWebMsg(t, stores, "Trash")
r := newMailTestRouter(h)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/folder/Trash/empty", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("empty status = %d, want 302", w.Code)
}
count, err := stores.Mails.CountByUserAndFolder(1, "Trash")
if err != nil || count != 0 {
t.Fatalf("Trash count after empty = %d, want 0", count)
}
}
func TestWebFolderPageListsDynamicFolders(t *testing.T) {
h, stores := newMailTestHandler(t)
seedWebMsg(t, stores, "INBOX")
r := newMailTestRouter(h)
// IMAP CREATE 语义创建的自定义文件夹(经同一 MailboxService
svc := imap_server.NewMailboxService(stores)
if err := svc.Create(1, "工作"); err != nil {
t.Fatalf("create custom mailbox: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/folder/工作", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("custom folder page status = %d, want 200", w.Code)
}
// 不存在的文件夹 → 404
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/folder/不存在", nil)
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusNotFound {
t.Fatalf("missing folder status = %d, want 404", w2.Code)
}
}
func itoa(n uint) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
@@ -0,0 +1,129 @@
package handlers
// P1 #4 回归测试:Web 写信的邮件头不可被 CRLF 注入。
// 旧实现把 to/cc/subject/附件名原样拼进 MIME 头,攻击者可通过
// subject 注入 Reply-To/Bcc 等任意头用于钓鱼。
import (
"strings"
"testing"
)
func TestSanitizeHeaderField(t *testing.T) {
cases := []struct {
in, want string
}{
{"normal value", "normal value"},
{"with\r\ninjected: header", "withinjected: header"},
{"lf\nonly", "lfonly"},
{"cr\ronly", "cronly"},
{"nul\x00byte", "nulbyte"},
{"mixed\r\n\x00all", "mixedall"},
{"中文主题", "中文主题"},
}
for _, tc := range cases {
if got := sanitizeHeaderField(tc.in); got != tc.want {
t.Errorf("sanitizeHeaderField(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestBuildOutgoingMessageBlocksHeaderInjection(t *testing.T) {
_, raw := buildOutgoingMessage(
"alice@example.com",
"bob@example.com\r\nBcc: victim@evil.com",
"carol@example.com\r\nReply-To: attacker@evil.com",
"Hi\r\nBcc: victim@evil.com\r\nReply-To: attacker@evil.com",
"body",
"",
nil,
)
// 注入的头不允许以独立头形式出现
for _, injected := range []string{
"Bcc:", "Reply-To:",
} {
if strings.Contains(raw, "\r\n"+injected) || strings.HasPrefix(raw, injected) {
t.Fatalf("injected header %q found in message:\n%s", injected, raw)
}
}
// 注入的邮箱地址本身允许以折叠形式残留在原头值中,
// 但绝不能成为独立的一行头。
lines := strings.Split(raw, "\r\n")
for _, line := range lines[1:] { // 跳过 From
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Bcc:") || strings.HasPrefix(trimmed, "Reply-To:") {
t.Fatalf("injected header line %q found in message:\n%s", line, raw)
}
}
}
func TestBuildOutgoingMessageAttFilenameInjection(t *testing.T) {
atts := []pendingAttachment{
{filename: "evil.png\r\nBcc: victim@evil.com", contentType: "image/png", data: []byte("x")},
{filename: `quote".png`, contentType: "image/png", data: []byte("x")},
}
_, raw := buildOutgoingMessage("a@b.com", "c@d.com", "", "t", "body", "", atts)
lines := strings.Split(raw, "\r\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Bcc:") {
t.Fatalf("filename header injection found: %q\nmessage:\n%s", line, raw)
}
}
// 含引号/换行的文件名必须被正确编码,不能破坏头结构
if !strings.Contains(raw, "Content-Disposition: attachment;") {
t.Fatalf("Content-Disposition missing in message:\n%s", raw)
}
}
func TestBuildOutgoingMessageEncodesNonASCIISubject(t *testing.T) {
_, raw := buildOutgoingMessage("a@b.com", "c@d.com", "", "中文主题测试", "body", "", nil)
// 非 ASCII 主题应按 RFC 2047 编码为 =?utf-8?...?= 形式
if !strings.Contains(raw, "Subject: =?utf-8?") && !strings.Contains(raw, "Subject: =?UTF-8?") {
t.Fatalf("non-ASCII subject should be RFC 2047 encoded, got:\n%s", raw)
}
// 头部不应再包含裸中文(应被编码)
for _, line := range strings.Split(raw, "\r\n") {
if strings.HasPrefix(line, "Subject:") && strings.ContainsAny(line, "中文测试") {
t.Fatalf("raw non-ASCII in Subject header: %q", line)
}
}
}
func TestFormatContentDisposition(t *testing.T) {
if got := formatContentDisposition("report.pdf"); got != "attachment; filename=report.pdf" {
t.Fatalf("simple filename: got %q", got)
}
// 特殊字符需要安全编码而不是原样嵌入
got := formatContentDisposition("a\"b\\c\r\nd.png")
if strings.ContainsAny(got, "\r\n") {
t.Fatalf("CRLF leaked into Content-Disposition: %q", got)
}
}
// P3 #12Referer 开放重定向防护。
func TestSafeRedirectPath(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", ""},
{"/inbox", "/inbox"},
{"/mail/delete/5", "/mail/delete/5"},
{"/sent?page=2", "/sent?page=2"},
{"https://evil.com/", ""},
{"//evil.com/inbox", ""},
{"http://mail.lmve.net/inbox", ""},
{"javascript:alert(1)", ""},
{"/\\evil.com", "/\\evil.com"}, // 浏览器对 /\\ 的处理不一致,但不涉及外部协议跳转
}
for _, tc := range cases {
if got := safeRedirectPath(tc.in); got != tc.want {
t.Errorf("safeRedirectPath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+216
View File
@@ -0,0 +1,216 @@
package handlers
// P1 #2 回归测试:OAuth2 state 必须随机、回调必须校验。
// 旧实现 state 为硬编码常量且回调完全不校验(登录 CSRF / 授权码注入)。
import (
"encoding/json"
"html/template"
"math"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/config"
"mail_go/internal/mailutil"
"github.com/gin-gonic/gin"
)
// testTemplateFuncs 提供模板解析所需的自定义函数(与 web 包的
// templateFuncs 等价,但 handlers 包无法反向依赖 web 包)。
func testTemplateFuncs() template.FuncMap {
return template.FuncMap{
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int { return a / b },
"mod": func(a, b int) int { return a % b },
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r },
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
"jsonify": func(v interface{}) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
},
"formatBytes": func(b int64) string {
return "1 KB"
},
"decodeHeader": mailutil.DecodeRFC2047,
"mailName": func(s string) string { return s },
"mailEmail": func(s string) string { return s },
"initial": func(s string) string { return "?" },
"truncate": func(s string, n int) string { return s },
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") },
"localTime": func(t time.Time) time.Time { return t },
"time12": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") },
"time12m": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
"avatarStyle": func(s string) string { return "background:#eee;color:#333" },
"urlPath": func(s string) string { return url.PathEscape(s) },
"folderLabel": func(s string) string { return s },
}
}
func newOAuth2TestContext(t *testing.T) (*gin.Context, *AuthHandler, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, engine := gin.CreateTestContext(w)
// 回调的错误分支渲染 login 模板,需要加载模板及自定义函数
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
engine.SetHTMLTemplate(tmpl)
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2", nil)
authCfg := config.AuthConfig{
OAuth2Enabled: true,
// 使用本地拒绝连接的地址作为 provider,token 交换快速失败,
// 测试不依赖外部网络。
OAuth2Provider: "127.0.0.1:1",
OAuth2ClientID: "test-client-id",
OAuth2ClientSecret: "test-client-secret",
OAuth2RedirectURL: "https://mail.example.com/auth/oauth2/callback",
}
h := NewAuthHandler(nil, authCfg, config.BanConfig{MaxFailAttempts: 100})
return c, h, w
}
func TestRandomOAuth2State(t *testing.T) {
s1, err := randomOAuth2State()
if err != nil {
t.Fatalf("randomOAuth2State() error: %v", err)
}
if len(s1) != oauth2StateRandLen*2 {
t.Fatalf("state length = %d, want %d (hex)", len(s1), oauth2StateRandLen*2)
}
s2, _ := randomOAuth2State()
if s1 == s2 {
t.Fatal("state must be unique per request")
}
if s1 == "mailgo_oauth2_state" {
t.Fatal("state must not be the old hardcoded constant")
}
}
func TestOAuth2StartSetsRandomStateCookie(t *testing.T) {
c, h, w := newOAuth2TestContext(t)
h.OAuth2Start(c)
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "state=") {
t.Fatalf("redirect URL should carry state: %s", loc)
}
// state cookie 必须存在且与 URL 中的一致
cookies := w.Result().Cookies()
var stateVal string
found := false
for _, ck := range cookies {
if ck.Name == oauth2StateCookie {
found = true
stateVal = ck.Value
if !ck.HttpOnly {
t.Error("state cookie must be HttpOnly")
}
if !ck.Secure {
t.Error("state cookie must be Secure")
}
if ck.MaxAge <= 0 || ck.MaxAge > oauth2StateMaxAge {
t.Errorf("state cookie MaxAge = %d, want in (0, %d]", ck.MaxAge, oauth2StateMaxAge)
}
}
}
if !found {
t.Fatal("OAuth2Start should set state cookie")
}
u, err := url.Parse(loc)
if err != nil {
t.Fatalf("parse location: %v", err)
}
if u.Query().Get("state") != stateVal {
t.Fatalf("cookie state %q != URL state %q", stateVal, u.Query().Get("state"))
}
// 两次发起的 state 不同
c2, h2, w2 := newOAuth2TestContext(t)
h2.OAuth2Start(c2)
u2, _ := url.Parse(w2.Header().Get("Location"))
if u2.Query().Get("state") == stateVal {
t.Fatal("state must differ between sessions")
}
}
func TestOAuth2CallbackRejectsMissingOrMismatchedState(t *testing.T) {
cases := []struct {
name string
cookieState string
queryState string
}{
{"no cookie", "", "abc"},
{"no query state", "abc", ""},
{"mismatch", "abc", "xyz"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, h, w := newOAuth2TestContext(t)
q := url.Values{}
q.Set("code", "test-code")
if tc.queryState != "" {
q.Set("state", tc.queryState)
}
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2/callback?"+q.Encode(), nil)
if tc.cookieState != "" {
c.Request.AddCookie(&http.Cookie{Name: oauth2StateCookie, Value: tc.cookieState})
}
h.OAuth2Callback(c)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
})
}
}
func TestOAuth2CallbackAcceptsValidState(t *testing.T) {
// 模拟完整流程:Start 下发 state -> Callback 带回同一 state。
// state 校验通过后应进入后续流程(本测试无真实 IdP,
// code 交换会失败并渲染登录错误页,但这证明 state 关卡已通过)。
c, h, w := newOAuth2TestContext(t)
h.OAuth2Start(c)
var stateVal string
for _, ck := range w.Result().Cookies() {
if ck.Name == oauth2StateCookie {
stateVal = ck.Value
}
}
w2 := httptest.NewRecorder()
c2, engine2 := gin.CreateTestContext(w2)
tmpl2 := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
engine2.SetHTMLTemplate(tmpl2)
q := url.Values{}
q.Set("code", "test-code")
q.Set("state", stateVal)
c2.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2/callback?"+q.Encode(), nil)
c2.Request.AddCookie(&http.Cookie{Name: oauth2StateCookie, Value: stateVal})
h.OAuth2Callback(c2)
// state 校验失败返回 403;此处应为非 403(进入 token 交换失败分支)
if w2.Code == http.StatusForbidden {
t.Fatalf("valid state was rejected")
}
if !strings.Contains(w2.Body.String(), "OAuth2") {
body := w2.Body.String()
if len(body) > 200 {
body = body[:200]
}
t.Fatalf("expected OAuth2 error page after state check passed, body: %s", body)
}
}
+43
View File
@@ -0,0 +1,43 @@
package web
// P3 #14jsonify 模板函数在 <script> 上下文中必须能阻止
// </script> 逃逸(encoding/json 默认转义 < > &)。
import (
"html/template"
"strings"
"testing"
)
func TestJsonifyEscapesScriptBreakout(t *testing.T) {
jsonify, ok := templateFuncs()["jsonify"].(func(interface{}) template.JS)
if !ok {
t.Fatal("template funcs must include jsonify")
}
payloads := []string{
`x</script><script>alert(1)</script>`,
`"><img src=x onerror=alert(1)>`,
"line1\nline2\ttab",
"中文内容",
`quill "quotes" 'single'`,
}
for _, p := range payloads {
out := jsonify(p)
if !strings.HasPrefix(string(out), `"`) || !strings.HasSuffix(string(out), `"`) {
t.Errorf("jsonify(%q) = %s, want a quoted JS string literal", p, out)
}
if strings.Contains(string(out), "</script>") || strings.Contains(string(out), "</SCRIPT>") {
t.Errorf("jsonify(%q) must not emit raw </script>: %s", p, out)
}
if strings.ContainsAny(string(out), "\r\n") {
t.Errorf("jsonify(%q) must escape control chars: %q", p, out)
}
}
// 特殊值:nil -> null
out := jsonify(nil)
if string(out) != "null" {
t.Errorf("jsonify(nil) = %s, want null", out)
}
}
+52
View File
@@ -1,12 +1,40 @@
package middleware
import (
"time"
"mail_go/internal/store"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// sessionAbsoluteMaxAge 会话绝对过期时间:超过后强制重新登录。
sessionAbsoluteMaxAge = 7 * 24 * time.Hour
// sessionSlidingRefresh 滑动续期阈值:距上次刷新超过该时长则更新
// loginAt 并写回 cookie,保持活跃用户不中断(约 12 小时写回一次)。
sessionSlidingRefresh = 12 * time.Hour
)
// sessionInt64 兼容不同底层 session store 解码出的整数类型。
func sessionInt64(v interface{}) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case int:
return int64(n), true
case uint:
return int64(n), true
case uint64:
return int64(n), true
case float64:
return int64(n), true
default:
return 0, false
}
}
// AuthMiddleware checks for a valid session and loads the current user
// into the Gin context. If no valid session exists, it redirects to /login.
func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
@@ -19,6 +47,23 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
return
}
// 会话绝对过期:登录超过 7 天强制重新登录;
// 滑动续期:活跃会话每 12 小时刷新一次 loginAt。
if loginAt, ok := sessionInt64(session.Get("loginAt")); ok {
elapsed := time.Since(time.Unix(loginAt, 0))
if elapsed > sessionAbsoluteMaxAge {
session.Clear()
session.Save()
c.Redirect(302, "/login")
c.Abort()
return
}
if elapsed > sessionSlidingRefresh {
session.Set("loginAt", time.Now().Unix())
session.Save()
}
}
// userID is stored as uint in session, but sessions.Get returns interface{}
// which may be stored as int or uint depending on the underlying store.
var id uint
@@ -48,6 +93,13 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
return
}
// 首次登录/密码被重置的用户必须先修改密码才能使用其他功能
if user.MustChangePassword && c.Request.URL.Path != "/settings" && c.Request.URL.Path != "/logout" {
c.Redirect(302, "/settings?force=1")
c.Abort()
return
}
c.Set("currentUser", user)
c.Set("userID", id)
c.Next()
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// securityCSP 是本应用的基础 CSP。
//
// 说明:
// - 模板大量使用内联脚本/样式(Quill 初始化、行内事件处理、
// avatarStyle 内联 CSS),故 script-src / style-src 需要
// 'unsafe-inline'
// - 邮件正文在 srcdoc iframe 中渲染,可能引用远程图片(https:),
// 因此 img-src 放行 https,同时仍阻止 data: 以外的自定义协议;
// - frame-ancestors 'none' 与 X-Frame-Options 共同防护点击劫持;
// - connect-src 'self' / form-action 'self' 阻止页面数据外泄到
// 外部域名。
const securityCSP = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self'; object-src 'none'; base-uri 'self'; " +
"form-action 'self'; frame-ancestors 'none'"
// SecurityHeaders 为所有响应设置基础安全头:HSTS、点击劫持防护、
// MIME 嗅探防护、Referrer 策略与基础 CSP。
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
c.Header("X-Frame-Options", "DENY")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Content-Security-Policy", securityCSP)
c.Next()
}
}
+45
View File
@@ -0,0 +1,45 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// TestSecurityHeaders 验证所有基础安全响应头都存在。
func TestSecurityHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
r.ServeHTTP(w, req)
for _, h := range []string{
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Content-Security-Policy",
} {
if v := w.Header().Get(h); v == "" {
t.Errorf("missing security header %q", h)
}
}
// 关键头内容抽查
if got := w.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options = %q, want DENY", got)
}
if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") {
t.Errorf("CSP should include frame-ancestors 'none', got %q", got)
}
if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "connect-src 'self'") {
t.Errorf("CSP should include connect-src 'self', got %q", got)
}
}
+151
View File
@@ -0,0 +1,151 @@
package web
// Temporary render test used to validate the rewritten frontend templates.
// Renders every page template with realistic dummy data and writes the
// output to /tmp/mailgo_preview for visual verification.
import (
"html/template"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/internal/db"
"mail_go/internal/imap_server"
)
func TestRenderAllPages(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseGlob(filepath.Join(wd, "templates", "*.html")))
template.Must(tmpl.ParseGlob(filepath.Join(wd, "templates", "admin", "*.html")))
now := time.Now()
user := &db.User{
ID: 1,
Username: "admin",
IsAdmin: true,
Domain: db.Domain{Name: "lmve.net"},
UsedBytes: 5 * 1024 * 1024,
QuotaBytes: 5 * 1024 * 1024 * 1024,
}
messages := []db.Message{
{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= <zhangsan@lmve.net>", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署,本邮件为测试邮件。", Date: now, IsRead: false},
{ID: 2, Folder: "INBOX", FromAddr: "alice@example.com", ToAddr: "admin@lmve.net", Subject: "Re: 项目进度同步", TextBody: "好的,我们下周一上午十点开会同步一下进度。", Date: now.Add(-3 * time.Hour), IsRead: true},
{ID: 3, Folder: "INBOX", FromAddr: "=?UTF-8?B?6ZmI5rKz?= <wangwu@lmve.net>", ToAddr: "admin@lmve.net", Subject: "服务器巡检报告(8 月)", TextBody: "本月巡检完成,磁盘使用率 62%,内存使用正常。", Date: now.Add(-48 * time.Hour), IsRead: false},
{ID: 4, Folder: "INBOX", FromAddr: "bob@other.com", ToAddr: "admin@lmve.net", Subject: "Newsletter #42", TextBody: "这是本周的资讯摘要,共 5 篇文章。", Date: now.Add(-10 * 24 * time.Hour), IsRead: true},
{ID: 5, Folder: "INBOX", FromAddr: "=?UTF-8?B?6ZmI5rKz?= <wangwu@lmve.net>", ToAddr: "admin@lmve.net", Subject: "DNS 记录更新", TextBody: "已按文档更新 SPF 与 DKIM 记录,请验证。", Date: now.Add(-100 * 24 * time.Hour), IsRead: true},
}
attachments := []db.Attachment{
{ID: 1, FileName: "部署文档.pdf", FileSize: 1024 * 1024},
{ID: 2, FileName: "logo.png", FileSize: 128 * 1024},
}
folders := []imap_server.FolderInfo{
{Name: "INBOX", SpecialUse: "", Unseen: 2},
{Name: "Sent", SpecialUse: "Sent", Total: 3},
{Name: "Drafts", SpecialUse: "Drafts", Total: 1},
{Name: "Trash", SpecialUse: "Trash", Total: 5},
{Name: "工作", SpecialUse: "", Total: 4},
}
cases := []struct {
name string
data ginH
}{
{"login", ginH{"error": ""}},
{"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
{"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
{"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
{"view", ginH{
"currentUser": user, "activeFolder": "INBOX",
"message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= <zhangsan@lmve.net>", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false},
"attachments": attachments, "inTrash": false, "folders": folders,
}},
{"compose", ginH{
"currentUser": user, "activeFolder": "compose", "error": "",
"to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "",
"usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024),
"folders": folders,
}},
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
{"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}},
{"admin_bans", ginH{
"currentUser": user, "activeFolder": "bans",
"rows": []struct {
db.BanEntry
Active bool
}{
{BanEntry: db.BanEntry{IPAddress: "203.0.113.7", BanCount: 4, FailCount: 5, Reason: "第1次封禁:登录失败次数过多(第4次触发,失败5次)", ExpiresAt: now.Add(20 * time.Minute)}, Active: true},
{BanEntry: db.BanEntry{IPAddress: "203.0.113.9", BanCount: 5, FailCount: 6, Reason: "第2次封禁:邮件协议认证失败次数过多(第5次触发,失败6次)", ExpiresAt: now.Add(-24 * time.Hour)}, Active: false},
{BanEntry: db.BanEntry{IPAddress: "10.0.0.2", BanCount: 1, FailCount: 5, Reason: "", ExpiresAt: time.Time{}}, Active: false},
},
"total": 3, "page": 1, "pageSize": 20, "totalPages": 1,
}},
{"admin_protocol_logs", ginH{
"currentUser": user, "activeFolder": "protocol-logs",
"logs": []db.ProtocolLog{
{ID: 1, Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "203.0.113.7", Username: "", Success: true, FailReason: "", Detail: "MAIL FROM:<spam@evil.example> RCPT×1 本地投递1", MsgCount: 1, DurationMs: 1234, CreatedAt: now},
{ID: 2, Protocol: db.ProtocolIMAP, Port: 993, ClientIP: "203.0.113.9", Username: "admin", Success: false, FailReason: "用户名或密码错误", Detail: "LOGIN 失败", DurationMs: 88, CreatedAt: now.Add(-time.Minute)},
{ID: 3, Protocol: db.ProtocolPOP3, Port: 110, ClientIP: "10.0.0.2", Username: "alice", Success: true, FailReason: "", Detail: "USER PASS STAT RETR×3 QUIT", MsgCount: 3, DurationMs: 500, CreatedAt: now.Add(-2 * time.Minute)},
},
"total": 3, "page": 1, "pageSize": 50, "totalPages": 1,
"filter": map[string]string{"protocol": "smtp", "success": "fail", "ip": "203.0.113", "username": "", "from": "2026-08-01", "to": "2026-08-19"},
"todayStats": map[string]map[string]int{
db.ProtocolSMTP: {"success": 10, "fail": 2},
db.ProtocolIMAP: {"success": 5, "fail": 7},
db.ProtocolPOP3: {"success": 3, "fail": 4},
},
"allStats": map[string]map[string]int{
db.ProtocolSMTP: {"success": 100, "fail": 20},
db.ProtocolIMAP: {"success": 50, "fail": 70},
db.ProtocolPOP3: {"success": 30, "fail": 40},
},
"keepDays": 30,
}},
{"admin_connections", ginH{
"currentUser": user, "activeFolder": "connections",
"conns": []struct {
ID uint64
Protocol string
IP string
Port int
User string
TLS bool
Connected time.Time
LastActive time.Time
}{
{ID: 1, Protocol: "smtp", IP: "203.0.113.7", Port: 25, TLS: true, Connected: now.Add(-2 * time.Minute), LastActive: now},
{ID: 2, Protocol: "imap", IP: "203.0.113.9", Port: 993, User: "admin", TLS: true, Connected: now.Add(-30 * time.Minute), LastActive: now.Add(-10 * time.Second)},
{ID: 3, Protocol: "pop3", IP: "10.0.0.2", Port: 110, User: "alice", TLS: false, Connected: now.Add(-time.Minute), LastActive: now.Add(-30 * time.Second)},
},
"total": 3, "smtpCount": 1, "imapCount": 1, "pop3Count": 1, "now": now,
}},
}
outDir := os.Getenv("MAILGO_PREVIEW_DIR")
if outDir == "" {
outDir = filepath.Join(os.TempDir(), "mailgo_preview")
}
os.MkdirAll(outDir, 0755)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, tc.name, tc.data); err != nil {
t.Fatalf("render %s: %v", tc.name, err)
}
os.WriteFile(filepath.Join(outDir, tc.name+".html"), []byte(buf.String()), 0644)
t.Logf("%s -> %d bytes", tc.name, buf.Len())
})
}
}
// ginH mimics gin.H so the test does not need the gin dependency surface.
type ginH map[string]interface{}
+280 -21
View File
@@ -1,15 +1,24 @@
package web
import (
"encoding/json"
"fmt"
"html/template"
"io/fs"
"math"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/imap_server"
"mail_go/internal/mailutil"
"mail_go/internal/outbound"
"mail_go/internal/storage"
@@ -45,7 +54,13 @@ type WebServer struct {
storageCfg config.StorageConfig
authCfg config.AuthConfig
banCfg config.BanConfig
caddyDataDir string
outbound *outbound.Manager
hub *connhub.Hub
// staticFS 内嵌的静态资源(/static 路由),二进制自包含
staticFS http.FileSystem
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
pusher imap_server.Pusher
}
// templateFuncs returns custom template functions for rendering.
@@ -54,7 +69,9 @@ func templateFuncs() template.FuncMap {
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int { return a / b },
"div": func(a, b int64) int64 { return a / b },
// durationSeconds 将 time.Duration 转为整秒(模板中无法做类型转换)。
"durationSeconds": func(d time.Duration) int64 { return int64(d / time.Second) },
"mod": func(a, b int) int { return a % b },
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
"seq": func(n int) []int {
@@ -67,11 +84,15 @@ func templateFuncs() template.FuncMap {
"domainName": func(domainID uint, domains []interface{}) string {
return fmt.Sprintf("Domain #%d", domainID)
},
"safeHTML": func(s string) template.HTML {
return template.HTML(s)
},
"safeJS": func(s string) template.JS {
return template.JS(s)
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
// < > &\u003c 等),无法逃出 </script>,杜绝 script 注入。
"jsonify": func(v interface{}) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b)
},
"formatBytes": func(b int64) string {
return formatBytes(b)
@@ -79,22 +100,226 @@ func templateFuncs() template.FuncMap {
"decodeHeader": func(s string) string {
return mailutil.DecodeRFC2047(s)
},
// mailName 从 "Name <addr>" 中提取显示名;无显示名时退回邮箱地址。
"mailName": mailName,
// mailEmail 从 "Name <addr>" 中提取邮箱地址部分。
"mailEmail": mailEmail,
// initial 返回字符串的首字符(用于头像占位)。
"initial": initial,
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
"truncate": truncate,
// shortDate 邮件列表时间:统一 12 小时制(上午/下午)。
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
// 更早显示「2026-08-20 下午 2:35」。
"shortDate": shortDate,
// time12 完整时间(含秒),先转换为 Web 时区:
// 「2026-08-20 下午 2:35:05」。
"time12": time12,
// time12m 同上但不含秒。
"time12m": time12m,
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
"localTime": localTime,
// avatarStyle 根据字符串哈希生成头像背景/前景色。
"avatarStyle": avatarStyle,
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
"urlPath": url.PathEscape,
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。
"folderLabel": func(name string) string {
switch name {
case "INBOX":
return "收件箱"
case "Sent":
return "已发送"
case "Drafts":
return "草稿箱"
case "Trash":
return "已删除"
default:
return name
}
},
}
}
// mailName extracts the display name from an RFC 5322 address.
func mailName(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '<'); i >= 0 {
name := strings.Trim(strings.TrimSpace(s[:i]), `"' `)
if name != "" {
return name
}
if j := strings.IndexByte(s, '>'); j > i {
return s[i+1 : j]
}
}
return s
}
// mailEmail extracts the bare email address from an RFC 5322 address.
func mailEmail(s string) string {
if i := strings.IndexByte(s, '<'); i >= 0 {
if j := strings.IndexByte(s, '>'); j > i {
return s[i+1 : j]
}
}
return strings.TrimSpace(s)
}
// initial returns the first rune of a string, upper-cased.
func initial(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "?"
}
r, _ := utf8.DecodeRuneInString(s)
return strings.ToUpper(string(r))
}
// truncate collapses whitespace and cuts the string to n runes.
func truncate(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。
func periodOf(t time.Time) (string, int) {
period := "上午"
if t.Hour() >= 12 {
period = "下午"
}
h := t.Hour() % 12
if h == 0 {
h = 12
}
return period, h
}
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
// 更早 → 「2026-08-20 下午 2:35」。
func shortDate(t time.Time) string {
t = inWebTZ(t)
now := time.Now().In(t.Location())
period, h := periodOf(t)
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return clock
}
if t.Year() == now.Year() {
return fmt.Sprintf("%s %s", t.Format("01-02"), clock)
}
return fmt.Sprintf("%s %s", t.Format("2006-01-02"), clock)
}
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
// 「2026-08-20 下午 2:35:05」。
func time12(t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
}
// time12m 完整时间(12 小时制,不含秒)。
func time12m(t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
}
// webTZ 是 Web 界面显示时间使用的时区(默认 Asia/Shanghai)。
var webTZ = time.Local
// fixedTimezone 解析 "+08:00"/"UTC+8" 形式的固定偏移时区;解析失败返回 nil。
func fixedTimezone(s string) *time.Location {
s = strings.TrimSpace(s)
sign := 1
rest := s
if strings.HasPrefix(rest, "+") {
rest = rest[1:]
} else if strings.HasPrefix(rest, "-") {
sign = -1
rest = rest[1:]
}
if strings.HasPrefix(strings.ToUpper(rest), "UTC") {
rest = strings.TrimSpace(rest[3:])
}
parts := strings.SplitN(rest, ":", 2)
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || h < 0 || h > 23 {
return nil
}
m := 0
if len(parts) == 2 {
if m, err = strconv.Atoi(strings.TrimSpace(parts[1])); err != nil || m < 0 || m > 59 {
return nil
}
}
offset := sign * (h*3600 + m*60)
return time.FixedZone("UTC"+strconv.Itoa(offset/3600), offset)
}
// inWebTZ 把时间转换到 Web 展示时区。
func inWebTZ(t time.Time) time.Time {
return t.In(webTZ)
}
// localTime 是 localTime 模板函数的实现(转换到 Web 展示时区)。
func localTime(t time.Time) time.Time {
return t.In(webTZ)
}
// avatarStyle returns inline CSS colors derived from a string hash.
func avatarStyle(s string) string {
h := 0
for _, r := range s {
h = (h*31 + int(r)) % 360
}
return fmt.Sprintf("background:hsl(%d,78%%,92%%);color:hsl(%d,72%%,36%%)", h, h)
}
// NewWebServer creates a new WebServer, initializes the Gin engine,
// configures sessions, middleware, and registers all routes.
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, ob *outbound.Manager) *WebServer {
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager, hub *connhub.Hub, pusher imap_server.Pusher) (*WebServer, error) {
if err := config.ValidateSecretKey(cfg.SecretKey); err != nil {
return nil, err
}
// Web 展示时区:邮件日期库内统一 UTC 存储,界面按配置时区显示。
if cfg.Timezone != "" {
if loc, err := time.LoadLocation(cfg.Timezone); err == nil {
webTZ = loc
} else if loc2 := fixedTimezone(cfg.Timezone); loc2 != nil {
webTZ = loc2
} else {
return nil, fmt.Errorf("无效的 Web 时区配置 %q: %v", cfg.Timezone, err)
}
}
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Logger())
engine.Use(gin.Recovery())
// Session store (cookie-based)
cookieStore := cookie.NewStore([]byte("mail-go-secret-key-change-in-production"))
// 仅信任本机回环上的反向代理(Caddy/Nginx)。外部直连时
// X-Forwarded-For 不可信,防止伪造客户端 IP 绕过登录封禁或
// 恶意封禁他人 IP。gin 对 Unix socket 监听无条件信任转发头,
// 因此 socket 必须保持仅本机可达。
if err := engine.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil {
return nil, fmt.Errorf("设置可信代理失败: %w", err)
}
// Session store (cookie-based). The signing key comes from the config
// file (auto-generated random key) or the MAILGO_SECRET_KEY env var.
cookieStore := cookie.NewStore([]byte(cfg.SecretKey))
cookieStore.Options(sessions.Options{
HttpOnly: true,
SameSite: 3, // SameSiteLaxMode
SameSite: 3, // SameSiteStrictMode(比 Lax 更严格)
Secure: cfg.CookieSecure,
MaxAge: 86400,
Path: "/",
})
@@ -106,6 +331,12 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html"))
engine.SetHTMLTemplate(tmpl)
// 内嵌静态资源根目录(/static 路由数据源)
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, fmt.Errorf("加载内嵌静态资源失败: %w", err)
}
ws := &WebServer{
engine: engine,
stores: stores,
@@ -114,21 +345,32 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
storageCfg: storageCfg,
authCfg: authCfg,
banCfg: banCfg,
caddyDataDir: caddyCfg.DataDir,
outbound: ob,
hub: hub,
staticFS: http.FS(staticSub),
pusher: pusher,
}
ws.registerRoutes()
return ws
return ws, nil
}
// registerRoutes sets up all HTTP routes with their handlers and middleware.
func (ws *WebServer) registerRoutes() {
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound)
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.outbound)
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, imap_server.NewMailboxService(ws.stores), ws.pusher)
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub, webTZ)
// Apply BanMiddleware globally before public routes
ws.engine.Use(middleware.BanMiddleware(ws.stores))
// Security headers on every response
ws.engine.Use(middleware.SecurityHeaders())
// 静态资源(本地化的 Quill 编辑器等第三方前端库)。
// 内嵌于二进制:同源加载以满足 CSP script-src/style-src 'self'
// 且不依赖部署目录(install.sh 只复制二进制 + templates)。
ws.engine.StaticFS("/static", ws.staticFS)
// Public routes (no auth required)
ws.engine.GET("/login", authHandler.ShowLogin)
@@ -146,20 +388,27 @@ func (ws *WebServer) registerRoutes() {
c.Redirect(302, "/inbox")
})
// Mail routes
auth.GET("/inbox", mailHandler.Inbox)
auth.GET("/inbox/:id", mailHandler.View)
// Mail routes:通用文件夹页(文件夹目录与 IMAP LIST 同源)
auth.GET("/folder/:name", mailHandler.Folder)
auth.GET("/folder/:name/:id", mailHandler.View)
auth.POST("/folder/:name/empty", mailHandler.EmptyFolder)
auth.GET("/compose", mailHandler.Compose)
auth.POST("/compose", mailHandler.DoSend)
auth.GET("/drafts", mailHandler.Drafts)
auth.GET("/drafts/:id", mailHandler.View)
auth.GET("/sent", mailHandler.Sent)
auth.GET("/sent/:id", mailHandler.View)
auth.GET("/settings", mailHandler.Settings)
auth.POST("/settings", mailHandler.UpdateSettings)
auth.POST("/mail/delete/:id", mailHandler.Delete)
auth.POST("/mail/restore/:id", mailHandler.Restore)
auth.POST("/mail/purge/:id", mailHandler.Purge)
auth.POST("/mail/read/:id", mailHandler.MarkRead)
auth.GET("/attachment/:id", mailHandler.DownloadAttachment)
// 旧路径兼容重定向(登录跳转、书签、外部链接仍指向 /inbox 等)
auth.GET("/inbox", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX") })
auth.GET("/inbox/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX/"+c.Param("id")) })
auth.GET("/sent", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent") })
auth.GET("/sent/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent/"+c.Param("id")) })
auth.GET("/drafts", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts") })
auth.GET("/drafts/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts/"+c.Param("id")) })
}
// Admin routes (auth + admin required)
@@ -175,6 +424,7 @@ func (ws *WebServer) registerRoutes() {
admin.GET("/domains/:id/edit", adminHandler.EditDomain)
admin.POST("/domains/:id", adminHandler.UpdateDomain)
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
admin.POST("/domains/:id/fetch-caddy-cert", adminHandler.FetchCaddyCert)
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
admin.GET("/users", adminHandler.ListUsers)
admin.GET("/users/new", adminHandler.NewUser)
@@ -190,10 +440,19 @@ func (ws *WebServer) registerRoutes() {
admin.POST("/outbound/:id/cancel", adminHandler.CancelOutbound)
admin.GET("/bans", adminHandler.ListBans)
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
admin.POST("/bans/cleanup", adminHandler.CleanupBans)
admin.GET("/protocol-logs", adminHandler.ListProtocolLogs)
admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs)
admin.GET("/connections", adminHandler.ListConnections)
admin.POST("/connections/:id/disconnect", adminHandler.DisconnectConnection)
}
}
// Handler returns the underlying Gin engine as an http.Handler, useful for
// integration tests and for embedding behind a reverse proxy.
func (ws *WebServer) Handler() http.Handler {
return ws.engine
}
// Start launches the HTTP server on the configured address.
// Supports both TCP (e.g. ":8080") and Unix socket (e.g. "/run/mail_go/web.sock").
func (ws *WebServer) Start() error {
+270
View File
@@ -0,0 +1,270 @@
package web
// P0 回归测试:验证会话 cookie 由配置中的 secret_key 签名,
// 且旧版硬编码密钥(源码公开,视为已泄露)无法再伪造有效会话。
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/storage"
"mail_go/internal/store"
"github.com/gorilla/securecookie"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func chdirRepoRoot(t *testing.T) {
t.Helper()
// NewWebServer 以相对路径加载 internal/web/templates/
// 测试进程的 CWD 是 internal/web,需要切到仓库根目录。
if err := os.Chdir(filepath.Join("..", "..")); err != nil {
t.Fatalf("chdir to repo root: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(filepath.Join("internal", "web")) })
}
func newTestStores(t *testing.T) *store.Stores {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.Mailbox{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return store.NewStores(gdb)
}
func newTestWebServer(t *testing.T, secretKey string) (*WebServer, *store.Stores) {
t.Helper()
chdirRepoRoot(t)
stores := newTestStores(t)
domain := &db.Domain{Name: "example.com", SmtpPort: 25, ImapPort: 143, Pop3Port: 110}
if err := stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte("test-password-123"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if err := stores.Users.Create(&db.User{
Username: "alice",
PasswordHash: string(hash),
DomainID: domain.ID,
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
baseDir := t.TempDir()
attStorage := storage.NewAttachmentStorage(filepath.Join(baseDir, "attachments"))
cfg := config.WebConfig{Addr: "127.0.0.1:0", SecretKey: secretKey, CookieSecure: true}
ws, err := NewWebServer(cfg, stores, attStorage, config.StorageConfig{BaseDir: baseDir},
config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil, connhub.New(), nil)
if err != nil {
t.Fatalf("NewWebServer: %v", err)
}
return ws, stores
}
func TestSessionSignedWithConfiguredSecretKey(t *testing.T) {
ws, _ := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
srv := httptest.NewServer(ws.Handler())
defer srv.Close()
// 登录成功 -> 返回会话 cookie(禁用自动重定向以获取原始 302 响应)
form := url.Values{"email": {"alice@example.com"}, "password": {"test-password-123"}}
loginReq, _ := http.NewRequest(http.MethodPost, srv.URL+"/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(loginReq)
if err != nil {
t.Fatalf("login request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("login status = %d, want 302", resp.StatusCode)
}
var sessionCookie string
for _, c := range resp.Cookies() {
if c.Name == "mail_go_session" {
sessionCookie = c.Value
if !c.HttpOnly {
t.Error("session cookie must be HttpOnly")
}
if !c.Secure {
t.Error("session cookie must be Secure")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("session cookie SameSite = %v, want Strict", c.SameSite)
}
}
}
if sessionCookie == "" {
t.Fatal("login should set mail_go_session cookie")
}
// 合法会话可以访问收件箱
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: sessionCookie})
resp2, err := client.Do(req)
if err != nil {
t.Fatalf("inbox request: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("inbox with valid session: status = %d, want 200", resp2.StatusCode)
}
}
func TestLegacyHardcodedKeyCannotForgeSession(t *testing.T) {
// 服务端使用随机生成的新密钥
ws, _ := newTestWebServer(t, "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0")
srv := httptest.NewServer(ws.Handler())
defer srv.Close()
// 攻击者用旧硬编码密钥(源码中公开)伪造管理员会话
forger := securecookie.New([]byte(config.InsecureLegacySecretKey), nil)
forged, err := forger.Encode("mail_go_session", map[interface{}]interface{}{
"userID": uint(1),
"userEmail": "admin@example.com",
"isAdmin": true,
})
if err != nil {
t.Fatalf("forge cookie: %v", err)
}
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: forged})
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request with forged cookie: %v", err)
}
defer resp.Body.Close()
// 签名校验失败 -> 未认证,必须被重定向到登录页
if resp.StatusCode != http.StatusFound {
t.Fatalf("forged legacy-key session must be rejected: status = %d, want 302 redirect to /login", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/login") {
t.Fatalf("forged session should redirect to /login, got Location: %q", loc)
}
}
func TestNewWebServerRejectsBadSecretKeys(t *testing.T) {
chdirRepoRoot(t)
stores := newTestStores(t)
baseDir := t.TempDir()
attStorage := storage.NewAttachmentStorage(filepath.Join(baseDir, "attachments"))
cases := []struct {
name string
key string
}{
{"empty", ""},
{"legacy default", config.InsecureLegacySecretKey},
{"too short", "short-key"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewWebServer(config.WebConfig{Addr: "127.0.0.1:0", SecretKey: tc.key},
stores, attStorage, config.StorageConfig{BaseDir: baseDir},
config.AuthConfig{}, config.BanConfig{}, config.CaddyConfig{}, nil, connhub.New(), nil)
if err == nil {
t.Fatalf("NewWebServer should reject secret key %q", tc.key)
}
})
}
}
// encodeSessionCookie 用配置密钥伪造一个签名合法的会话 cookie。
// 仅用于测试会话治理逻辑(生产密钥不会泄露)。
func encodeSessionCookie(t *testing.T, secretKey string, values map[interface{}]interface{}) string {
t.Helper()
sc := securecookie.New([]byte(secretKey), nil)
enc, err := sc.Encode("mail_go_session", values)
if err != nil {
t.Fatalf("encode session: %v", err)
}
return enc
}
// authCookieValues 构造 AuthMiddleware 可识别的最小会话内容。
func authCookieValues(userID uint, loginAt int64) map[interface{}]interface{} {
return map[interface{}]interface{}{
"userID": userID,
"userEmail": "alice@example.com",
"isAdmin": false,
"loginAt": loginAt,
}
}
// P3 #16:会话绝对过期(7 天)后强制重新登录。
func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) {
const key = "0123456789abcdef0123456789abcdef"
ws, _ := newTestWebServer(t, key)
srv := httptest.NewServer(ws.Handler())
defer srv.Close()
expired := time.Now().Add(-8 * 24 * time.Hour).Unix()
cookie := encodeSessionCookie(t, key, authCookieValues(1, expired))
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), "/login") {
t.Fatalf("expired session should redirect to /login, got %d Location=%q",
resp.StatusCode, resp.Header.Get("Location"))
}
}
// P3 #16:未过期会话(含滑动续期窗口内)正常访问。
func TestSessionWithinExpiryWorks(t *testing.T) {
const key = "0123456789abcdef0123456789abcdef"
ws, _ := newTestWebServer(t, key)
srv := httptest.NewServer(ws.Handler())
defer srv.Close()
cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix()))
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("fresh session should access inbox, got %d", resp.StatusCode)
}
}
File diff suppressed because one or more lines are too long
+945
View File
@@ -0,0 +1,945 @@
/*!
* Quill Editor v1.3.7
* https://quilljs.com/
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/
.ql-container {
box-sizing: border-box;
font-family: Helvetica, Arial, sans-serif;
font-size: 13px;
height: 100%;
margin: 0px;
position: relative;
}
.ql-container.ql-disabled .ql-tooltip {
visibility: hidden;
}
.ql-container.ql-disabled .ql-editor ul[data-checked] > li::before {
pointer-events: none;
}
.ql-clipboard {
left: -100000px;
height: 1px;
overflow-y: hidden;
position: absolute;
top: 50%;
}
.ql-clipboard p {
margin: 0;
padding: 0;
}
.ql-editor {
box-sizing: border-box;
line-height: 1.42;
height: 100%;
outline: none;
overflow-y: auto;
padding: 12px 15px;
tab-size: 4;
-moz-tab-size: 4;
text-align: left;
white-space: pre-wrap;
word-wrap: break-word;
}
.ql-editor > * {
cursor: text;
}
.ql-editor p,
.ql-editor ol,
.ql-editor ul,
.ql-editor pre,
.ql-editor blockquote,
.ql-editor h1,
.ql-editor h2,
.ql-editor h3,
.ql-editor h4,
.ql-editor h5,
.ql-editor h6 {
margin: 0;
padding: 0;
counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol,
.ql-editor ul {
padding-left: 1.5em;
}
.ql-editor ol > li,
.ql-editor ul > li {
list-style-type: none;
}
.ql-editor ul > li::before {
content: '\2022';
}
.ql-editor ul[data-checked=true],
.ql-editor ul[data-checked=false] {
pointer-events: none;
}
.ql-editor ul[data-checked=true] > li *,
.ql-editor ul[data-checked=false] > li * {
pointer-events: all;
}
.ql-editor ul[data-checked=true] > li::before,
.ql-editor ul[data-checked=false] > li::before {
color: #777;
cursor: pointer;
pointer-events: all;
}
.ql-editor ul[data-checked=true] > li::before {
content: '\2611';
}
.ql-editor ul[data-checked=false] > li::before {
content: '\2610';
}
.ql-editor li::before {
display: inline-block;
white-space: nowrap;
width: 1.2em;
}
.ql-editor li:not(.ql-direction-rtl)::before {
margin-left: -1.5em;
margin-right: 0.3em;
text-align: right;
}
.ql-editor li.ql-direction-rtl::before {
margin-left: 0.3em;
margin-right: -1.5em;
}
.ql-editor ol li:not(.ql-direction-rtl),
.ql-editor ul li:not(.ql-direction-rtl) {
padding-left: 1.5em;
}
.ql-editor ol li.ql-direction-rtl,
.ql-editor ul li.ql-direction-rtl {
padding-right: 1.5em;
}
.ql-editor ol li {
counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
counter-increment: list-0;
}
.ql-editor ol li:before {
content: counter(list-0, decimal) '. ';
}
.ql-editor ol li.ql-indent-1 {
counter-increment: list-1;
}
.ql-editor ol li.ql-indent-1:before {
content: counter(list-1, lower-alpha) '. ';
}
.ql-editor ol li.ql-indent-1 {
counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-2 {
counter-increment: list-2;
}
.ql-editor ol li.ql-indent-2:before {
content: counter(list-2, lower-roman) '. ';
}
.ql-editor ol li.ql-indent-2 {
counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-3 {
counter-increment: list-3;
}
.ql-editor ol li.ql-indent-3:before {
content: counter(list-3, decimal) '. ';
}
.ql-editor ol li.ql-indent-3 {
counter-reset: list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-4 {
counter-increment: list-4;
}
.ql-editor ol li.ql-indent-4:before {
content: counter(list-4, lower-alpha) '. ';
}
.ql-editor ol li.ql-indent-4 {
counter-reset: list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-5 {
counter-increment: list-5;
}
.ql-editor ol li.ql-indent-5:before {
content: counter(list-5, lower-roman) '. ';
}
.ql-editor ol li.ql-indent-5 {
counter-reset: list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-6 {
counter-increment: list-6;
}
.ql-editor ol li.ql-indent-6:before {
content: counter(list-6, decimal) '. ';
}
.ql-editor ol li.ql-indent-6 {
counter-reset: list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-7 {
counter-increment: list-7;
}
.ql-editor ol li.ql-indent-7:before {
content: counter(list-7, lower-alpha) '. ';
}
.ql-editor ol li.ql-indent-7 {
counter-reset: list-8 list-9;
}
.ql-editor ol li.ql-indent-8 {
counter-increment: list-8;
}
.ql-editor ol li.ql-indent-8:before {
content: counter(list-8, lower-roman) '. ';
}
.ql-editor ol li.ql-indent-8 {
counter-reset: list-9;
}
.ql-editor ol li.ql-indent-9 {
counter-increment: list-9;
}
.ql-editor ol li.ql-indent-9:before {
content: counter(list-9, decimal) '. ';
}
.ql-editor .ql-indent-1:not(.ql-direction-rtl) {
padding-left: 3em;
}
.ql-editor li.ql-indent-1:not(.ql-direction-rtl) {
padding-left: 4.5em;
}
.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right {
padding-right: 3em;
}
.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right {
padding-right: 4.5em;
}
.ql-editor .ql-indent-2:not(.ql-direction-rtl) {
padding-left: 6em;
}
.ql-editor li.ql-indent-2:not(.ql-direction-rtl) {
padding-left: 7.5em;
}
.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right {
padding-right: 6em;
}
.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right {
padding-right: 7.5em;
}
.ql-editor .ql-indent-3:not(.ql-direction-rtl) {
padding-left: 9em;
}
.ql-editor li.ql-indent-3:not(.ql-direction-rtl) {
padding-left: 10.5em;
}
.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right {
padding-right: 9em;
}
.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right {
padding-right: 10.5em;
}
.ql-editor .ql-indent-4:not(.ql-direction-rtl) {
padding-left: 12em;
}
.ql-editor li.ql-indent-4:not(.ql-direction-rtl) {
padding-left: 13.5em;
}
.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right {
padding-right: 12em;
}
.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right {
padding-right: 13.5em;
}
.ql-editor .ql-indent-5:not(.ql-direction-rtl) {
padding-left: 15em;
}
.ql-editor li.ql-indent-5:not(.ql-direction-rtl) {
padding-left: 16.5em;
}
.ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right {
padding-right: 15em;
}
.ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right {
padding-right: 16.5em;
}
.ql-editor .ql-indent-6:not(.ql-direction-rtl) {
padding-left: 18em;
}
.ql-editor li.ql-indent-6:not(.ql-direction-rtl) {
padding-left: 19.5em;
}
.ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right {
padding-right: 18em;
}
.ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right {
padding-right: 19.5em;
}
.ql-editor .ql-indent-7:not(.ql-direction-rtl) {
padding-left: 21em;
}
.ql-editor li.ql-indent-7:not(.ql-direction-rtl) {
padding-left: 22.5em;
}
.ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right {
padding-right: 21em;
}
.ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right {
padding-right: 22.5em;
}
.ql-editor .ql-indent-8:not(.ql-direction-rtl) {
padding-left: 24em;
}
.ql-editor li.ql-indent-8:not(.ql-direction-rtl) {
padding-left: 25.5em;
}
.ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right {
padding-right: 24em;
}
.ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right {
padding-right: 25.5em;
}
.ql-editor .ql-indent-9:not(.ql-direction-rtl) {
padding-left: 27em;
}
.ql-editor li.ql-indent-9:not(.ql-direction-rtl) {
padding-left: 28.5em;
}
.ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right {
padding-right: 27em;
}
.ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right {
padding-right: 28.5em;
}
.ql-editor .ql-video {
display: block;
max-width: 100%;
}
.ql-editor .ql-video.ql-align-center {
margin: 0 auto;
}
.ql-editor .ql-video.ql-align-right {
margin: 0 0 0 auto;
}
.ql-editor .ql-bg-black {
background-color: #000;
}
.ql-editor .ql-bg-red {
background-color: #e60000;
}
.ql-editor .ql-bg-orange {
background-color: #f90;
}
.ql-editor .ql-bg-yellow {
background-color: #ff0;
}
.ql-editor .ql-bg-green {
background-color: #008a00;
}
.ql-editor .ql-bg-blue {
background-color: #06c;
}
.ql-editor .ql-bg-purple {
background-color: #93f;
}
.ql-editor .ql-color-white {
color: #fff;
}
.ql-editor .ql-color-red {
color: #e60000;
}
.ql-editor .ql-color-orange {
color: #f90;
}
.ql-editor .ql-color-yellow {
color: #ff0;
}
.ql-editor .ql-color-green {
color: #008a00;
}
.ql-editor .ql-color-blue {
color: #06c;
}
.ql-editor .ql-color-purple {
color: #93f;
}
.ql-editor .ql-font-serif {
font-family: Georgia, Times New Roman, serif;
}
.ql-editor .ql-font-monospace {
font-family: Monaco, Courier New, monospace;
}
.ql-editor .ql-size-small {
font-size: 0.75em;
}
.ql-editor .ql-size-large {
font-size: 1.5em;
}
.ql-editor .ql-size-huge {
font-size: 2.5em;
}
.ql-editor .ql-direction-rtl {
direction: rtl;
text-align: inherit;
}
.ql-editor .ql-align-center {
text-align: center;
}
.ql-editor .ql-align-justify {
text-align: justify;
}
.ql-editor .ql-align-right {
text-align: right;
}
.ql-editor.ql-blank::before {
color: rgba(0,0,0,0.6);
content: attr(data-placeholder);
font-style: italic;
left: 15px;
pointer-events: none;
position: absolute;
right: 15px;
}
.ql-snow.ql-toolbar:after,
.ql-snow .ql-toolbar:after {
clear: both;
content: '';
display: table;
}
.ql-snow.ql-toolbar button,
.ql-snow .ql-toolbar button {
background: none;
border: none;
cursor: pointer;
display: inline-block;
float: left;
height: 24px;
padding: 3px 5px;
width: 28px;
}
.ql-snow.ql-toolbar button svg,
.ql-snow .ql-toolbar button svg {
float: left;
height: 100%;
}
.ql-snow.ql-toolbar button:active:hover,
.ql-snow .ql-toolbar button:active:hover {
outline: none;
}
.ql-snow.ql-toolbar input.ql-image[type=file],
.ql-snow .ql-toolbar input.ql-image[type=file] {
display: none;
}
.ql-snow.ql-toolbar button:hover,
.ql-snow .ql-toolbar button:hover,
.ql-snow.ql-toolbar button:focus,
.ql-snow .ql-toolbar button:focus,
.ql-snow.ql-toolbar button.ql-active,
.ql-snow .ql-toolbar button.ql-active,
.ql-snow.ql-toolbar .ql-picker-label:hover,
.ql-snow .ql-toolbar .ql-picker-label:hover,
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
.ql-snow .ql-toolbar .ql-picker-label.ql-active,
.ql-snow.ql-toolbar .ql-picker-item:hover,
.ql-snow .ql-toolbar .ql-picker-item:hover,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected {
color: #06c;
}
.ql-snow.ql-toolbar button:hover .ql-fill,
.ql-snow .ql-toolbar button:hover .ql-fill,
.ql-snow.ql-toolbar button:focus .ql-fill,
.ql-snow .ql-toolbar button:focus .ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-fill,
.ql-snow .ql-toolbar button.ql-active .ql-fill,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill,
.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill {
fill: #06c;
}
.ql-snow.ql-toolbar button:hover .ql-stroke,
.ql-snow .ql-toolbar button:hover .ql-stroke,
.ql-snow.ql-toolbar button:focus .ql-stroke,
.ql-snow .ql-toolbar button:focus .ql-stroke,
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
.ql-snow .ql-toolbar button.ql-active .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
.ql-snow .ql-toolbar button:hover .ql-stroke-miter,
.ql-snow.ql-toolbar button:focus .ql-stroke-miter,
.ql-snow .ql-toolbar button:focus .ql-stroke-miter,
.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
.ql-snow .ql-toolbar button.ql-active .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter {
stroke: #06c;
}
@media (pointer: coarse) {
.ql-snow.ql-toolbar button:hover:not(.ql-active),
.ql-snow .ql-toolbar button:hover:not(.ql-active) {
color: #444;
}
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill,
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill {
fill: #444;
}
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke,
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter {
stroke: #444;
}
}
.ql-snow {
box-sizing: border-box;
}
.ql-snow * {
box-sizing: border-box;
}
.ql-snow .ql-hidden {
display: none;
}
.ql-snow .ql-out-bottom,
.ql-snow .ql-out-top {
visibility: hidden;
}
.ql-snow .ql-tooltip {
position: absolute;
transform: translateY(10px);
}
.ql-snow .ql-tooltip a {
cursor: pointer;
text-decoration: none;
}
.ql-snow .ql-tooltip.ql-flip {
transform: translateY(-10px);
}
.ql-snow .ql-formats {
display: inline-block;
vertical-align: middle;
}
.ql-snow .ql-formats:after {
clear: both;
content: '';
display: table;
}
.ql-snow .ql-stroke {
fill: none;
stroke: #444;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.ql-snow .ql-stroke-miter {
fill: none;
stroke: #444;
stroke-miterlimit: 10;
stroke-width: 2;
}
.ql-snow .ql-fill,
.ql-snow .ql-stroke.ql-fill {
fill: #444;
}
.ql-snow .ql-empty {
fill: none;
}
.ql-snow .ql-even {
fill-rule: evenodd;
}
.ql-snow .ql-thin,
.ql-snow .ql-stroke.ql-thin {
stroke-width: 1;
}
.ql-snow .ql-transparent {
opacity: 0.4;
}
.ql-snow .ql-direction svg:last-child {
display: none;
}
.ql-snow .ql-direction.ql-active svg:last-child {
display: inline;
}
.ql-snow .ql-direction.ql-active svg:first-child {
display: none;
}
.ql-snow .ql-editor h1 {
font-size: 2em;
}
.ql-snow .ql-editor h2 {
font-size: 1.5em;
}
.ql-snow .ql-editor h3 {
font-size: 1.17em;
}
.ql-snow .ql-editor h4 {
font-size: 1em;
}
.ql-snow .ql-editor h5 {
font-size: 0.83em;
}
.ql-snow .ql-editor h6 {
font-size: 0.67em;
}
.ql-snow .ql-editor a {
text-decoration: underline;
}
.ql-snow .ql-editor blockquote {
border-left: 4px solid #ccc;
margin-bottom: 5px;
margin-top: 5px;
padding-left: 16px;
}
.ql-snow .ql-editor code,
.ql-snow .ql-editor pre {
background-color: #f0f0f0;
border-radius: 3px;
}
.ql-snow .ql-editor pre {
white-space: pre-wrap;
margin-bottom: 5px;
margin-top: 5px;
padding: 5px 10px;
}
.ql-snow .ql-editor code {
font-size: 85%;
padding: 2px 4px;
}
.ql-snow .ql-editor pre.ql-syntax {
background-color: #23241f;
color: #f8f8f2;
overflow: visible;
}
.ql-snow .ql-editor img {
max-width: 100%;
}
.ql-snow .ql-picker {
color: #444;
display: inline-block;
float: left;
font-size: 14px;
font-weight: 500;
height: 24px;
position: relative;
vertical-align: middle;
}
.ql-snow .ql-picker-label {
cursor: pointer;
display: inline-block;
height: 100%;
padding-left: 8px;
padding-right: 2px;
position: relative;
width: 100%;
}
.ql-snow .ql-picker-label::before {
display: inline-block;
line-height: 22px;
}
.ql-snow .ql-picker-options {
background-color: #fff;
display: none;
min-width: 100%;
padding: 4px 8px;
position: absolute;
white-space: nowrap;
}
.ql-snow .ql-picker-options .ql-picker-item {
cursor: pointer;
display: block;
padding-bottom: 5px;
padding-top: 5px;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
color: #ccc;
z-index: 2;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill {
fill: #ccc;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
stroke: #ccc;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-options {
display: block;
margin-top: -1px;
top: 100%;
z-index: 1;
}
.ql-snow .ql-color-picker,
.ql-snow .ql-icon-picker {
width: 28px;
}
.ql-snow .ql-color-picker .ql-picker-label,
.ql-snow .ql-icon-picker .ql-picker-label {
padding: 2px 4px;
}
.ql-snow .ql-color-picker .ql-picker-label svg,
.ql-snow .ql-icon-picker .ql-picker-label svg {
right: 4px;
}
.ql-snow .ql-icon-picker .ql-picker-options {
padding: 4px 0px;
}
.ql-snow .ql-icon-picker .ql-picker-item {
height: 24px;
width: 24px;
padding: 2px 4px;
}
.ql-snow .ql-color-picker .ql-picker-options {
padding: 3px 5px;
width: 152px;
}
.ql-snow .ql-color-picker .ql-picker-item {
border: 1px solid transparent;
float: left;
height: 16px;
margin: 2px;
padding: 0px;
width: 16px;
}
.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg {
position: absolute;
margin-top: -9px;
right: 0;
top: 50%;
width: 18px;
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=''])::before {
content: attr(data-label);
}
.ql-snow .ql-picker.ql-header {
width: 98px;
}
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
content: 'Normal';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
content: 'Heading 1';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
content: 'Heading 2';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
content: 'Heading 3';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
content: 'Heading 4';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
content: 'Heading 5';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
content: 'Heading 6';
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
font-size: 2em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
font-size: 1.5em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
font-size: 1.17em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
font-size: 1em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
font-size: 0.83em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
font-size: 0.67em;
}
.ql-snow .ql-picker.ql-font {
width: 108px;
}
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
content: 'Sans Serif';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
content: 'Serif';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
content: 'Monospace';
}
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
font-family: Georgia, Times New Roman, serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
font-family: Monaco, Courier New, monospace;
}
.ql-snow .ql-picker.ql-size {
width: 98px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: 'Normal';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
content: 'Small';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
content: 'Large';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
content: 'Huge';
}
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
font-size: 10px;
}
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
font-size: 18px;
}
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
font-size: 32px;
}
.ql-snow .ql-color-picker.ql-background .ql-picker-item {
background-color: #fff;
}
.ql-snow .ql-color-picker.ql-color .ql-picker-item {
background-color: #000;
}
.ql-toolbar.ql-snow {
border: 1px solid #ccc;
box-sizing: border-box;
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
padding: 8px;
}
.ql-toolbar.ql-snow .ql-formats {
margin-right: 15px;
}
.ql-toolbar.ql-snow .ql-picker-label {
border: 1px solid transparent;
}
.ql-toolbar.ql-snow .ql-picker-options {
border: 1px solid transparent;
box-shadow: rgba(0,0,0,0.2) 0 2px 8px;
}
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label {
border-color: #ccc;
}
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
border-color: #ccc;
}
.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected,
.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover {
border-color: #000;
}
.ql-toolbar.ql-snow + .ql-container.ql-snow {
border-top: 0px;
}
.ql-snow .ql-tooltip {
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0px 0px 5px #ddd;
color: #444;
padding: 5px 12px;
white-space: nowrap;
}
.ql-snow .ql-tooltip::before {
content: "Visit URL:";
line-height: 26px;
margin-right: 8px;
}
.ql-snow .ql-tooltip input[type=text] {
display: none;
border: 1px solid #ccc;
font-size: 13px;
height: 26px;
margin: 0px;
padding: 3px 5px;
width: 170px;
}
.ql-snow .ql-tooltip a.ql-preview {
display: inline-block;
max-width: 200px;
overflow-x: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
.ql-snow .ql-tooltip a.ql-action::after {
border-right: 1px solid #ccc;
content: 'Edit';
margin-left: 16px;
padding-right: 8px;
}
.ql-snow .ql-tooltip a.ql-remove::before {
content: 'Remove';
margin-left: 8px;
}
.ql-snow .ql-tooltip a {
line-height: 26px;
}
.ql-snow .ql-tooltip.ql-editing a.ql-preview,
.ql-snow .ql-tooltip.ql-editing a.ql-remove {
display: none;
}
.ql-snow .ql-tooltip.ql-editing input[type=text] {
display: inline-block;
}
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
border-right: 0px;
content: 'Save';
padding-right: 0px;
}
.ql-snow .ql-tooltip[data-mode=link]::before {
content: "Enter link:";
}
.ql-snow .ql-tooltip[data-mode=formula]::before {
content: "Enter formula:";
}
.ql-snow .ql-tooltip[data-mode=video]::before {
content: "Enter video:";
}
.ql-snow a {
color: #06c;
}
.ql-container.ql-snow {
border: 1px solid #ccc;
}
+13
View File
@@ -0,0 +1,13 @@
package web
// 静态资源内嵌进二进制:install.sh 只部署二进制 + templates
// 此前 internal/web/static/ 未被复制导致线上 /static 全部 404。
// go:embed 使前端资源自包含,不再依赖部署目录与工作目录。
import "embed"
// staticFS 包含 internal/web/static/ 下的全部静态资源
// (目前为本地化的 Quill 编辑器 css/js)。
//
//go:embed all:static
var staticFS embed.FS
+40
View File
@@ -0,0 +1,40 @@
package web
// 回归测试:写邮件页的 Quill 编辑器必须由本服务同源提供静态资源
// CSP script-src/style-src 'self' 会拦截外部 CDN),否则正文无法输入。
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestQuillAssetsServedLocally(t *testing.T) {
ws, _ := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
srv := httptest.NewServer(ws.Handler())
defer srv.Close()
for _, path := range []string{
"/static/vendor/quill/quill.min.js",
"/static/vendor/quill/quill.snow.css",
} {
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", path, resp.StatusCode)
}
if len(body) < 1000 {
t.Fatalf("GET %s body too small (%d bytes), vendor file missing?", path, len(body))
}
if strings.HasPrefix(path, "/static/vendor/quill/quill.min.js") &&
!strings.Contains(string(body), "Quill") {
t.Fatalf("%s 不是 Quill 脚本", path)
}
}
}
+22 -14
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>IP黑名单 - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,24 +18,22 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>IP 黑名单</h2>
<form method="POST" action="/admin/bans/cleanup" style="display:inline;">
<button type="submit" class="btn btn-primary">清理过期记录</button>
</form>
<span style="color:#7f8c8d;font-size:13px;">阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)</span>
</div>
{{if not .bans}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无被封禁的 IP</p>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>IP 地址</th>
<th>状态</th>
<th>封禁次数</th>
<th>失败次数</th>
<th>原因</th>
<th>到期时间</th>
@@ -43,16 +41,24 @@
</tr>
</thead>
<tbody>
{{range .bans}}
{{range .rows}}
<tr>
<td>{{.ID}}</td>
<td>{{.IPAddress}}</td>
<td>
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">封禁中</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">已过期</span>{{end}}
</td>
<td>
{{if .BanCount}}
{{if gt .BanCount 3}}第{{sub .BanCount 3}}次封禁{{else}}仅计数{{end}}
{{else}}—{{end}}
</td>
<td>{{.FailCount}}</td>
<td>{{.Reason}}</td>
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td>
<td>{{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}</td>
<td>
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?');">
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
<button type="submit" class="btn btn-primary btn-sm">解封</button>
</form>
</td>
@@ -60,6 +66,8 @@
{{end}}
</tbody>
</table>
{{if not .rows}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无封禁记录</p>
{{end}}
</div>
{{if .totalPages}}
@@ -67,7 +75,7 @@
{{if gt .page 1}}
<a href="/admin/bans?page={{sub .page 1}}">上一页</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}} 页</span>
<span>第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
{{if lt .page .totalPages}}
<a href="/admin/bans?page={{add .page 1}}">下一页</a>
{{end}}
@@ -0,0 +1,101 @@
{{define "admin_connections"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>当前连接 - MailGo</title>
<meta http-equiv="refresh" content="5">
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>当前连接(SMTP / IMAP / POP3</h2>
<span style="color:#7f8c8d;font-size:13px;">每 5 秒自动刷新</span>
</div>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{.total}}</h3>
<p>当前连接总数</p>
</div>
<div class="stat-card">
<h3>{{.smtpCount}}</h3>
<p>SMTP</p>
</div>
<div class="stat-card">
<h3>{{.imapCount}}</h3>
<p>IMAP</p>
</div>
<div class="stat-card">
<h3>{{.pop3Count}}</h3>
<p>POP3</p>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>协议</th>
<th>来源 IP</th>
<th>端口</th>
<th>用户名</th>
<th>TLS</th>
<th>连接时间</th>
<th>时长</th>
<th>最后活跃</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .conns}}
<tr>
<td>{{.ID}}</td>
<td>
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
{{else}}<span class="badge" style="background:#16a085;color:#fff;">POP3</span>{{end}}
</td>
<td>{{.IP}}</td>
<td>{{.Port}}</td>
<td>{{if .User}}{{.User}}{{else}}—{{end}}</td>
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td>
<td>{{time12 .Connected}}</td>
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
<td>{{time12 .LastActive}}</td>
<td>
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');">
<button type="submit" class="btn btn-sm btn-danger">断开并封禁</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">当前没有活动连接</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
{{end}}
+3 -1
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>管理后台 - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,6 +18,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
+3 -1
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>DNS配置 - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,6 +18,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
+33 -2
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,12 +18,15 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
{{if .caddyMsg}}<div class="alert {{if eq .caddyMsgType "success"}}alert-success{{else}}alert-error{{end}}">{{.caddyMsg}}</div>{{end}}
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
<div class="form-group">
<label>域名</label>
@@ -63,12 +66,40 @@
<label>TLS 公钥证书 PEM</label>
<textarea name="tls_public_cert" rows="8" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" style="font-family:monospace;">{{.tlsPublicCert}}</textarea>
{{if .tlsCertConfigured}}
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ TLS 证书已配置;上传新证书后需重启服务生效。</p>
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ TLS 证书已配置;上传新证书后自动热加载生效。</p>
{{else}}
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。</p>
{{end}}
</div>
</div>
<div class="form-group" style="margin-top:4px;">
<label>从 Caddy 获取证书</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。</p>
<button type="button" class="btn" id="btn_fetch_caddy" onclick="fetchCaddyCert()" style="background:#2e86de;color:#fff;">🔒 从 Caddy 获取证书</button>
<span id="caddy_fetch_msg" style="margin-left:10px;font-size:12px;color:#7f8c8d;"></span>
<script>
async function fetchCaddyCert() {
var btn = document.getElementById('btn_fetch_caddy');
var msg = document.getElementById('caddy_fetch_msg');
btn.disabled = true;
var oldText = btn.textContent;
btn.textContent = '获取中…';
msg.textContent = '';
try {
var resp = await fetch('/admin/domains/{{.domain.ID}}/fetch-caddy-cert', { method: 'POST' });
if (resp.redirected) {
window.location.href = resp.url;
return;
}
msg.textContent = '获取失败: ' + await resp.text();
} catch (e) {
msg.textContent = '请求失败: ' + e;
}
btn.disabled = false;
btn.textContent = oldText;
}
</script>
</div>
{{end}}
<script>
function togglePorts() {
+3 -1
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>域名管理 - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,6 +18,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
+5 -3
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>查看邮件 - MailGo 管理后台</title>
{{template "styles" .}}
<style>
@@ -27,6 +27,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" class="active">所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
@@ -41,11 +43,11 @@
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
<p><strong>所属用户:</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
<p><strong>文件夹:</strong> {{.message.Folder}}</p>
<p><strong>时间:</strong> {{.message.Date.Format "2006-01-02 15:04:05"}}</p>
<p><strong>时间:</strong> {{time12 .message.Date}}</p>
</div>
<div class="mail-body">
{{if .message.HtmlBody}}
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody | safeJS}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
{{else}}
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
{{end}}
+4 -2
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>所有邮件 - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,6 +18,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
@@ -52,7 +54,7 @@
<td>{{.Subject}}</td>
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
<td>{{.Folder}}</td>
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
<td>{{time12m .Date}}</td>
</tr>
{{end}}
</tbody>
+5 -3
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>外发队列 - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,6 +18,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
@@ -80,9 +82,9 @@
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
</td>
<td>{{.Attempts}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m .NextAttemptAt}}{{else}}—{{end}}</td>
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>{{time12m .CreatedAt}}</td>
<td>
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
<form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
@@ -0,0 +1,132 @@
{{define "admin_protocol_logs"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>协议日志 - MailGo</title>
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>协议日志(SMTP / IMAP / POP3</h2>
<form method="POST" action="/admin/protocol-logs/cleanup" style="display:inline;"
onsubmit="return confirm('确认清理 {{.keepDays}} 天前的协议日志?');">
<button type="submit" class="btn btn-primary">清理旧日志</button>
</form>
</div>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{index .todayStats "smtp" "fail"}}</h3>
<p>今日 SMTP 失败</p>
</div>
<div class="stat-card">
<h3>{{index .todayStats "imap" "fail"}}</h3>
<p>今日 IMAP 失败</p>
</div>
<div class="stat-card">
<h3>{{index .todayStats "pop3" "fail"}}</h3>
<p>今日 POP3 失败</p>
</div>
<div class="stat-card">
<h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3>
<p>历史失败(全部)</p>
</div>
</div>
<div class="card">
<form method="GET" action="/admin/protocol-logs" style="margin-bottom:16px;">
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;">
<select name="protocol" style="padding:6px 10px;">
<option value="">全部协议</option>
<option value="smtp" {{if eq .filter.protocol "smtp"}}selected{{end}}>SMTP</option>
<option value="imap" {{if eq .filter.protocol "imap"}}selected{{end}}>IMAP</option>
<option value="pop3" {{if eq .filter.protocol "pop3"}}selected{{end}}>POP3</option>
</select>
<select name="success" style="padding:6px 10px;">
<option value="">全部状态</option>
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>成功</option>
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>失败</option>
</select>
<input type="text" name="ip" placeholder="来源 IP(模糊)" value="{{.filter.ip}}" style="padding:6px 10px;width:160px;">
<input type="text" name="username" placeholder="用户名(模糊)" value="{{.filter.username}}" style="padding:6px 10px;width:160px;">
<input type="date" name="from" value="{{.filter.from}}" style="padding:6px 10px;">
<span></span>
<input type="date" name="to" value="{{.filter.to}}" style="padding:6px 10px;">
<button type="submit" class="btn btn-sm btn-primary">筛选</button>
<a href="/admin/protocol-logs" class="btn btn-sm">重置</a>
</div>
</form>
<table>
<thead>
<tr>
<th>时间</th>
<th>协议</th>
<th>端口</th>
<th>来源 IP</th>
<th>用户名</th>
<th>状态</th>
<th>失败原因</th>
<th>操作摘要</th>
<th>消息数</th>
<th>时长</th>
</tr>
</thead>
<tbody>
{{range .logs}}
<tr>
<td style="white-space:nowrap;">{{time12 .CreatedAt}}</td>
<td>
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
{{else}}<span class="badge" style="background:#16a085;color:#fff;">POP3</span>{{end}}
</td>
<td>{{.Port}}</td>
<td>{{.ClientIP}}</td>
<td>{{if .Username}}{{.Username}}{{else}}—{{end}}</td>
<td>
{{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">成功</span>
{{else}}<span class="badge badge-unread">失败</span>{{end}}
</td>
<td style="max-width:200px;">{{if .FailReason}}{{.FailReason}}{{else}}—{{end}}</td>
<td style="max-width:320px;word-break:break-all;font-size:13px;color:#555;">{{.Detail}}</td>
<td>{{if .MsgCount}}{{.MsgCount}}{{else}}—{{end}}</td>
<td>{{if .DurationMs}}{{div .DurationMs 1000}}s{{else}}—{{end}}</td>
</tr>
{{else}}
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">暂无记录</td></tr>
{{end}}
</tbody>
</table>
{{if gt .totalPages 1}}
<div class="pagination">
{{if gt .page 1}}<a href="/admin/protocol-logs?page={{sub .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">上一页</a>{{end}}
<span class="current">第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
{{if lt .page .totalPages}}<a href="/admin/protocol-logs?page={{add .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">下一页</a>{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
</body>
</html>
{{end}}
+3 -1
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,6 +18,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
+3 -1
View File
@@ -3,7 +3,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>用户管理 - MailGo</title>
{{template "styles" .}}
</head>
@@ -18,6 +18,8 @@
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
+35 -12
View File
@@ -3,21 +3,40 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>访问被禁止 - MailGo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.banned-card { background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 48px; text-align: center; max-width: 480px; width: 100%; }
.banned-icon { font-size: 64px; margin-bottom: 16px; }
h1 { font-size: 24px; color: #c0392b; margin-bottom: 12px; }
p { color: #7f8c8d; line-height: 1.6; margin-bottom: 8px; }
.detail { background: #f8f9fa; border-radius: 6px; padding: 16px; margin: 16px 0; text-align: left; }
.detail-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #eee; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: linear-gradient(165deg, #fdecec 0%, #f6f8fc 55%, #eaf2ff 100%);
color: #1f2329; display: flex; justify-content: center; align-items: center; min-height: 100vh;
}
.banned-card {
background: #fff; border-radius: 14px;
box-shadow: 0 10px 40px rgba(227, 77, 89, 0.10);
padding: 48px; text-align: center; max-width: 480px; width: 100%;
}
.banned-icon {
width: 72px; height: 72px; margin: 0 auto 18px; border-radius: 50%;
background: #fde8e8; display: flex; align-items: center; justify-content: center;
font-size: 34px;
}
h1 { font-size: 22px; color: #e34d59; margin-bottom: 12px; }
p { color: #646a73; line-height: 1.7; margin-bottom: 8px; font-size: 14px; }
.detail {
background: #f8f9fb; border-radius: 10px; padding: 16px 18px;
margin: 18px 0; text-align: left;
}
.detail-row { display: flex; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid #f0f1f3; }
.detail-row:last-child { border-bottom: none; }
.detail-label { color: #7f8c8d; font-size: 13px; }
.detail-value { color: #2c3e50; font-weight: 600; font-size: 13px; }
.back-link { display: inline-block; margin-top: 20px; color: #3498db; text-decoration: none; }
.detail-label { color: #8f959e; font-size: 13px; }
.detail-value { color: #1f2329; font-weight: 600; font-size: 13px; }
.back-link {
display: inline-block; margin-top: 22px; color: #1677ff;
text-decoration: none; font-size: 14px;
}
.back-link:hover { text-decoration: underline; }
</style>
</head>
@@ -32,6 +51,10 @@
<span class="detail-label">IP 地址</span>
<span class="detail-value">{{.entry.IPAddress}}</span>
</div>
<div class="detail-row">
<span class="detail-label">封禁档位</span>
<span class="detail-value">{{if gt .entry.BanCount 3}}第 {{sub .entry.BanCount 3}} 次封禁{{else}}—{{end}}</span>
</div>
<div class="detail-row">
<span class="detail-label">原因</span>
<span class="detail-value">{{.entry.Reason}}</span>
@@ -42,7 +65,7 @@
</div>
<div class="detail-row">
<span class="detail-label">解封时间</span>
<span class="detail-value">{{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}}</span>
<span class="detail-value">{{time12 .entry.ExpiresAt}}</span>
</div>
</div>
{{end}}
+601 -47
View File
@@ -1,66 +1,620 @@
{{define "styles"}}
<style>
/* ===== MailGo 设计系统(参考 QQ 邮箱布局) ===== */
:root {
--primary: #1677ff;
--primary-hover: #0e5fd8;
--primary-soft: #e8f1ff;
--orange: #ff9500;
--orange-hover: #f08800;
--danger: #e34d59;
--danger-soft: #fde8e8;
--success: #34a853;
--success-soft: #e6f7ec;
--sidebar-bg: #f6f8fc;
--topbar-h: 56px;
--border: #e5e6eb;
--border-light: #f0f1f3;
--text: #1f2329;
--text-2: #646a73;
--text-3: #8f959e;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f5f5; color:#333; }
.navbar { background:#2c3e50; padding:0 20px; height:50px; display:flex; align-items:center; }
.navbar a { color:#ecf0f1; text-decoration:none; margin-right:20px; font-size:14px; }
.navbar a:hover { color:#3498db; }
.navbar .right { margin-left:auto; }
.container { max-width:1200px; margin:20px auto; padding:0 20px; }
.card { background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); padding:20px; margin-bottom:20px; }
table { width:100%; border-collapse:collapse; }
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #eee; }
th { background:#f8f9fa; font-weight:600; }
.btn { display:inline-block; padding:8px 16px; border-radius:4px; text-decoration:none; font-size:14px; cursor:pointer; border:none; }
.btn-primary { background:#3498db; color:#fff; }
.btn-primary:hover { background:#2980b9; }
.btn-danger { background:#e74c3c; color:#fff; }
.btn-danger:hover { background:#c0392b; }
.btn-sm { padding:4px 10px; font-size:12px; }
.alert { padding:12px 16px; border-radius:4px; margin-bottom:16px; }
.alert-error { background:#fde8e8; color:#c0392b; }
.alert-success { background:#e8fde8; color:#27ae60; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: #f6f8fc; color: var(--text); font-size: 14px;
}
a { text-decoration: none; color: inherit; }
ul { list-style: none; }
input, textarea, select, button { font-family: inherit; }
input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; flex-shrink: 0; }
svg { flex-shrink: 0; }
a, button { touch-action: manipulation; }
/* ---------- 顶部导航栏 ---------- */
.topbar {
position: fixed; top: 0; left: 0; right: 0; height: var(--topbar-h);
background: #fff; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 28px; padding: 0 20px; z-index: 100;
}
.logo { display: flex; align-items: center; gap: 9px; }
.logo-icon {
width: 34px; height: 34px; border-radius: 9px; color: #fff; font-size: 17px;
background: linear-gradient(135deg, #1677ff, #4aa3ff);
display: inline-flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.35);
}
.logo-text { font-size: 19px; font-weight: 700; color: var(--primary); letter-spacing: 0.5px; }
.logo-text em { font-style: normal; font-weight: 400; font-size: 13px; color: var(--text-2); margin-left: 3px; }
.topbar-search {
flex: 0 1 340px; height: 34px; display: none; align-items: center; gap: 8px;
background: #f2f3f5; border: 1px solid transparent; border-radius: 17px;
padding: 0 14px; color: var(--text-3);
}
body.page-list .topbar-search { display: flex; }
.topbar-search:focus-within { background: #fff; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12); }
.topbar-search input { border: none; outline: none; background: transparent; flex: 1; font-size: 13px; color: var(--text); }
.topbar-search input::placeholder { color: var(--text-3); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 18px; }
.topbar-link { color: var(--text-2); font-size: 13.5px; }
.topbar-link:hover { color: var(--primary); }
.user-chip { display: flex; align-items: center; gap: 8px; }
.user-email { font-size: 13px; color: var(--text-2); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.logout-form { display: inline-flex; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 6px;
background: #fff; color: var(--text-2); cursor: pointer;
}
.icon-btn:hover { color: var(--danger); border-color: var(--danger); }
/* ---------- 头像 ---------- */
.avatar {
width: 36px; height: 36px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 15px; font-weight: 600; flex-shrink: 0; user-select: none;
}
.avatar-sm { width: 28px; height: 28px; font-size: 12.5px; }
/* ---------- 整体布局 ---------- */
.app-body { display: flex; min-height: 100vh; padding-top: var(--topbar-h); }
/* ---------- 左侧文件夹导航 ---------- */
.mail-sidebar {
width: 200px; flex-shrink: 0;
background: var(--sidebar-bg); border-right: 1px solid var(--border);
padding: 16px 12px; display: flex; flex-direction: column; gap: 14px;
position: sticky; top: var(--topbar-h); height: calc(100vh - var(--topbar-h));
}
.compose-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
height: 38px; border-radius: 19px; color: #fff; font-size: 14.5px; font-weight: 600;
background: linear-gradient(135deg, #ffa940, #ff9500);
box-shadow: 0 2px 8px rgba(255, 149, 0, 0.35);
transition: transform 0.1s, box-shadow 0.1s;
}
.compose-btn:hover { background: linear-gradient(135deg, #ff9d2e, #f08800); box-shadow: 0 3px 10px rgba(255, 149, 0, 0.45); transform: translateY(-1px); }
.folder-nav { display: flex; flex-direction: column; gap: 3px; }
.folder {
display: flex; align-items: center; gap: 10px;
height: 36px; padding: 0 10px; border-radius: 7px;
color: var(--text-2); font-size: 13.5px; position: relative;
}
.folder:hover { background: #eef1f6; color: var(--text); }
.folder.active { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
.folder.active::before {
content: ""; position: absolute; left: -12px; top: 9px; bottom: 9px;
width: 3px; border-radius: 2px; background: var(--primary);
}
.folder .badge {
margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px;
border-radius: 10px; background: #ff4d4f; color: #fff;
font-size: 11px; line-height: 20px; text-align: center; font-weight: 600;
}
.folder .count { margin-left: auto; color: var(--text-3); font-size: 12px; }
.sidebar-footer { margin-top: auto; border-top: 1px solid var(--border-light); padding-top: 12px; display: flex; flex-direction: column; gap: 3px; }
/* ---------- 主内容区 ---------- */
.mail-main { flex: 1; min-width: 0; background: #fff; display: flex; flex-direction: column; }
/* ---------- 列表工具条 ---------- */
.list-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 10px 18px; border-bottom: 1px solid var(--border-light); background: #fff;
}
.tb-btn {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 11px; border: 1px solid var(--border); border-radius: 6px;
background: #fff; color: var(--text-2); font-size: 13px; cursor: pointer;
}
.tb-btn:hover { color: var(--primary); border-color: var(--primary); }
.tb-btn.danger:hover { color: var(--danger); border-color: var(--danger); }
.check-all { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-2); cursor: pointer; user-select: none; }
.toolbar-spacer { flex: 1; }
.page-info { font-size: 13px; color: var(--text-3); }
/* ---------- 邮件列表 ---------- */
.mail-list { flex: 1; overflow-y: auto; }
.mail-row {
display: flex; align-items: center; gap: 10px;
padding: 0 18px; height: 54px; border-bottom: 1px solid var(--border-light);
cursor: pointer; transition: background 0.08s;
background: #fff;
}
.mail-row:not(.unread) { background: #fbfcfe; }
.mail-row:hover { background: #f2f6ff; }
.mail-row.selected { background: var(--primary-soft); }
.cell-check { display: flex; align-items: center; }
.cell-avatar { flex: 0 0 auto; }
.cell-from {
width: 150px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13.5px; color: var(--text-2);
}
.mail-row.unread .cell-from { color: var(--text); font-weight: 600; }
.cell-subject-wrap { display: flex; align-items: center; gap: 7px; flex: 0 1 36%; min-width: 150px; }
.unread-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--primary); flex-shrink: 0; }
.cell-subject {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13.5px; color: var(--text-2);
}
.mail-row.unread .cell-subject { color: var(--text); font-weight: 600; }
.cell-snippet {
flex: 1; min-width: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12.5px; color: var(--text-3);
}
.cell-date {
flex: 0 0 auto; white-space: nowrap; text-align: right;
font-size: 12.5px; color: var(--text-3);
}
.mail-row.unread .cell-date { color: var(--text-2); }
.row-del { opacity: 0; transition: opacity 0.12s; }
.mail-row:hover .row-del { opacity: 1; }
.empty-tip { padding: 90px 0; text-align: center; color: var(--text-3); font-size: 14px; }
.empty-tip .empty-icon { font-size: 46px; display: block; margin-bottom: 14px; opacity: 0.5; }
/* ---------- 列表底部分页 ---------- */
.list-footer {
display: flex; align-items: center; gap: 12px;
padding: 10px 18px; border-top: 1px solid var(--border-light);
font-size: 13px; color: var(--text-3); background: #fff;
}
.pager { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.page-btn {
height: 28px; padding: 0 11px; border: 1px solid var(--border); border-radius: 6px;
background: #fff; color: var(--text-2); font-size: 13px;
display: inline-flex; align-items: center; gap: 4px;
}
.page-btn:hover:not(.disabled) { color: var(--primary); border-color: var(--primary); }
.page-btn.disabled { opacity: 0.45; cursor: not-allowed; }
.page-num { font-size: 13px; }
/* ---------- 邮件阅读页 ---------- */
.view-toolbar { display: flex; align-items: center; gap: 10px; padding: 10px 18px; border-bottom: 1px solid var(--border-light); }
.mail-head { padding: 22px 28px 0; }
.mail-title { font-size: 20px; font-weight: 600; line-height: 1.45; word-break: break-word; }
.mail-from-row { display: flex; align-items: center; gap: 12px; margin: 16px 0 18px; }
.mail-from-name { font-size: 14.5px; font-weight: 600; }
.mail-from-addr { color: var(--text-3); font-size: 12.5px; }
.mail-date { color: var(--text-3); font-size: 12.5px; margin-left: auto; }
.mail-body-wrap { padding: 22px 28px; flex: 1; overflow: auto; }
.mail-body { line-height: 1.7; font-size: 14.5px; }
.mail-body pre { white-space: pre-wrap; font-family: inherit; }
.mail-body-iframe {
width: 100%; min-height: 340px; border: 1px solid var(--border-light);
border-radius: 8px; background: #fff;
}
.attachment-list { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border-light); }
.attachment-item {
display: inline-flex; align-items: center; gap: 7px;
margin: 0 12px 10px 0; padding: 7px 13px;
background: #f2f3f5; border-radius: 7px; font-size: 13px; color: var(--text);
}
.attachment-item a { color: var(--text); }
.attachment-item a:hover { color: var(--primary); }
.view-actions { display: flex; align-items: center; gap: 10px; padding: 18px 28px 24px; }
/* ---------- 写信页 ---------- */
.compose-toolbar { display: flex; align-items: center; gap: 10px; padding: 10px 18px; border-bottom: 1px solid var(--border-light); }
.compose-form { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.compose-field { display: flex; align-items: center; gap: 12px; padding: 9px 22px; border-bottom: 1px solid var(--border-light); }
.compose-field label { width: 54px; color: var(--text-2); font-size: 13.5px; flex-shrink: 0; }
.compose-field input { border: none; outline: none; flex: 1; font-size: 14px; color: var(--text); background: transparent; }
.editor-wrap { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.editor-wrap .ql-toolbar { border-left: none; border-right: none; border-top: none; }
.editor-wrap .ql-container { border: none; flex: 1; font-size: 14.5px; }
#editor { height: 100%; }
.attach-chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 22px; border-bottom: 1px solid var(--border-light); }
.attach-chip {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 11px; background: #f2f3f5; border-radius: 15px;
font-size: 12.5px; color: var(--text-2);
}
.attach-chip .chip-del { cursor: pointer; color: var(--text-3); border: none; background: none; font-size: 13px; line-height: 1; }
.attach-chip .chip-del:hover { color: var(--danger); }
.compose-footer {
display: flex; align-items: center; gap: 14px;
padding: 12px 22px; border-top: 1px solid var(--border-light);
font-size: 12.5px; color: var(--text-3); background: #fafbfc;
}
.quota-bar { width: 200px; height: 6px; background: #eceef1; border-radius: 3px; overflow: hidden; }
.quota-bar i { display: block; height: 100%; background: linear-gradient(90deg, #4aa3ff, var(--primary)); border-radius: 3px; }
.quota-bar.warn i { background: linear-gradient(90deg, #ffc53d, var(--orange)); }
.quota-bar.over i { background: var(--danger); }
/* ---------- 登录页 ---------- */
.login-page {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(165deg, #eaf2ff 0%, #f6f8fc 55%, #f0f6ff 100%);
}
.login-card {
width: 400px; max-width: calc(100vw - 32px);
background: #fff; border-radius: 14px;
box-shadow: 0 10px 40px rgba(22, 119, 255, 0.10);
padding: 42px 38px 36px;
}
.login-logo { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 8px; }
.login-title { text-align: center; font-size: 21px; font-weight: 700; color: var(--text); margin-bottom: 26px; }
.login-sub { text-align: center; color: var(--text-3); font-size: 13px; margin-bottom: 26px; }
.divider { display: flex; align-items: center; gap: 10px; color: var(--text-3); font-size: 12.5px; margin: 18px 0; }
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border-light); }
/* ---------- 通用组件(兼容管理后台) ---------- */
.container { max-width: 1400px; margin: 20px auto; padding: var(--topbar-h) 20px 0; }
.card {
background: #fff; border: 1px solid var(--border-light); border-radius: var(--radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); padding: 20px; margin-bottom: 16px;
}
.btn {
display: inline-block; padding: 8px 16px; border-radius: 6px; border: 1px solid transparent;
text-decoration: none; font-size: 14px; cursor: pointer; text-align: center;
background: #f2f3f5; color: var(--text); line-height: 1.4;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #d23b47; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 5px; }
.alert { padding: 12px 16px; border-radius: 7px; margin-bottom: 16px; font-size: 13.5px; }
.alert-error { background: var(--danger-soft); color: #c0392b; }
.alert-success { background: var(--success-soft); color: #2e9e4f; }
.form-group { margin-bottom: 16px; }
.form-group label { display:block; margin-bottom:6px; font-weight:600; }
.form-group input, .form-group textarea, .form-group select { width:100%; padding:8px 12px; border:1px solid #ddd; border-radius:4px; font-size:14px; }
.form-group label { display: block; margin-bottom: 7px; font-weight: 600; font-size: 13.5px; }
.form-group input, .form-group textarea, .form-group select {
width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px;
font-size: 14px; outline: none; background: #fff; color: var(--text);
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
border-color: var(--primary); box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
}
.form-group input[type="checkbox"] { width: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border-light); font-size: 13.5px; }
th { background: #f8f9fb; font-weight: 600; color: var(--text-2); }
tbody tr:hover td { background: #fafbfc; }
.unread { font-weight: bold; }
.message-subject { color:#2c3e50; text-decoration:none; }
.message-subject:hover { color:#3498db; }
.sidebar { width:200px; float:left; }
.sidebar a { display:block; padding:10px 15px; color:#2c3e50; text-decoration:none; border-radius:4px; margin-bottom:2px; }
.sidebar a:hover, .sidebar a.active { background:#3498db; color:#fff; }
.content { margin-left:220px; }
.message-subject { color: var(--text); }
.message-subject:hover { color: var(--primary); }
.mail-meta { color: var(--text-2); font-size: 13px; margin-bottom: 10px; line-height: 1.9; }
.pagination { margin-top: 16px; text-align: center; }
.pagination a, .pagination span { display:inline-block; padding:6px 12px; margin:0 2px; border:1px solid #ddd; border-radius:4px; text-decoration:none; color:#333; }
.pagination .current { background:#3498db; color:#fff; border-color:#3498db; }
.mail-meta { color:#7f8c8d; font-size:13px; margin-bottom:8px; }
.mail-body { line-height:1.6; margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
.attachment-list { margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
.attachment-item { display:inline-block; margin-right:12px; margin-bottom:8px; padding:6px 12px; background:#ecf0f1; border-radius:4px; font-size:13px; }
.attachment-item a { color:#2c3e50; text-decoration:none; }
.attachment-item a:hover { color:#3498db; }
.pagination a, .pagination span {
display: inline-block; padding: 6px 12px; margin: 0 2px;
border: 1px solid var(--border); border-radius: 6px; text-decoration: none;
color: var(--text-2); font-size: 13px;
}
.pagination .current { background: var(--primary); color: #fff; border-color: var(--primary); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: bold; }
.badge-unread { background:#e74c3c; color:#fff; }
.stat-card { display:inline-block; width:200px; padding:20px; margin-right:20px; background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); text-align:center; }
.stat-card h3 { font-size:32px; color:#2c3e50; margin-bottom:4px; }
.stat-card p { color:#7f8c8d; font-size:14px; }
.dns-record { background:#f8f9fa; padding:12px 16px; border-radius:4px; margin-bottom:12px; font-family:monospace; font-size:13px; white-space:pre-wrap; }
.badge-unread { background: #ff4d4f; color: #fff; }
.stat-card {
display: inline-block; width: 200px; padding: 20px; margin: 0 20px 16px 0;
background: #fff; border: 1px solid var(--border-light); border-radius: var(--radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); text-align: center;
}
.stat-card h3 { font-size: 30px; color: var(--primary); margin-bottom: 4px; }
.stat-card p { color: var(--text-2); font-size: 13.5px; }
.dns-record {
background: #f8f9fb; padding: 12px 16px; border-radius: 6px; margin-bottom: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px; white-space: pre-wrap;
}
.clearfix::after { content: ""; display: table; clear: both; }
/* 管理后台左侧导航兼容 */
.sidebar { width: 200px; float: left; background: #fff; border: 1px solid var(--border-light); border-radius: var(--radius); padding: 8px; }
.sidebar a { display: block; padding: 9px 14px; color: var(--text-2); text-decoration: none; border-radius: 6px; margin-bottom: 2px; font-size: 13.5px; }
.sidebar a:hover, .sidebar a.active { background: var(--primary-soft); color: var(--primary); }
.content { margin-left: 220px; }
.settings-main { padding: 24px; background: var(--sidebar-bg); }
/* ===== 移动端适配(≤767px ===== */
@media (max-width: 767px) {
:root { --topbar-h: 50px; }
/* 顶栏:紧凑单行 + 列表页搜索框下移为第二行 */
.topbar { height: auto; min-height: 50px; padding: 8px 12px; gap: 8px; flex-wrap: wrap; }
.logo-icon { width: 30px; height: 30px; font-size: 15px; border-radius: 8px; }
.logo-text { font-size: 17px; }
.logo-text em { display: none; }
.topbar-search { flex: 1 1 100%; order: 9; border-radius: 8px; }
body.page-list { --topbar-h: 96px; }
.topbar-right { gap: 12px; }
.user-email { display: none; }
/* 左侧文件夹栏 → 底部导航条 */
.mail-sidebar {
position: fixed; top: auto; bottom: 0; left: 0; right: 0;
width: 100%; height: auto; min-height: 58px;
padding: 4px 8px calc(4px + env(safe-area-inset-bottom));
flex-direction: row; align-items: center; gap: 2px;
background: #fff; border-top: 1px solid var(--border); border-right: none;
z-index: 90; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
.folder-nav { display: contents; }
.folder {
flex: 1; flex-direction: column; justify-content: center; gap: 2px;
height: 46px; padding: 0; border-radius: 8px; font-size: 10.5px;
position: relative; order: 7; /* 自定义文件夹排在最后 */
}
.folder-nav .folder:nth-child(1) { order: 1; }
.folder-nav .folder:nth-child(2) { order: 2; }
.folder-nav .folder:nth-child(3) { order: 4; }
.folder-nav .folder:nth-child(4) { order: 6; }
.folder svg { width: 19px; height: 19px; }
.folder.active::before { display: none; }
.folder .badge {
position: absolute; top: 1px; right: calc(50% - 20px);
min-width: 16px; height: 16px; padding: 0 4px;
font-size: 10px; line-height: 16px; border-radius: 8px;
}
.folder .count { display: none; }
.compose-btn {
width: 46px; height: 46px; padding: 0; border-radius: 50%;
margin: 0 4px; flex-shrink: 0; order: 3;
font-size: 0; box-shadow: 0 3px 10px rgba(255, 149, 0, 0.5);
transform: translateY(-10px);
}
.compose-btn:hover { transform: translateY(-10px); }
.compose-btn svg { width: 20px; height: 20px; }
.sidebar-footer {
margin: 0; border: none; padding: 0;
flex-direction: row; gap: 2px; order: 8;
}
.mail-main { padding-bottom: calc(58px + env(safe-area-inset-bottom)); }
/* 邮件列表 */
.list-toolbar { padding: 8px 10px; gap: 8px; }
.check-all { font-size: 12.5px; }
.tb-btn { height: 30px; padding: 0 9px; font-size: 12.5px; }
.page-info { font-size: 12px; }
.mail-row { padding: 0 10px; height: 60px; gap: 8px; }
.avatar { width: 32px; height: 32px; font-size: 13px; }
.cell-from { width: 76px; font-size: 13px; }
.cell-subject-wrap { flex: 1; min-width: 0; }
.cell-subject { font-size: 13px; }
.cell-snippet { display: none; }
.cell-date { width: auto; font-size: 10.5px; }
.row-del { opacity: 1; }
.list-footer { padding: 8px 10px; }
.pager { margin-left: auto; gap: 4px; }
/* 阅读页 */
.view-toolbar { padding: 8px 10px; gap: 8px; }
.mail-head { padding: 16px 16px 0; }
.mail-title { font-size: 17px; }
.mail-from-row { gap: 10px; margin: 12px 0 14px; }
.mail-body-wrap { padding: 14px 16px; }
.mail-body-iframe { min-height: 260px; }
.view-actions { padding: 14px 16px 20px; flex-wrap: wrap; }
/* 写信页 */
.compose-toolbar { padding: 8px 10px; gap: 8px; }
.compose-field { padding: 8px 14px; gap: 8px; }
.compose-field label { width: 44px; font-size: 13px; }
.compose-footer { padding: 10px 14px; flex-wrap: wrap; }
.quota-bar { width: 130px; }
/* 设置页 */
.settings-main { padding: 14px; }
/* 管理后台 */
.container { padding: var(--topbar-h) 12px 0; margin: 12px auto; }
.sidebar { float: none; width: 100%; margin-bottom: 12px; display: flex; flex-wrap: wrap; gap: 2px; padding: 6px; }
.sidebar a { margin: 0; padding: 8px 12px; font-size: 13px; }
.content { margin-left: 0; overflow-x: auto; }
th, td { white-space: nowrap; }
.stat-card { width: calc(50% - 10px); margin: 0 0 10px; }
}
</style>
{{end}}
{{define "navbar"}}
{{if .currentUser}}
<nav class="navbar">
<a href="/inbox">MailGo</a>
{{if .currentUser.IsAdmin}}<a href="/admin">管理后台</a>{{end}}
<div class="right">
<span style="color:#ecf0f1;font-size:13px;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
<form method="POST" action="/logout" style="display:inline;">
<a href="#" onclick="this.parentElement.submit(); return false;">退出</a>
<header class="topbar">
<div class="topbar-left">
<a class="logo" href="/inbox">
<span class="logo-icon"></span>
<span class="logo-text">MailGo<em>邮箱</em></span>
</a>
</div>
<div class="topbar-search">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="mail-search" type="text" placeholder="搜索邮件(发件人 / 主题)" autocomplete="off">
</div>
<div class="topbar-right">
{{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">管理后台</a>{{end}}
<a class="topbar-link" href="/settings">设置</a>
<span class="user-chip">
<span class="avatar avatar-sm" style="{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
<span class="user-email" title="{{.currentUser.Username}}@{{.currentUser.Domain.Name}}">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
</span>
<form method="POST" action="/logout" class="logout-form">
<button type="submit" class="icon-btn" title="退出登录">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</form>
</div>
</header>
{{end}}
{{end}}
{{define "sidebar"}}
<aside class="mail-sidebar">
<a href="/compose" class="compose-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
写信
</a>
<nav class="folder-nav">
{{range .folders}}
<a class="folder {{if eq $.activeFolder .Name}}active{{end}}" href="/folder/{{urlPath .Name}}">
{{if eq .SpecialUse "Sent"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
{{else if eq .SpecialUse "Drafts"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
{{else if eq .SpecialUse "Trash"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{{else if eq .Name "INBOX"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
{{else}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{{end}}
{{folderLabel .Name}}
{{if eq .Name "INBOX"}}
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
</a>
{{end}}
</nav>
<div class="sidebar-footer">
{{if .currentUser.IsAdmin}}
<a class="folder" href="/admin">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
管理后台
</a>
{{end}}
</div>
</aside>
{{end}}
{{define "listjs"}}
<script>
(function () {
var searchInput = document.getElementById('mail-search');
var rows = Array.prototype.slice.call(document.querySelectorAll('.mail-row'));
var selectAll = document.getElementById('select-all');
var btnDelete = document.getElementById('btn-delete');
// 行点击跳转(复选框 / 行内删除按钮除外)
rows.forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('.cell-check') || e.target.closest('.row-del')) return;
var link = row.querySelector('.cell-subject');
if (link) window.location.href = link.getAttribute('href');
});
});
// 全选
selectAll && selectAll.addEventListener('change', function () {
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.checked = selectAll.checked;
row.classList.toggle('selected', cb.checked);
});
updateDeleteState();
});
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.addEventListener('change', function () {
row.classList.toggle('selected', cb.checked);
if (!cb.checked && selectAll) selectAll.checked = false;
updateDeleteState();
});
});
function updateDeleteState() {
if (!btnDelete) return;
var n = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; }).length;
btnDelete.disabled = n === 0;
}
// 批量删除
btnDelete && btnDelete.addEventListener('click', function () {
var ids = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; })
.map(function (r) { return r.dataset.id; });
if (!ids.length) return;
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return;
var done = 0;
ids.forEach(function (id) {
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
.then(function () { if (++done === ids.length) window.location.reload(); })
.catch(function () { if (++done === ids.length) window.location.reload(); });
});
});
// 刷新
var btnRefresh = document.getElementById('btn-refresh');
btnRefresh && btnRefresh.addEventListener('click', function () { window.location.reload(); });
// 搜索过滤(发件人 / 主题 / 摘要)
searchInput && searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
rows.forEach(function (row) {
if (!row.isConnected) return;
var text = row.textContent.toLowerCase();
row.style.display = (!q || text.indexOf(q) !== -1) ? '' : 'none';
});
});
// 从 BFCache(往返缓存)恢复时静默同步最新列表状态:
// 阅读页已把邮件标记为已读/删除,恢复的旧 DOM 需要与服务端对齐,
// 避免“返回后仍是未读/已删邮件仍显示”。
// 真实浏览器在 BFCache 恢复时会触发 pageshow(persisted=true)
// visibilitychange 作为兜底(页面重新可见时也同步一次)。
var syncing = false;
function syncFromServer(forceReloadOnError) {
if (syncing) return;
syncing = true;
fetch(window.location.href, { headers: { 'Accept': 'text/html' }, credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = {};
doc.querySelectorAll('.mail-row').forEach(function (r) { fresh[r.dataset.id] = r.classList.contains('unread'); });
rows.forEach(function (row) {
var id = row.dataset.id;
if (!(id in fresh)) { row.remove(); return; } // 已被删除
row.classList.toggle('unread', fresh[id]);
var wrap = row.querySelector('.cell-subject-wrap');
var dot = row.querySelector('.unread-dot');
if (fresh[id] && !dot && wrap) {
var s = document.createElement('span');
s.className = 'unread-dot';
wrap.insertBefore(s, wrap.firstChild);
} else if (!fresh[id] && dot) {
dot.remove();
}
});
// 同步“共 N 封”与侧栏未读角标
var total = doc.querySelector('.page-info');
var curTotal = document.querySelector('.page-info');
if (total && curTotal) curTotal.textContent = total.textContent;
var badge = doc.querySelector('.folder.active .badge');
var curBadge = document.querySelector('.folder.active .badge');
if (badge && curBadge) curBadge.textContent = badge.textContent;
else if (!badge && curBadge) curBadge.remove();
})
.catch(function () {
// BFCache 恢复时 DOM 是旧的,同步失败只能整页刷新兜底;
// 标签页切回触发的同步失败则静默忽略(可能只是离线)。
if (forceReloadOnError) window.location.reload();
})
.then(function () { syncing = false; });
}
window.addEventListener('pageshow', function (e) {
if (e.persisted) syncFromServer(true);
});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') syncFromServer(false);
});
})();
</script>
{{end}}
+89 -44
View File
@@ -3,64 +3,68 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>撰写邮件 - MailGo</title>
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>写信 - MailGo</title>
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet">
{{template "styles" .}}
</head>
<body>
<body class="page-compose">
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
<div class="app-body">
{{template "sidebar" .}}
<main class="mail-main">
<div class="compose-toolbar">
<button type="submit" form="compose-form" class="btn btn-primary">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
发送
</button>
<label class="tb-btn" style="cursor:pointer;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
附件
<input type="file" name="attachments" id="attach-input" multiple style="display:none;">
</label>
<a href="/inbox" class="tb-btn">取消</a>
<div class="toolbar-spacer"></div>
<span class="page-info">撰写新邮件</span>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">撰写邮件</h2>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
<form method="POST" action="/compose" enctype="multipart/form-data">
<div class="form-group">
{{if .error}}<div class="alert alert-error" style="margin:12px 18px 0;">{{.error}}</div>{{end}}
<form id="compose-form" class="compose-form" method="POST" action="/compose" enctype="multipart/form-data">
<div class="compose-field">
<label>收件人</label>
<input type="email" name="to" required value="{{.to}}" placeholder="user@example.com">
<input type="email" name="to" required value="{{.to}}" placeholder="输入收件人邮箱地址">
</div>
<div class="form-group">
<label>抄送(可选)</label>
<input type="text" name="cc" value="{{.cc}}" placeholder="cc@example.com">
<div class="compose-field">
<label>抄送</label>
<input type="text" name="cc" value="{{.cc}}" placeholder="多个地址用逗号分隔(可选)">
</div>
<div class="form-group">
<div class="compose-field">
<label>主题</label>
<input type="text" name="subject" value="{{.subject}}" placeholder="邮件主题">
<input type="text" name="subject" value="{{.subject}}" placeholder="输入邮件主题">
</div>
<div class="form-group">
<label>正文</label>
<div id="editor" style="height:300px;"></div>
<div id="attach-chips" class="attach-chips" style="{{if not .attachments}}display:none;{{end}}"></div>
<div class="editor-wrap">
<div id="editor" data-placeholder="请输入邮件内容..."></div>
<input type="hidden" name="body" id="body-hidden">
<input type="hidden" name="html_body" id="html-body-hidden">
</div>
<div class="form-group">
<label>附件</label>
<input type="file" name="attachments" multiple>
<div class="compose-footer">
<span>附件配额</span>
<span class="quota-bar" id="quota-bar" data-used="{{.usedBytes}}" data-quota="{{.quotaBytes}}"><i></i></span>
<span id="quota-text">{{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}</span>
</div>
<div class="form-group" style="color:#7f8c8d;font-size:12px;">
配额: {{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}
</div>
<button type="submit" class="btn btn-primary">发送邮件</button>
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
</form>
</main>
</div>
</div>
</div>
</div>
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
<script src="/static/vendor/quill/quill.min.js"></script>
<script>
var quill = new Quill('#editor', {
theme: 'snow',
placeholder: '请输入邮件内容...',
placeholder: document.getElementById('editor').dataset.placeholder || '请输入邮件内容...',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
@@ -72,13 +76,54 @@
]
}
});
document.querySelector('form').addEventListener('submit', function() {
{{if .bodyContent}}
quill.root.innerHTML = {{.bodyContent | jsonify}};
{{end}}
document.getElementById('compose-form').addEventListener('submit', function () {
document.getElementById('body-hidden').value = quill.getText();
document.getElementById('html-body-hidden').value = quill.root.innerHTML;
});
{{if .bodyContent}}
quill.root.innerHTML = {{.bodyContent | safeJS}};
{{end}}
// 附件选择预览
var attachInput = document.getElementById('attach-input');
var chipsBox = document.getElementById('attach-chips');
var files = [];
function renderChips() {
chipsBox.innerHTML = '';
chipsBox.style.display = files.length ? 'flex' : 'none';
files.forEach(function (f, i) {
var chip = document.createElement('span');
chip.className = 'attach-chip';
chip.innerHTML = '📎 ' + f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)' +
'<button type="button" class="chip-del" data-i="' + i + '" title="移除">✕</button>';
chipsBox.appendChild(chip);
});
}
attachInput.addEventListener('change', function () {
files = Array.prototype.slice.call(attachInput.files);
renderChips();
});
chipsBox.addEventListener('click', function (e) {
var del = e.target.closest('.chip-del');
if (!del) return;
files.splice(parseInt(del.dataset.i, 10), 1);
var dt = new DataTransfer();
files.forEach(function (f) { dt.items.add(f); });
attachInput.files = dt.files;
renderChips();
});
// 配额进度条
var bar = document.getElementById('quota-bar');
if (bar) {
var used = parseInt(bar.dataset.used, 10) || 0;
var quota = parseInt(bar.dataset.quota, 10) || 1;
var pct = Math.min(100, Math.round(used / quota * 100));
bar.querySelector('i').style.width = pct + '%';
if (pct >= 90) bar.classList.add('warn');
if (pct >= 100) bar.classList.add('over');
}
</script>
</body>
</html>
-74
View File
@@ -1,74 +0,0 @@
{{define "drafts"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>草稿箱 - MailGo</title>
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">草稿箱</h2>
{{if not .messages}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无草稿邮件</p>
{{else}}
<table>
<thead>
<tr>
<th style="width:25%;">发件人/收件人</th>
<th style="width:45%;">主题</th>
<th style="width:20%;">时间</th>
<th style="width:10%;">操作</th>
</tr>
</thead>
<tbody>
{{range .messages}}
<tr>
<td>{{.ToAddr}}</td>
<td>
<a href="/drafts/{{.ID}}" class="message-subject">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</td>
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
<td>
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/drafts?page={{sub .page 1}}">上一页</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}} 页</span>
{{if lt .page .totalPages}}
<a href="/drafts?page={{add .page 1}}">下一页</a>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
</body>
</html>
{{end}}
+119
View File
@@ -0,0 +1,119 @@
{{define "folder"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{folderLabel .folder}} - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-list">
{{template "navbar" .}}
<div class="app-body">
{{template "sidebar" .}}
<main class="mail-main">
<div class="list-toolbar">
<label class="check-all" title="全选/取消全选">
<input type="checkbox" id="select-all">
全选
</label>
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{{if .isTrash}}彻底删除{{else}}删除{{end}}
</button>
{{if .isTrash}}
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
onsubmit="return confirm('确定要清空「已删除」文件夹吗?此操作不可恢复。');">
<button type="submit" class="tb-btn danger">清空文件夹</button>
</form>
{{end}}
<div class="toolbar-spacer"></div>
<span class="page-info">共 {{.total}} 封</span>
</div>
{{if not .messages}}
<div class="mail-list">
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel .folder}}暂无邮件</div>
</div>
{{else}}
<ul class="mail-list">
{{range .messages}}
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
<label class="cell-check" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-id="{{.ID}}">
</label>
<span class="cell-avatar">
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
{{else}}
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
{{end}}
</span>
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="收件人:{{.ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
{{if eq $.folder "Drafts"}}致:{{end}}{{mailName .ToAddr}}
{{else}}
{{mailName (decodeHeader .FromAddr)}}
{{end}}
</span>
<span class="cell-subject-wrap">
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</span>
<span class="cell-snippet">
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
</span>
<span class="cell-date">{{shortDate .Date}}</span>
{{if $.isTrash}}
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要恢复这封邮件吗?');">
<button type="submit" class="icon-btn" title="恢复到收件箱">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="icon-btn" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
{{end}}
</li>
{{end}}
</ul>
{{end}}
<div class="list-footer">
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<div class="pager">
{{if gt .page 1}}
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页
</a>
{{else}}
<span class="page-btn disabled">上一页</span>
{{end}}
{{if lt .page .totalPages}}
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}">
下一页
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
{{else}}
<span class="page-btn disabled">下一页</span>
{{end}}
</div>
</div>
</main>
</div>
{{template "listjs" .}}
</body>
</html>
{{end}}
-75
View File
@@ -1,75 +0,0 @@
{{define "inbox"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收件箱 - MailGo</title>
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">收件箱</h2>
{{if not .messages}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
{{else}}
<table>
<thead>
<tr>
<th style="width:25%;">发件人</th>
<th style="width:45%;">主题</th>
<th style="width:20%;">时间</th>
<th style="width:10%;">操作</th>
</tr>
</thead>
<tbody>
{{range .messages}}
<tr class="{{if not .IsRead}}unread{{end}}">
<td>{{decodeHeader .FromAddr}}</td>
<td>
<a href="/inbox/{{.ID}}" class="message-subject">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</td>
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
<td>
{{if not .IsRead}}
<form method="POST" action="/mail/read/{{.ID}}" style="display:inline;">
<button type="submit" class="btn btn-primary btn-sm">已读</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/inbox?page={{sub .page 1}}">上一页</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}} 页</span>
{{if lt .page .totalPages}}
<a href="/inbox?page={{add .page 1}}">下一页</a>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
</body>
</html>
{{end}}
+18 -17
View File
@@ -3,35 +3,38 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - MailGo</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>登录 - MailGo 邮箱</title>
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div style="max-width:400px;margin:80px auto;">
<div class="card">
<h2 style="text-align:center;margin-bottom:24px;">MailGo 登录</h2>
<body class="login-page">
<div class="login-card">
<div class="login-logo">
<span class="logo-icon" style="width:44px;height:44px;font-size:22px;border-radius:12px;"></span>
<span style="font-size:26px;font-weight:700;color:var(--primary);">MailGo<em style="font-style:normal;font-weight:400;font-size:15px;color:var(--text-2);margin-left:4px;">邮箱</em></span>
</div>
<div class="login-sub">登录您的邮箱账户</div>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label>邮箱地址</label>
<input type="email" name="email" required autofocus placeholder="admin@example.com">
<input type="email" name="email" required autofocus placeholder="user@example.com" style="padding:11px 14px;border-radius:8px;">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required placeholder="请输入密码">
<input type="password" name="password" required placeholder="请输入密码" style="padding:11px 14px;border-radius:8px;">
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
<button type="submit" class="btn btn-primary" style="width:100%;padding:11px 16px;font-size:15px;border-radius:8px;"> </button>
</form>
{{if or .oauth2Enabled .ldapEnabled}}
<div style="text-align:center;margin:16px 0;color:#7f8c8d;">─── 或 ───</div>
<div class="divider"></div>
{{end}}
{{if .ldapEnabled}}
<form method="POST" action="/login/ldap">
<form method="POST" action="/login/ldap" style="margin-bottom:10px;">
<div class="form-group">
<label>LDAP 用户名</label>
<input type="text" name="username" placeholder="LDAP 用户名">
@@ -40,18 +43,16 @@
<label>LDAP 密码</label>
<input type="password" name="password" placeholder="LDAP 密码">
</div>
<button type="submit" class="btn" style="width:100%;background:#8e44ad;color:#fff;">LDAP 登录</button>
<button type="submit" class="btn" style="width:100%;background:#7b5cd6;color:#fff;">LDAP 登录</button>
</form>
{{end}}
{{if .oauth2Enabled}}
<a href="/auth/oauth2" class="btn" style="width:100%;background:#3498db;color:#fff;text-align:center;display:block;margin-top:8px;">
<a href="/auth/oauth2" class="btn btn-primary" style="width:100%;text-align:center;display:block;margin-top:4px;">
{{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} 登录
</a>
{{end}}
</div>
</div>
</div>
</body>
</html>
{{end}}
-74
View File
@@ -1,74 +0,0 @@
{{define "sent"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发件箱 - MailGo</title>
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">发件箱</h2>
{{if not .messages}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无已发送邮件</p>
{{else}}
<table>
<thead>
<tr>
<th style="width:25%;">收件人</th>
<th style="width:45%;">主题</th>
<th style="width:20%;">时间</th>
<th style="width:10%;">操作</th>
</tr>
</thead>
<tbody>
{{range .messages}}
<tr>
<td>{{.ToAddr}}</td>
<td>
<a href="/sent/{{.ID}}" class="message-subject">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</td>
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
<td>
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/sent?page={{sub .page 1}}">上一页</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}} 页</span>
{{if lt .page .totalPages}}
<a href="/sent?page={{add .page 1}}">下一页</a>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
</body>
</html>
{{end}}
+55 -20
View File
@@ -3,46 +3,81 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>设置 - MailGo</title>
{{template "styles" .}}
</head>
<body>
<body class="page-settings">
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">设置</h2>
<div class="app-body">
{{template "sidebar" .}}
<main class="mail-main settings-main">
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
<h3 style="margin-bottom:12px;">修改密码</h3>
<form method="POST" action="/settings">
{{if .mustChange}}<div class="alert" style="border:1px solid #ffa940;background:#fff7e6;color:#d46b08;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:13.5px;">
⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。
</div>{{end}}
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
账号信息
</h2>
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px;">
<span class="avatar" style="width:52px;height:52px;font-size:22px;{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
<div>
<div style="font-size:16px;font-weight:600;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</div>
<div style="color:var(--text-3);font-size:12.5px;margin-top:3px;">
已用 {{formatBytes .currentUser.UsedBytes}} / 配额 {{formatBytes .currentUser.QuotaBytes}}
{{if .currentUser.IsAdmin}} · 管理员{{end}}
</div>
</div>
</div>
<div class="quota-bar" style="width:100%;" data-used="{{.currentUser.UsedBytes}}" data-quota="{{.currentUser.QuotaBytes}}"><i></i></div>
</div>
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:18px;">修改密码</h2>
<form method="POST" action="/settings" style="max-width:420px;">
<div class="form-group">
<label>当前密码</label>
<input type="password" name="old_password" required placeholder="请输入当前密码">
<input type="password" name="old_password" required placeholder="请输入当前密码" autocomplete="current-password">
</div>
<div class="form-group">
<label>新密码</label>
<input type="password" name="new_password" required placeholder="请输入新密码">
<input type="password" name="new_password" required placeholder="请输入新密码" autocomplete="new-password">
</div>
<div class="form-group">
<label>确认新密码</label>
<input type="password" name="confirm_password" required placeholder="请再次输入新密码">
<input type="password" name="confirm_password" required placeholder="请再次输入新密码" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">修改密码</button>
</form>
</div>
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:10px;">帮助</h2>
<p style="color:var(--text-2);font-size:13.5px;line-height:1.9;">
客户端收发信(IMAP / SMTP)配置:<br>
IMAP 服务器:{{.currentUser.Domain.Name}} 端口 143 / SSL 993<br>
SMTP 服务器:{{.currentUser.Domain.Name}} 端口 587(提交)/ SSL 465
</p>
</div>
</main>
</div>
</div>
<script>
(function () {
var bar = document.querySelector('.quota-bar[data-quota]');
if (bar) {
var used = parseInt(bar.dataset.used, 10) || 0;
var quota = parseInt(bar.dataset.quota, 10) || 1;
var pct = Math.min(100, Math.round(used / quota * 100));
bar.querySelector('i').style.width = pct + '%';
if (pct >= 90) bar.classList.add('warn');
if (pct >= 100) bar.classList.add('over');
}
})();
</script>
</body>
</html>
{{end}}

Some files were not shown because too many files have changed in this diff Show More