55 Commits
Author SHA1 Message Date
kevin 17d8dc1567 fix(imap): 序号按到达顺序(id ASC)分配,INTERNALDATE 使用到达时间
- ListAllByUserAndFolder 由 date DESC 改为 id ASC:新邮件永远追加到末尾
  (seq = EXISTS 数),与 Dovecot/Courier 一致;带旧 Date 头的新邮件不再
  插入中间导致既有邮件序号位移,seq 增量同步客户端不会漏收/标错
- INTERNALDATE 改用 CreatedAt(服务器接收时间, RFC 3501 §2.3.4),
  零值降级用 Date 头;不再返回可被伪造的 Date 头
- Web 分页排序补 id DESC tiebreak,与 IMAP 全链路一致
- ListDeletedByUserAndFolder 同步改为 id ASC
- 测试:TestSeqStoreClientSelfNumbered 替换为 TestSeqOrderArrival,
  断言到达顺序序号映射、旧 Date 头新邮件落在末尾、INTERNALDATE=CreatedAt
2026-08-20 15:18:55 +08:00
kevin cca7e4f092 Merge pull request 'feat(web): 多语言界面(中/英/日)与用户语言偏好字段' (#15) from dsh/mailgo:fix/legacy-ban-index into main
Reviewed-on: kevin/mailgo#15
2026-08-20 00:27:02 -04:00
dsh c881146a40 feat(web): 多语言界面(中/英/日)与用户语言偏好字段
- 新增 internal/i18n 包:以原始中文字符串为 key 的翻译目录(zh 恒等,
  en 兜底,ja 缺译回退 en),t/tf 模板函数与 Accept-Language 解析
  (按 q 值排序,兜底英语)
- User 模型新增 language 字段(auto|en|zh|ja,默认 auto),
  AutoMigrate 自动加列;UserStore 新增 UpdateLanguage
- 语言解析:登录用户按偏好(auto 时取浏览器 Accept-Language),
  登录/封禁页按浏览器语言;所有 c.HTML 渲染注入 Lang 模板数据
- 全部页面模板(用户页 + 管理后台 12 页)接入翻译:导航、文件夹、
  邮件列表/阅读/写信、设置、登录、封禁页、表单、表格、JS 确认框等;
  html lang 属性随语言切换;日期函数(shortDate/time12/time12m)
  与 folderLabel 改为语言感知(上午/下午 vs AM/PM vs 午前/午後)
- 设置页新增「界面语言」选择(自动/English/中文/日本語),
  /settings 表单支持单独提交语言偏好;管理后台用户表单可预设语言
- 封禁原因等入库中文文案在展示时经 t 翻译(未知 key 原样回退)
- 测试:i18n 单测(解析/回退/译文)、render 测试补 Lang 数据、
  时间格式化测试适配新签名;全量 go test 通过
2026-08-20 00:21:10 -04:00
kevin d1ffd10408 up: readme 2026-08-20 11:54:46 +08:00
kevin 975fd34f61 up: readme 2026-08-20 11:54:26 +08:00
kevin 5abe5c4528 Merge pull request 'fix(db): AutoMigrate 前清理旧模型遗留的同名非唯一索引,修复 MySQL 启动失败' (#14) from dsh/mailgo:fix/legacy-ban-index into main
Reviewed-on: kevin/mailgo#14
2026-08-19 23:41:04 -04:00
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
112 changed files with 16083 additions and 2667 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/
+305 -202
View File
@@ -1,136 +1,186 @@
# MailGo
Go 语言编写的轻量级邮件系统,集成 SMTP / IMAP / POP3 协议服务和 Web 管理界面。
> English | [中文](README_cn.md)
## 功能特性
A lightweight mail system written in Go, integrating SMTP / IMAP / POP3 protocol servers and a web admin interface.
Web frontend layout: top navigation + left folder sidebar + mail list (three-column design).
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
- **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
- **Web 邮箱**:收件箱、已发送、草稿箱、富文本编辑(Quill.js)、附件上传/下载
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计
- **外部认证**OAuth2Google / GitHub)、LDAP(可选,默认关闭)
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
- **多数据库**:默认 SQLite,可切换 MySQL
- **跨平台**Linux 生产部署 + Windows 本地调试
## Features
## 快速开始
- **Mail protocols**: SMTP (send), IMAP (sync), POP3 (receive), all with TLS support
- **External delivery**: authenticated users can send mail to external addresses (Gmail/Outlook, etc.), with a built-in outbound queue, **concurrent worker pool delivery** (default 4 workers + max 2 concurrent connections per recipient domain), direct MX delivery, STARTTLS, exponential backoff retry, bounce notifications and DKIM signing
- **Multi-language UI (i18n)**: Chinese / English / Japanese, selectable in user settings (or auto-follow the browser language via Accept-Language; English is the fallback), covering all user and admin pages, 12-hour time formats (AM/PM) and error messages
- **Web mail**: folder data-driven (Web and IMAP share the same MailboxService — whatever IMAP LIST returns is what the UI shows), system folders (Inbox / Sent / Drafts / Trash) plus custom folder management, delete moves to Trash (with restore / permanent delete / empty Trash), unread badges and search filters, select-all / batch delete, sender avatars, rich text editing (Quill.js), attachment upload/download
- **Admin console**: domain management, user management, automatic DKIM key generation, DNS configuration hints, full mail browsing, outbound queue management, IP ban management, dashboard statistics, all timestamps displayed as 12-hour format (AM/PM) in the web timezone
- **Protocol call logs**: every SMTP/IMAP/POP3 connection automatically records source IP, username, success/failure, failure reason and operation summary; filterable by protocol/status/IP/user/time for analyzing password brute-force, relay abuse and other attacks (kept 30 days by default, auto-cleaned)
- **IMAP new-mail push**: real-time push after local delivery (SMTP/Web compose) — clients hanging on IDLE receive new-mail notifications instantly (no polling); read/star/delete changes made by other clients are also synced in real time (IMAP STORE/EXPUNGE, POP3 delete, Web mark-read/delete)
- **Live connection monitoring**: admin console shows active SMTP/IMAP/POP3 connections (source IP, username, TLS, duration), auto-refreshing every 5 seconds; "Disconnect and ban" one-click bans all connections of that IP (ban for 180 days, unban anytime)
- **External auth**: OAuth2 (Google / GitHub), LDAP (optional, disabled by default)
- **Security**: BCrypt password hashing, automatic IP ban on login failures (**tiered bans**: the first 3 times reaching the failure threshold only count without banning, from the 4th time on it bans with escalating durations 30 min → 3 h → 3 months → 6 months, cleared on successful login; **no grace for enumeration attacks**: failures with a non-existent username skip the grace period and are banned on first trigger), outbound rate limiting (anti-abuse), relay denied to unauthenticated users (anti-open-relay), admins can unban
- **Multiple databases**: SQLite by default, switchable to MySQL
- **Cross-platform**: Linux production deployment + Windows local debugging
### 编译
## Screenshots
| Inbox | Mail view |
|-------|-----------|
| ![Inbox](docs/screenshots/inbox.png) | ![Mail view](docs/screenshots/view.png) |
| Compose | Settings | Login |
|---------|----------|-------|
| ![Compose](docs/screenshots/compose.png) | ![Settings](docs/screenshots/settings.png) | ![Login](docs/screenshots/login.png) |
> Screenshots are rendered with demo data; the actual UI may differ from your deployment.
## Quick Start
### Build
```bash
go build -o mailgo .
```
### 启动
### Start
```bash
./mailgo
```
首次启动会自动创建配置文件和数据库,默认管理员账户:
On first start the config file and database are created automatically, along with the default admin account:
| 项目 | 值 |
| Item | Value |
|------|-------|
| Email | `admin@example.com` |
| Initial password | Randomly generated (16 chars, digits + upper/lowercase letters), printed once in the startup log; or pre-set via the `MAILGO_ADMIN_PASSWORD` environment variable |
> ⚠️ This account is flagged "must change password on first login" — change it immediately on the Settings page.
### Access
| Page | URL |
|------|-----|
| 邮箱 | `admin@example.com` |
| 密码 | `admin` |
> ⚠️ 请在生产环境中立即修改默认密码。
### 访问
| 页面 | 地址 |
|------|------|
| 用户邮箱 | `http://localhost:8080/` |
| 管理后台 | `http://localhost:8080/admin` |
| User mailbox | `http://localhost:8080/` |
| Admin console | `http://localhost:8080/admin` |
---
## 配置文件
## Configuration File
配置文件路径(TOML 格式):
Config file path (TOML format):
| 系统 | 路径 |
|------|------|
| System | Path |
|--------|------|
| Linux | `/etc/mail_go/mail_go.toml` |
| Windows | `./win/etc/mail_go/mail_go.toml` |
首次启动自动生成,缺失字段自动补全。修改后需重启服务生效。
Auto-generated on first start; missing fields are auto-filled. A restart is required for changes to take effect.
### 完整配置参考
### Full Configuration Reference
```toml
[database]
driver = "sqlite" # sqlite | mysql
dsn = "/srv/mail_go/mail.db" # SQLite: 文件路径; MySQL: DSN 连接串
dsn = "/srv/mail_go/mail.db" # SQLite: file path; MySQL: DSN string
[storage]
base_dir = "/srv/mail_go" # 数据根目录
attach_dir = "/srv/mail_go/attachments" # 附件存储目录
base_dir = "/srv/mail_go" # data root directory
attach_dir = "/srv/mail_go/attachments" # attachment storage directory
[web]
addr = ":8080" # 监听地址,支持 TCP 端口或 Unix socket
addr = ":8080" # listen address; TCP port or Unix socket
secret_key = "" # web session signing key; if empty, a random
# key is generated on first start and written
# to this file (back it up: leaking it allows
# forging sessions, losing it invalidates all)
cookie_secure = true # session cookie sent over HTTPS only (Secure
# flag); set to false only for local HTTP debug
protocol_log_keep_days = 30 # retention days for SMTP/IMAP/POP3 protocol
# call logs; expired entries are cleaned up by a
# background task; 0 disables cleanup
[smtp]
addr = ":25" # SMTP 明文端口
tls_addr = ":465" # SMTPS 端口(需配置 TLS 证书)
domain = "example.com" # 邮件域名
tls_cert = "" # TLS 证书路径(留空则不启用 TLS
tls_key = "" # TLS 私钥路径
max_message_bytes = 67108864 # 单封邮件最大 64MB
addr = ":25" # SMTP plaintext port
tls_addr = ":465" # SMTPS port (requires TLS certs)
domain = "example.com" # mail domain
tls_cert = "" # TLS certificate path (empty = TLS disabled)
tls_key = "" # TLS private key path
max_message_bytes = 67108864 # max 64MB per message
[imap]
addr = ":143" # IMAP 明文端口
tls_addr = ":993" # IMAPS 端口
addr = ":143" # IMAP plaintext port
tls_addr = ":993" # IMAPS port
tls_cert = ""
tls_key = ""
[pop3]
addr = ":110" # POP3 明文端口
tls_addr = ":995" # POP3S 端口
addr = ":110" # POP3 plaintext port
tls_addr = ":995" # POP3S port
tls_cert = ""
tls_key = ""
[auth]
oauth2_enabled = false # 是否启用 OAuth2 登录
oauth2_enabled = false # enable OAuth2 login
oauth2_provider = "" # google | github
oauth2_client_id = ""
oauth2_client_secret = ""
oauth2_redirect_url = ""
ldap_enabled = false # 是否启用 LDAP 登录
ldap_server = "" # 例: ldap://localhost:389
ldap_bind_dn = "" # 例: cn=admin,dc=example,dc=com
ldap_enabled = false # enable LDAP login
ldap_server = "" # e.g. ldap://localhost:389
ldap_bind_dn = "" # e.g. cn=admin,dc=example,dc=com
ldap_bind_password = ""
ldap_search_base = "" # 例: ou=users,dc=example,dc=com
ldap_search_filter = "" # 例: (uid=%s)
ldap_search_base = "" # e.g. ou=users,dc=example,dc=com
ldap_search_filter = "" # e.g. (uid=%s)
ldap_use_tls = false
[ban]
max_fail_attempts = 5 # 登录失败次数阈值
ban_duration_min = 30 # 封禁时长(分钟)
max_fail_attempts = 5 # login failure threshold
ban_duration_min = 30 # 1st ban duration (minutes); then escalates:
# 2nd 3h → 3rd 3 months → 4th+ 6 months (cap)
# first 3 threshold hits only count, no ban;
# cleared on successful login
[caddy]
data_dir = "" # Caddy data directory (the one containing
# certificates/), used for one-click cert import
# in the admin console; auto-detected when empty
# (e.g. /var/lib/caddy/.local/share/caddy)
[outbound]
hostname = "" # EHLO 主机名,留空使用 [smtp] domain
poll_interval = 15 # 外发队列扫描间隔(秒)
max_attempts = 12 # 单封邮件最大投递尝试次数
retry_base_min = 5 # 重试退避基数(分钟),指数增长:5/10/20/40...
max_recipients = 50 # 单封邮件最大外部收件人数
max_per_min = 30 # 每用户每分钟最大外发数
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
connect_timeout = 30 # 连接远程 MX 超时(秒)
relay_host = "" # 智能主机(smarthost),留空则直投 MX
relay_port = 587 # 465 = 隐式 TLS,其他端口按需 STARTTLS
relay_user = "" # 中继认证用户名(AUTH PLAIN
relay_password = "" # 中继认证密码
relay_starttls = true # 非 465 端口是否使用 STARTTLS
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
hostname = "" # EHLO hostname; defaults to [smtp] domain
poll_interval = 15 # outbound queue scan interval (seconds)
workers = 4 # concurrent delivery workers (parallel sends,
# improves throughput for bulk mail; 0/1 serial)
batch_size = 50 # max messages picked per scan
max_concurrent_per_domain = 2 # max concurrent connections to one recipient
# domain (or relay), prevents spam-lookalike
# bursts; 0 = unlimited
max_attempts = 12 # max delivery attempts per message
retry_base_min = 5 # retry backoff base (minutes), exponential:
# 5/10/20/40...
max_recipients = 50 # max external recipients per message
max_per_min = 30 # max outgoing messages per user per minute
max_per_day = 500 # max outgoing messages per user per day;
# 0 disables external delivery
connect_timeout = 30 # remote MX connect timeout (seconds)
relay_host = "" # smarthost; empty = direct MX delivery
relay_port = 587 # 465 = implicit TLS, other ports use STARTTLS
relay_user = "" # relay auth username (AUTH PLAIN)
relay_password = "" # relay auth password
relay_starttls = true # use STARTTLS on non-465 ports
relay_tls_insecure = false # skip relay TLS certificate verification; certs
# are verified by default (protects relay
# credentials); only set to true for self-signed
# intranet relays when you accept the risk
ip_family = "ipv4" # outbound address family: ipv4 (default) | ipv6 | auto
source_ip = "" # outbound source IP binding (e.g. static IPv6);
# empty = kernel picks
```
---
## 常见配置场景
## Common Configuration Scenarios
### 1. 切换到 MySQL
### 1. Switching to MySQL
```toml
[database]
@@ -138,9 +188,9 @@ driver = "mysql"
dsn = "mailgo:YourPassword@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=Local"
```
MySQL DSN 格式:`用户名:密码@tcp(主机:端口)/数据库名?参数`
MySQL DSN format: `user:password@tcp(host:port)/dbname?params`
> 数据库和用户需提前在 MySQL 中创建:
> The database and user must be created in MySQL first:
> ```sql
> CREATE DATABASE mailgo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> CREATE USER 'mailgo'@'localhost' IDENTIFIED BY 'YourPassword';
@@ -148,16 +198,26 @@ MySQL DSN 格式:`用户名:密码@tcp(主机:端口)/数据库名?参数`
> FLUSH PRIVILEGES;
> ```
### 2. Web 使用 Unix Socket
### 2. Web over Unix Socket
```toml
[web]
addr = "/run/mail_go/web.sock"
```
`addr` `/` 开头时,Gin 自动以 Unix socket 方式监听。
When `addr` starts with `/`, Gin listens on a Unix socket automatically.
Nginx 反向代理配置:
### 3. Setting the Session Key (Container / Multi-Instance)
The session signing key can be overridden via the `MAILGO_SECRET_KEY` environment variable (takes precedence over the config file and is never written to disk):
```bash
MAILGO_SECRET_KEY="$(openssl rand -hex 32)" mail_go
```
Requirement: at least 16 bytes; when empty, the value from the config file is used (auto-generated on first start). After changing the key, all logged-in sessions are invalidated immediately and users must log in again.
Nginx reverse proxy configuration:
```nginx
server {
@@ -174,15 +234,15 @@ server {
}
```
> 确保 socket 目录存在并有写权限:
> Make sure the socket directory exists and is writable:
> ```bash
> mkdir -p /run/mail_go
> chown mailgo:mailgo /run/mail_go
> ```
### 3. 启用 TLS 加密
### 4. Enabling TLS Encryption
SMTP/IMAP/POP3 启用 TLS(三个协议共享同一证书,或分别配置):
Enable TLS for SMTP/IMAP/POP3 (all three protocols can share one certificate, or be configured separately):
```toml
[smtp]
@@ -198,16 +258,42 @@ tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
```
配置 TLS 后,系统会自动启动对应的加密端口(465/993/995)。管理后台添加域名时勾选 TLS 会自动切换端口号。
Once TLS is configured, the corresponding encrypted ports (465/993/995) start automatically. Checking the TLS box when adding a domain in the admin console switches the ports automatically.
> 可用 Let's Encrypt 获取免费证书:
> You can get free certificates from Let's Encrypt:
> ```bash
> certbot certonly --standalone -d mail.example.com
> # 证书路径: /etc/letsencrypt/live/mail.example.com/fullchain.pem
> # 私钥路径: /etc/letsencrypt/live/mail.example.com/privkey.pem
> # Certificate: /etc/letsencrypt/live/mail.example.com/fullchain.pem
> # Private key: /etc/letsencrypt/live/mail.example.com/privkey.pem
> ```
### 4. 启用 OAuth2 登录(Google 示例)
#### One-Click Certificate Import from Caddy
If this machine already serves HTTPS for the domain via [Caddy](https://caddyserver.com/) (Caddy issues and renews certificates automatically),
you can click **"Fetch certificate from Caddy"** on the **Domain Management → Edit Domain** page in the admin console
to import the certificate and private key from Caddy's storage into the mail server (automatically enabling TLS for that domain), without manually copying PEM files.
Wildcard certificates are supported (e.g. `*.example.com` matches `mail.example.com`).
Certificates support **hot reload**: they take effect immediately after import (or manual upload) without restarting the service — SMTP/IMAP/POP3
re-check and reload changed certificate files on every TLS handshake.
Because Caddy's certificate directory is only readable by the `caddy` user, install.sh installs a root-privileged certificate sync job
(`mailgo-caddy-sync.{path,timer}`) that mirrors Caddy's certificate tree to `/srv/mail_go/tls/caddy`,
so mail_go can always read it after renewals; an ACL grant is also applied as a fallback for direct reads.
This is configured automatically during installation, or can be run manually:
```bash
sudo ./install.sh setup-caddy-cert # auto-detect Caddy data dir, configure sync + ACL
sudo ./install.sh setup-caddy-cert /path/to/caddy/data # or specify the data dir manually
```
If Caddy's data directory is not in a common location, specify it explicitly in the config file:
```toml
[caddy]
data_dir = "/var/lib/caddy/.local/share/caddy"
```
### 5. Enabling OAuth2 Login (Google Example)
```toml
[auth]
@@ -218,9 +304,9 @@ oauth2_client_secret = "your-client-secret"
oauth2_redirect_url = "https://mail.example.com/auth/oauth2/callback"
```
> 需要在 [Google Cloud Console](https://console.cloud.google.com/) 创建 OAuth 2.0 客户端,授权回调地址填 `https://你的域名/auth/oauth2/callback`
> You need to create an OAuth 2.0 client in the [Google Cloud Console](https://console.cloud.google.com/) and set the authorized redirect URI to `https://your-domain/auth/oauth2/callback`.
### 5. 启用 LDAP 认证
### 6. Enabling LDAP Authentication
```toml
[auth]
@@ -233,147 +319,164 @@ ldap_search_filter = "(uid=%s)"
ldap_use_tls = true
```
### 6. 对外发送邮件(外部投递)
### 7. Sending Mail to External Recipients (External Delivery)
认证用户(Web 邮箱 / SMTP 提交)可以向外部邮箱地址发送邮件。系统通过
收件人域名的 MX 记录直投,自动处理 STARTTLS、指数退避重试,并为外发
邮件添加 DKIM 签名(使用后台域名管理中生成的密钥)。
Authenticated users (Web mail / SMTP submission) can send mail to external addresses. The system
delivers directly via the recipient domain's MX records, handling STARTTLS and exponential backoff
retries automatically, and DKIM-signs outgoing mail (using the key generated in the admin domain management).
```toml
[smtp]
domain = "example.com" # 必须设置为真实的邮件域名(EHLO/退信地址)
domain = "example.com" # must be the real mail domain (EHLO/bounce address)
[outbound]
hostname = "mail.example.com" # 建议与邮件主机名一致
hostname = "mail.example.com" # recommended to match the mail hostname
poll_interval = 15
max_attempts = 12
max_per_day = 500 # 设为 0 可完全禁用外部投递
max_per_day = 500 # set to 0 to disable external delivery entirely
```
需要满足的 DNS / 服务器条件(详见 `todo.md` 与后台 DNS 提示页):
Required DNS / server prerequisites (see `todo.md` and the admin DNS hints page for details):
| 项目 | 说明 |
|------|------|
| Item | Description |
|------|-------------|
| MX | `example.com MX 10 mail.example.com` |
| SPF | `example.com TXT "v=spf1 mx -all"` |
| DKIM | `default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=<公钥>"`(后台自动生成) |
| DKIM | `default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=<public key>"` (auto-generated in the admin console) |
| DMARC | `_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"` |
| PTR | 服务器 IP 反向解析指向邮件主机名(需向机房申请) |
| 网络 | 服务器 25 端口出站未被云厂商封锁 |
| PTR | Server IP reverse-resolves to the mail hostname (request from your hosting provider) |
| Network | Port 25 outbound is not blocked by the cloud provider |
安全策略:外部收件人仅接受已认证用户;`MAIL FROM` 必须与登录用户一致;
每用户每分钟/每日外发数受限;失败邮件会退信到发件人收件箱;
管理员可在后台「外发队列」查看投递状态、手动重试或取消。
Security policy: external recipients are only accepted from authenticated users; `MAIL FROM` must match
the logged-in user; per-user per-minute/day outbound limits apply; failed mail bounces back to the
sender's inbox; admins can view delivery status in the **Outbound Queue** and manually retry or cancel.
> **IPv4/IPv6**:默认仅使用 IPv4 出站(`ip_family = "ipv4"`),因为很多收件方
> (如 Gmail)会拒收没有 PTR 的 IPv6 地址,而 IPv4 通常具备正反向一致的 PTR
> 如需走 IPv6:请运营商为静态地址配置 PTR(指向 `mail.example.com`),
> 然后设置 `ip_family = "ipv6"` 并把 `source_ip` 绑定到该静态地址
> (避免内核使用轮换的临时隐私地址)。
> **IPv4/IPv6**: Only IPv4 outbound is used by default (`ip_family = "ipv4"`), because many recipients
> (e.g. Gmail) reject IPv6 addresses without PTR, while IPv4 usually has matching forward/reverse PTR.
> To use IPv6: ask your provider to configure PTR for the static address (pointing to `mail.example.com`),
> then set `ip_family = "ipv6"` and bind `source_ip` to that static address
> (to avoid the kernel picking rotating temporary privacy addresses).
### 7. 通过智能主机(smarthost)中继外发
### 8. Relaying Outbound Mail Through a Smarthost
服务器 IP 属于家庭宽带/动态 IP 段时,常被 Spamhaus PBL 等策略列表收录,
MicrosoftOutlook/Hotmail)等收件方会直接拒收。此时建议把外发邮件交给
第三方 SMTP 中继(Mailgun / SendGrid / Amazon SES / 阿里云邮件推送等),
`[outbound]` 中配置即可,所有外部投递自动改走中继:
When the server IP is on a residential/dynamic IP range, it is often listed in policy lists such as Spamhaus PBL,
and recipients like Microsoft (Outlook/Hotmail) reject the mail outright. In that case, it is recommended to hand
outbound mail to a third-party SMTP relay (Mailgun / SendGrid / Amazon SES / Aliyun Direct Mail, etc.),
configured in `[outbound]` — all external delivery automatically routes through the relay:
```toml
[outbound]
relay_host = "smtp.example-relay.com"
relay_port = 587 # 465 为隐式 TLS
relay_port = 587 # 465 = implicit TLS
relay_user = "your-api-user"
relay_password = "your-api-key"
relay_starttls = true
```
中继使用 AUTH PLAIN 认证;本地收件人仍走本地投递,不受影响。
The relay uses AUTH PLAIN authentication; local recipients still use local delivery, unaffected.
### 9. Setting the Initial Admin Password
When the admin account is created on first start, the initial password is randomly generated by default
and printed once in the startup log; to pre-set it (e.g. for deployment scripts), use the `MAILGO_ADMIN_PASSWORD`
environment variable:
```bash
MAILGO_ADMIN_PASSWORD="$(openssl rand -base64 12)" ./mailgo
```
This variable only takes effect when the admin account is first created (ignored if the account already exists);
either way, the password must be changed on first login.
---
## 端口速查
## Port Quick Reference
| 协议 | 明文端口 | TLS 端口 | 说明 |
|------|---------|---------|------|
| SMTP | 25 | 465 | 邮件发送 |
| IMAP | 143 | 993 | 邮箱同步 |
| POP3 | 110 | 995 | 邮件收取 |
| Web | 8080 | — | Web 界面(可配 Unix socket |
| Protocol | Plaintext port | TLS port | Description |
|----------|----------------|----------|-------------|
| SMTP | 25 | 465 | Mail sending |
| IMAP | 143 | 993 | Mailbox sync |
| POP3 | 110 | 995 | Mail retrieval |
| Web | 8080 | — | Web UI (Unix socket supported) |
---
## 目录结构
## Directory Structure
```
mailgo/
├── main.go # 程序入口
├── main.go # program entry
├── config/
│ ├── config.go # 配置加载与合并
│ └── defaults.go # 默认常量
│ ├── config.go # config loading and merging
│ └── defaults.go # default constants
├── internal/
│ ├── db/
│ │ ├── db.go # 数据库初始化(SQLite/MySQL
│ │ └── models.go # GORM 模型定义
│ │ ├── db.go # database initialization (SQLite/MySQL)
│ │ └── models.go # GORM model definitions
│ ├── store/
│ │ ├── stores.go # Store 聚合器
│ │ ├── user_store.go # 用户数据操作
│ │ ├── mail_store.go # 邮件数据操作
│ │ ├── domain_store.go # 域名数据操作
│ │ ├── attachment_store.go # 附件数据操作
│ │ ├── outbound_store.go # 外发队列数据操作
│ │ ── ban_store.go # 封禁数据操作
│ ├── smtp_server/server.go # SMTP 服务
│ │ ├── stores.go # Store aggregator
│ │ ├── user_store.go # user data operations
│ │ ├── mail_store.go # mail data operations
│ │ ├── mailbox_store.go # folder (IMAP mailbox) data operations
│ │ ├── domain_store.go # domain data operations
│ │ ├── attachment_store.go # attachment data operations
│ │ ── outbound_store.go # outbound queue data operations
│ ├── ban_store.go # ban data operations
│ │ └── protocol_log_store.go # protocol call log data operations
│ ├── smtp_server/server.go # SMTP server
│ ├── outbound/
│ │ ├── mailer.go # MX 查询与 SMTP 出站客户端
│ │ ├── manager.go # 外发队列、重试、限速、退信
│ │ └── sign.go # DKIM 签名
│ │ ├── mailer.go # MX lookup and SMTP outbound client
│ │ ├── manager.go # outbound queue, retry, rate limiting, bounces
│ │ └── sign.go # DKIM signing
│ ├── imap_server/
│ │ ├── server.go # IMAP 服务
│ │ ── backend.go # IMAP 后端
├── pop3_server/server.go # POP3 服务
│ ├── storage/attachment.go # 附件文件存储
│ ├── dkim/keys.go # DKIM 密钥生成
│ │ ├── server.go # IMAP server (listener/capabilities/cross-session push)
│ │ ── service.go # MailboxService (shared by IMAP sessions and Web)
│ └── session.go # IMAP Session implementation (SELECT/FETCH/STORE/EXPUNGE)
│ ├── pop3_server/server.go # POP3 server
│ ├── connhub/hub.go # protocol connection registry (live connection monitoring)
│ ├── storage/attachment.go # attachment file storage
│ ├── dkim/keys.go # DKIM key generation
│ ├── auth/
│ │ ├── provider.go # 认证接口
│ │ ├── oauth2.go # OAuth2 认证
│ │ └── ldap.go # LDAP 认证
│ │ ├── provider.go # auth interface
│ │ ├── oauth2.go # OAuth2 authentication
│ │ └── ldap.go # LDAP authentication
│ └── web/
│ ├── server.go # Web 路由与模板加载
│ ├── server.go # web routing and template loading
│ ├── handlers/
│ │ ├── auth.go # 登录/登出/OAuth2/LDAP
│ │ ├── mail.go # 邮箱操作
│ │ └── admin.go # 管理后台
│ │ ├── auth.go # login/logout/OAuth2/LDAP
│ │ ├── mail.go # mailbox operations
│ │ └── admin.go # admin console
│ ├── middleware/
│ │ ├── auth.go # 会话认证
│ │ ├── admin.go # 管理员权限
│ │ └── ban.go # IP 封禁检查
│ └── templates/ # HTML 模板
│ ├── *.html # 用户页面
│ └── admin/*.html # 管理页面
│ │ ├── auth.go # session authentication
│ │ ├── admin.go # admin authorization
│ │ └── ban.go # IP ban check
│ └── templates/ # HTML templates
│ ├── *.html # user pages
│ └── admin/*.html # admin pages
└── .gitignore
```
### 运行时数据目录(Linux
### Runtime Data Directories (Linux)
| 路径 | 说明 |
|------|------|
| `/etc/mail_go/` | 配置文件 |
| `/srv/mail_go/` | 数据库 + 附件存储 |
| Path | Description |
|------|-------------|
| `/etc/mail_go/` | config file |
| `/srv/mail_go/` | database + attachment storage |
### 运行时数据目录(Windows 调试)
### Runtime Data Directories (Windows Debug)
| 路径 | 说明 |
|------|------|
| `./win/etc/mail_go/` | 配置文件 |
| `./win/srv/mail_go/` | 数据库 + 附件存储 |
| Path | Description |
|------|-------------|
| `./win/etc/mail_go/` | config file |
| `./win/srv/mail_go/` | database + attachment storage |
---
## Linux 服务部署
## Linux Service Deployment
### systemd 服务文件
### systemd Unit File
创建 `/etc/systemd/system/mailgo.service`
Create `/etc/systemd/system/mailgo.service`:
```ini
[Unit]
@@ -390,7 +493,7 @@ Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# 安全加固
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
@@ -400,68 +503,68 @@ ReadWritePaths=/srv/mail_go /run/mail_go /etc/mail_go
WantedBy=multi-user.target
```
### 启动服务
### Starting the Service
```bash
# 创建系统用户
# Create the system user
sudo useradd -r -s /sbin/nologin -d /srv/mail_go mailgo
# 设置目录权限
# Set directory ownership
sudo chown -R mailgo:mailgo /srv/mail_go /etc/mail_go
# 启用并启动
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable mailgo
sudo systemctl start mailgo
# 查看日志
# View logs
sudo journalctl -u mailgo -f
```
---
## DNS 配置
## DNS Configuration
邮件服务需要以下 DNS 记录(管理后台 → DNS 提示 页面可查看完整配置):
The mail service requires the following DNS records (the admin console → DNS hints page shows the full configuration):
| 记录类型 | 名称 | 值 | 说明 |
|---------|------|-----|------|
| A | mail | 服务器 IP | 邮件服务器地址 |
| MX | @ | mail.example.com | 邮件路由 |
| TXT | @ | `v=spf1 mx ~all` | SPF 反垃圾 |
| TXT | default._domainkey | DKIM 公钥(后台自动生成) | DKIM 签名验证 |
| TXT | _dmarc | `v=DMARC1; p=none; rua=mailto:admin@example.com` | DMARC 策略 |
| Type | Name | Value | Description |
|------|------|-------|-------------|
| A | mail | server IP | mail server address |
| MX | @ | mail.example.com | mail routing |
| TXT | @ | `v=spf1 mx ~all` | SPF anti-spam |
| TXT | default._domainkey | DKIM public key (auto-generated in the admin console) | DKIM signature verification |
| TXT | _dmarc | `v=DMARC1; p=none; rua=mailto:admin@example.com` | DMARC policy |
---
## 技术栈
## Tech Stack
| 组件 | 技术 |
|------|------|
| 语言 | Go 1.25+ |
| Web 框架 | Gin |
| 模板引擎 | html/template |
| Component | Technology |
|-----------|------------|
| Language | Go 1.25+ |
| Web framework | Gin |
| Template engine | html/template |
| ORM | GORM |
| 数据库 | SQLite(默认)/ MySQL |
| 配置格式 | TOML |
| Database | SQLite (default) / MySQL |
| Config format | TOML |
| SMTP | github.com/emersion/go-smtp |
| IMAP | github.com/emersion/go-imap v1 |
| POP3 | 手工实现 TCP 协议 |
| 密码哈希 | golang.org/x/crypto/bcrypt |
| 富文本 | Quill.js (CDN) |
| IMAP | github.com/emersion/go-imap/v2 |
| POP3 | hand-implemented TCP protocol |
| Password hashing | golang.org/x/crypto/bcrypt |
| Rich text | Quill.js (embedded via go:embed, works offline) |
| OAuth2 | golang.org/x/oauth2 |
| LDAP | github.com/go-ldap/ldap/v3 |
| DKIM | RSA 2048 自动生成 |
| DKIM | RSA 2048 auto-generated |
---
## 默认账户
## Default Account
| 角色 | 邮箱 | 密码 | 默认配额 |
|------|------|------|---------|
| 管理员 | admin@example.com | admin | 5 GB |
| Role | Email | Initial password | Default quota |
|------|-------|------------------|---------------|
| Admin | admin@example.com | Randomly generated (printed once in the startup log) or set via `MAILGO_ADMIN_PASSWORD` | 5 GB |
> ⚠️ 部署到生产环境后请立即修改管理员密码。
> ⚠️ Password change is mandatory on first login (also triggered when an admin resets the password); change it immediately after production deployment.
## License
+561
View File
@@ -0,0 +1,561 @@
# MailGo
> [English](README.md) | 中文
Go 语言编写的轻量级邮件系统,集成 SMTP / IMAP / POP3 协议服务和 Web 管理界面。
Web 前端的布局:顶部导航 + 左侧文件夹栏 + 邮件列表三栏设计。
## 功能特性
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
- **外部投递**:认证用户可向外部邮箱(Gmail/Outlook 等)发送邮件,内置外发队列、**并发 worker 池投递**(默认 4 线程 + 每收件域并发上限 2)、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
- **多语言界面(i18n)**:中文 / 英文 / 日文,在「设置」页可选择界面语言(或自动跟随浏览器 Accept-Language,英语兜底),覆盖全部用户页与管理后台、12 小时制时间与错误提示
- **Web 邮箱**:文件夹数据驱动(Web 与 IMAP 共享 MailboxServiceIMAP LIST 返回什么界面就显示什么),系统文件夹(收件箱 / 已发送 / 草稿箱 / 垃圾箱)与自定义文件夹管理、删除邮件移入垃圾箱(支持恢复 / 彻底删除 / 清空)、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计、时间统一按 Web 时区显示 12 小时制(上午/下午)
- **协议调用日志**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(**阶段性封禁**:前 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) |
> 截图使用演示数据渲染,实际界面以部署为准。
## 快速开始
### 编译
```bash
go build -o mailgo .
```
### 启动
```bash
./mailgo
```
首次启动会自动创建配置文件和数据库,默认管理员账户:
| 项目 | 值 |
|------|-----|
| 邮箱 | `admin@example.com` |
| 初始密码 | 随机生成(16 位,数字+大小写字母),仅在启动日志中打印一次;也可用环境变量 `MAILGO_ADMIN_PASSWORD` 预先指定 |
> ⚠️ 该账户已标记「首次登录必须修改密码」,登录后请在 设置 页面立即改密。
### 访问
| 页面 | 地址 |
|------|------|
| 用户邮箱 | `http://localhost:8080/` |
| 管理后台 | `http://localhost:8080/admin` |
---
## 配置文件
配置文件路径(TOML 格式):
| 系统 | 路径 |
|------|------|
| Linux | `/etc/mail_go/mail_go.toml` |
| Windows | `./win/etc/mail_go/mail_go.toml` |
首次启动自动生成,缺失字段自动补全。修改后需重启服务生效。
### 完整配置参考
```toml
[database]
driver = "sqlite" # sqlite | mysql
dsn = "/srv/mail_go/mail.db" # SQLite: 文件路径; MySQL: DSN 连接串
[storage]
base_dir = "/srv/mail_go" # 数据根目录
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 明文端口
tls_addr = ":465" # SMTPS 端口(需配置 TLS 证书)
domain = "example.com" # 邮件域名
tls_cert = "" # TLS 证书路径(留空则不启用 TLS)
tls_key = "" # TLS 私钥路径
max_message_bytes = 67108864 # 单封邮件最大 64MB
[imap]
addr = ":143" # IMAP 明文端口
tls_addr = ":993" # IMAPS 端口
tls_cert = ""
tls_key = ""
[pop3]
addr = ":110" # POP3 明文端口
tls_addr = ":995" # POP3S 端口
tls_cert = ""
tls_key = ""
[auth]
oauth2_enabled = false # 是否启用 OAuth2 登录
oauth2_provider = "" # google | github
oauth2_client_id = ""
oauth2_client_secret = ""
oauth2_redirect_url = ""
ldap_enabled = false # 是否启用 LDAP 登录
ldap_server = "" # 例: ldap://localhost:389
ldap_bind_dn = "" # 例: cn=admin,dc=example,dc=com
ldap_bind_password = ""
ldap_search_base = "" # 例: ou=users,dc=example,dc=com
ldap_search_filter = "" # 例: (uid=%s)
ldap_use_tls = false
[ban]
max_fail_attempts = 5 # 登录失败次数阈值
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 # 单封邮件最大外部收件人数
max_per_min = 30 # 每用户每分钟最大外发数
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
connect_timeout = 30 # 连接远程 MX 超时(秒)
relay_host = "" # 智能主机(smarthost),留空则直投 MX
relay_port = 587 # 465 = 隐式 TLS,其他端口按需 STARTTLS
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 地址),留空由内核选择
```
---
## 常见配置场景
### 1. 切换到 MySQL
```toml
[database]
driver = "mysql"
dsn = "mailgo:YourPassword@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=Local"
```
MySQL DSN 格式:`用户名:密码@tcp(主机:端口)/数据库名?参数`
> 数据库和用户需提前在 MySQL 中创建:
> ```sql
> CREATE DATABASE mailgo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> CREATE USER 'mailgo'@'localhost' IDENTIFIED BY 'YourPassword';
> GRANT ALL PRIVILEGES ON mailgo.* TO 'mailgo'@'localhost';
> FLUSH PRIVILEGES;
> ```
### 2. Web 使用 Unix Socket
```toml
[web]
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
server {
listen 80;
server_name mail.example.com;
location / {
proxy_pass http://unix:/run/mail_go/web.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> 确保 socket 目录存在并有写权限:
> ```bash
> mkdir -p /run/mail_go
> chown mailgo:mailgo /run/mail_go
> ```
### 3. 启用 TLS 加密
为 SMTP/IMAP/POP3 启用 TLS(三个协议共享同一证书,或分别配置):
```toml
[smtp]
tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
[imap]
tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
[pop3]
tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
```
配置 TLS 后,系统会自动启动对应的加密端口(465/993/995)。管理后台添加域名时勾选 TLS 会自动切换端口号。
> 可用 Let's Encrypt 获取免费证书:
> ```bash
> certbot certonly --standalone -d mail.example.com
> # 证书路径: /etc/letsencrypt/live/mail.example.com/fullchain.pem
> # 私钥路径: /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
[auth]
oauth2_enabled = true
oauth2_provider = "google"
oauth2_client_id = "your-client-id.apps.googleusercontent.com"
oauth2_client_secret = "your-client-secret"
oauth2_redirect_url = "https://mail.example.com/auth/oauth2/callback"
```
> 需要在 [Google Cloud Console](https://console.cloud.google.com/) 创建 OAuth 2.0 客户端,授权回调地址填 `https://你的域名/auth/oauth2/callback`。
### 5. 启用 LDAP 认证
```toml
[auth]
ldap_enabled = true
ldap_server = "ldap://ldap.example.com:389"
ldap_bind_dn = "cn=admin,dc=example,dc=com"
ldap_bind_password = "ldap_admin_password"
ldap_search_base = "ou=users,dc=example,dc=com"
ldap_search_filter = "(uid=%s)"
ldap_use_tls = true
```
### 6. 对外发送邮件(外部投递)
认证用户(Web 邮箱 / SMTP 提交)可以向外部邮箱地址发送邮件。系统通过
收件人域名的 MX 记录直投,自动处理 STARTTLS、指数退避重试,并为外发
邮件添加 DKIM 签名(使用后台域名管理中生成的密钥)。
```toml
[smtp]
domain = "example.com" # 必须设置为真实的邮件域名(EHLO/退信地址)
[outbound]
hostname = "mail.example.com" # 建议与邮件主机名一致
poll_interval = 15
max_attempts = 12
max_per_day = 500 # 设为 0 可完全禁用外部投递
```
需要满足的 DNS / 服务器条件(详见 `todo.md` 与后台 DNS 提示页):
| 项目 | 说明 |
|------|------|
| MX | `example.com MX 10 mail.example.com` |
| SPF | `example.com TXT "v=spf1 mx -all"` |
| DKIM | `default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=<公钥>"`(后台自动生成) |
| DMARC | `_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"` |
| PTR | 服务器 IP 反向解析指向邮件主机名(需向机房申请) |
| 网络 | 服务器 25 端口出站未被云厂商封锁 |
安全策略:外部收件人仅接受已认证用户;`MAIL FROM` 必须与登录用户一致;
每用户每分钟/每日外发数受限;失败邮件会退信到发件人收件箱;
管理员可在后台「外发队列」查看投递状态、手动重试或取消。
> **IPv4/IPv6**:默认仅使用 IPv4 出站(`ip_family = "ipv4"`),因为很多收件方
> (如 Gmail)会拒收没有 PTR 的 IPv6 地址,而 IPv4 通常具备正反向一致的 PTR。
> 如需走 IPv6:请运营商为静态地址配置 PTR(指向 `mail.example.com`),
> 然后设置 `ip_family = "ipv6"` 并把 `source_ip` 绑定到该静态地址
> (避免内核使用轮换的临时隐私地址)。
### 7. 通过智能主机(smarthost)中继外发
服务器 IP 属于家庭宽带/动态 IP 段时,常被 Spamhaus PBL 等策略列表收录,
MicrosoftOutlook/Hotmail)等收件方会直接拒收。此时建议把外发邮件交给
第三方 SMTP 中继(Mailgun / SendGrid / Amazon SES / 阿里云邮件推送等),
`[outbound]` 中配置即可,所有外部投递自动改走中继:
```toml
[outbound]
relay_host = "smtp.example-relay.com"
relay_port = 587 # 465 为隐式 TLS
relay_user = "your-api-user"
relay_password = "your-api-key"
relay_starttls = true
```
中继使用 AUTH PLAIN 认证;本地收件人仍走本地投递,不受影响。
### 8. 指定初始管理员密码
首次启动创建管理员账户时,初始密码默认随机生成并仅在启动日志中打印一次;
如需预先指定(例如部署脚本),可设置环境变量 `MAILGO_ADMIN_PASSWORD`
```bash
MAILGO_ADMIN_PASSWORD="$(openssl rand -base64 12)" ./mailgo
```
该变量仅在首次创建管理员账户时生效(账户已存在则忽略);无论哪种方式,
首次登录都会强制修改密码。
---
## 端口速查
| 协议 | 明文端口 | TLS 端口 | 说明 |
|------|---------|---------|------|
| SMTP | 25 | 465 | 邮件发送 |
| IMAP | 143 | 993 | 邮箱同步 |
| POP3 | 110 | 995 | 邮件收取 |
| Web | 8080 | — | Web 界面(可配 Unix socket |
---
## 目录结构
```
mailgo/
├── main.go # 程序入口
├── config/
│ ├── config.go # 配置加载与合并
│ └── defaults.go # 默认常量
├── internal/
│ ├── db/
│ │ ├── db.go # 数据库初始化(SQLite/MySQL
│ │ └── models.go # GORM 模型定义
│ ├── store/
│ │ ├── stores.go # Store 聚合器
│ │ ├── user_store.go # 用户数据操作
│ │ ├── mail_store.go # 邮件数据操作
│ │ ├── mailbox_store.go # 文件夹(IMAP mailbox)数据操作
│ │ ├── domain_store.go # 域名数据操作
│ │ ├── attachment_store.go # 附件数据操作
│ │ ├── outbound_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 服务(监听器/能力/跨会话推送)
│ │ ├── service.go # MailboxServiceIMAP 会话与 Web 共享)
│ │ └── session.go # IMAP Session 实现(SELECT/FETCH/STORE/EXPUNGE
│ ├── pop3_server/server.go # POP3 服务
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
│ ├── storage/attachment.go # 附件文件存储
│ ├── dkim/keys.go # DKIM 密钥生成
│ ├── auth/
│ │ ├── provider.go # 认证接口
│ │ ├── oauth2.go # OAuth2 认证
│ │ └── ldap.go # LDAP 认证
│ └── web/
│ ├── server.go # Web 路由与模板加载
│ ├── handlers/
│ │ ├── auth.go # 登录/登出/OAuth2/LDAP
│ │ ├── mail.go # 邮箱操作
│ │ └── admin.go # 管理后台
│ ├── middleware/
│ │ ├── auth.go # 会话认证
│ │ ├── admin.go # 管理员权限
│ │ └── ban.go # IP 封禁检查
│ └── templates/ # HTML 模板
│ ├── *.html # 用户页面
│ └── admin/*.html # 管理页面
└── .gitignore
```
### 运行时数据目录(Linux
| 路径 | 说明 |
|------|------|
| `/etc/mail_go/` | 配置文件 |
| `/srv/mail_go/` | 数据库 + 附件存储 |
### 运行时数据目录(Windows 调试)
| 路径 | 说明 |
|------|------|
| `./win/etc/mail_go/` | 配置文件 |
| `./win/srv/mail_go/` | 数据库 + 附件存储 |
---
## Linux 服务部署
### systemd 服务文件
创建 `/etc/systemd/system/mailgo.service`
```ini
[Unit]
Description=MailGo Mail Server
After=network.target mysql.service
[Service]
Type=simple
User=mailgo
Group=mailgo
WorkingDirectory=/opt/mailgo
ExecStart=/opt/mailgo/mailgo
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# 安全加固
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/mail_go /run/mail_go /etc/mail_go
[Install]
WantedBy=multi-user.target
```
### 启动服务
```bash
# 创建系统用户
sudo useradd -r -s /sbin/nologin -d /srv/mail_go mailgo
# 设置目录权限
sudo chown -R mailgo:mailgo /srv/mail_go /etc/mail_go
# 启用并启动
sudo systemctl daemon-reload
sudo systemctl enable mailgo
sudo systemctl start mailgo
# 查看日志
sudo journalctl -u mailgo -f
```
---
## DNS 配置
邮件服务需要以下 DNS 记录(管理后台 → DNS 提示 页面可查看完整配置):
| 记录类型 | 名称 | 值 | 说明 |
|---------|------|-----|------|
| A | mail | 服务器 IP | 邮件服务器地址 |
| MX | @ | mail.example.com | 邮件路由 |
| TXT | @ | `v=spf1 mx ~all` | SPF 反垃圾 |
| TXT | default._domainkey | DKIM 公钥(后台自动生成) | DKIM 签名验证 |
| TXT | _dmarc | `v=DMARC1; p=none; rua=mailto:admin@example.com` | DMARC 策略 |
---
## 技术栈
| 组件 | 技术 |
|------|------|
| 语言 | Go 1.25+ |
| Web 框架 | Gin |
| 模板引擎 | html/template |
| ORM | GORM |
| 数据库 | SQLite(默认)/ MySQL |
| 配置格式 | TOML |
| SMTP | github.com/emersion/go-smtp |
| IMAP | github.com/emersion/go-imap/v2 |
| POP3 | 手工实现 TCP 协议 |
| 密码哈希 | golang.org/x/crypto/bcrypt |
| 富文本 | Quill.jsgo:embed 内嵌,离线可用) |
| OAuth2 | golang.org/x/oauth2 |
| LDAP | github.com/go-ldap/ldap/v3 |
| DKIM | RSA 2048 自动生成 |
---
## 默认账户
| 角色 | 邮箱 | 初始密码 | 默认配额 |
|------|------|---------|---------|
| 管理员 | admin@example.com | 随机生成(启动日志打印一次)或 `MAILGO_ADMIN_PASSWORD` 指定 | 5 GB |
> ⚠️ 首次登录强制修改密码(管理员重置密码同样触发);部署到生产环境后请立即完成改密。
## License
MIT
+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("迁移完成 ✅")
}
+172 -16
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"`
}
@@ -159,7 +213,10 @@ func defaultConfig() *Config {
AttachDir: filepath.Join(bd, "attachments"),
},
Web: WebConfig{
Addr: DefaultWebPort,
Addr: DefaultWebPort,
CookieSecure: true,
ProtocolLogKeepDays: DefaultProtocolLogKeepDays,
Timezone: DefaultTimezone,
},
SMTP: SMTPConfig{
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
@@ -184,17 +241,22 @@ func defaultConfig() *Config {
MaxFailAttempts: 5,
BanDurationMin: 30,
},
// Caddy: 留空则自动探测常见数据目录,无需配置
Caddy: CaddyConfig{},
Outbound: OutboundConfig{
PollInterval: 15, // 15 秒扫描一次队列
MaxAttempts: 12, // 最多尝试 12 次
RetryBaseMin: 5, // 5/10/20/40/... 分钟指数退避
MaxRecipients: 50, // 单封最多 50 个外部收件人
MaxPerMin: 30, // 每用户每分钟 30 封
MaxPerDay: 500,
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
RelayPort: 587, // smarthost 默认提交端口
RelayStartTLS: true,
IPFamily: "ipv4",
PollInterval: 15, // 15 秒扫描一次队列
MaxAttempts: 12, // 最多尝试 12 次
RetryBaseMin: 5, // 5/10/20/40/... 分钟指数退避
MaxRecipients: 50, // 单封最多 50 个外部收件人
MaxPerMin: 30, // 每用户每分钟 30 封
MaxPerDay: 500,
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
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=
+177 -13
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} =========="
@@ -352,21 +513,24 @@ do_status() {
# ======================== 入口 ========================
case "${1:-}" in
install) do_install ;;
uninstall) do_uninstall ;;
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
status) do_status ;;
install) do_install ;;
uninstall) do_uninstall ;;
start) do_start ;;
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 — 卸载服务(可选保留数据)"
echo " start — 启动服务"
echo " stop — 停止服务"
echo " restart — 重启服务"
echo " status — 查看服务状态"
echo " install — 完整安装/更新(拉代码+编译+部署+启动+开机自启)"
echo " uninstall — 卸载服务(可选保留数据)"
echo " start — 启动服务"
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)
}
+127 -30
View File
@@ -6,17 +6,23 @@ import (
// User represents a mail user in the system.
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
DomainID uint `gorm:"index" json:"domain_id"`
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
DomainID uint `gorm:"index" json:"domain_id"`
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
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:"-"`
// Language 界面语言偏好:auto(跟随浏览器 Accept-Language| en | zh | ja。
// 默认 auto,浏览器语言无法识别时回退英语。
Language string `gorm:"size:8;default:auto" json:"language"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for User.
@@ -48,20 +54,25 @@ func (Domain) TableName() string {
// Message represents an email message in the system.
type Message struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user"`
MessageID string `gorm:"size:255;index" json:"message_id"`
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
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"`
RawData string `gorm:"type:mediumtext" json:"raw_data"`
IsRead bool `gorm:"default:false" json:"is_read"`
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user"`
MessageID string `gorm:"size:255;index" json:"message_id"`
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
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: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"`
}
@@ -107,10 +118,16 @@ 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"`
Reason string `gorm:"size:255" json:"reason"`
FailCount int `gorm:"default:0" json:"fail_count"`
ID uint `gorm:"primaryKey" json:"id"`
// 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 +136,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 +181,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"
}
+736
View File
@@ -0,0 +1,736 @@
package i18n
// en 英文目录:key 为界面中的原始中文字符串。
// 英语是兜底语言:ja 缺译时回退这里,未知语言也回退这里。
var en = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "AM",
"下午": "PM",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "Inbox",
"已发送": "Sent",
"草稿箱": "Drafts",
"已删除": "Trash",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "Mail",
"搜索邮件(发件人 / 主题)": "Search mail (sender / subject)",
"管理后台": "Admin",
"设置": "Settings",
"退出登录": "Log out",
"写信": "Compose",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "Select all / deselect all",
"全选": "Select all",
"刷新": "Refresh",
"删除": "Delete",
"彻底删除": "Delete permanently",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "Empty the Trash folder? This cannot be undone.",
"清空文件夹": "Empty folder",
"共 %d 封": "%d messages",
"暂无邮件": "No messages",
"收件人:%s": "To: %s",
"致:": "To: ",
"(无主题)": "(no subject)",
"[HTML 邮件]": "[HTML email]",
"确定要恢复这封邮件吗?": "Restore this message?",
"恢复到收件箱": "Restore to inbox",
"确定要删除这封邮件吗?": "Delete this message?",
"第 %d / %d 页": "Page %d of %d",
"上一页": "Previous",
"下一页": "Next",
"确定要删除选中的 %s 封邮件吗?": "Delete the %s selected message(s)?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "View message",
"返回": "Back",
"回复": "Reply",
"恢复": "Restore",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "Permanently delete this message? This cannot be undone.",
"删除邮件": "Delete message",
"抄送:": "Cc: ",
// ---------- 写信(compose.html ----------
"发送": "Send",
"附件": "Attach",
"取消": "Cancel",
"撰写新邮件": "New message",
"收件人": "To",
"输入收件人邮箱地址": "Recipient email address",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "Separate multiple addresses with commas (optional)",
"主题": "Subject",
"输入邮件主题": "Email subject",
"请输入邮件内容...": "Enter your message...",
"附件配额": "Attachment quota",
"移除": "Remove",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ First login / password has been reset. Please change your password before using the mailbox.",
"账号信息": "Account info",
"已用 %s / 配额 %s": "Used %s / Quota %s",
"管理员": "Admin",
"修改密码": "Change password",
"当前密码": "Current password",
"请输入当前密码": "Enter current password",
"新密码": "New password",
"请输入新密码": "Enter new password",
"确认新密码": "Confirm new password",
"请再次输入新密码": "Re-enter new password",
"界面语言": "Interface language",
"自动(跟随浏览器)": "Auto (browser)",
"保存": "Save",
"语言偏好已保存": "Language preference saved",
"语言偏好保存失败": "Failed to save language preference",
"帮助": "Help",
"客户端收发信(IMAP / SMTP)配置:": "IMAP / SMTP client settings:",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP server: %s port %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP server: %s port %d (submission) / SSL %d",
// ---------- 登录(login.html ----------
"登录": "Log in",
"登录您的邮箱账户": "Log in to your mailbox",
"邮箱地址": "Email address",
"密码": "Password",
"请输入密码": "Enter password",
"登 录": "Log in",
"或": "or",
"LDAP 用户名": "LDAP username",
"LDAP 密码": "LDAP password",
"LDAP 登录": "LDAP sign in",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "Access denied",
"您的 IP 已被暂时禁止访问": "Your IP has been temporarily blocked",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "Your IP address has been temporarily banned due to too many failed login attempts.",
"IP 地址": "IP address",
"封禁档位": "Ban level",
"第 %d 次封禁": "Ban #%d",
"原因": "Reason",
"失败次数": "Failed attempts",
"%d 次": "%d times",
"解封时间": "Unban time",
"请在解封时间后重试,或联系管理员。": "Please retry after the unban time, or contact the administrator.",
"返回登录页": "Back to login",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "Back to mailbox",
"控制面板": "Dashboard",
"域名管理": "Domains",
"用户管理": "Users",
"所有邮件": "All mail",
"外发队列": "Outbound queue",
"协议日志": "Protocol logs",
"当前连接": "Connections",
"IP黑名单": "IP blacklist",
// ---------- 控制面板(dashboard.html ----------
"域名数": "Domains",
"用户数": "Users",
"邮件总数": "Total mail",
"被封IP": "Banned IPs",
"邮件分布": "Mail distribution",
"文件夹": "Folder",
"邮件数": "Messages",
"占用空间": "Size",
"合计": "Total",
"收发统计": "Send/receive stats",
"时间段": "Period",
"收件": "Received",
"发件": "Sent",
"今日": "Today",
"近 7 天": "Last 7 days",
"快捷操作": "Quick actions",
"新增域名": "Add domain",
"新增用户": "Add user",
"查看所有邮件": "View all mail",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "Users",
"暂无用户": "No users",
"用户名": "Username",
"域名ID": "Domain ID",
"配额": "Quota",
"已用": "Used",
"状态": "Status",
"操作": "Actions",
"编辑": "Edit",
"确定要删除用户 %s 吗?": "Delete user %s?",
"编辑用户": "Edit user",
"新密码(留空则不修改)": "New password (leave blank to keep)",
"域名": "Domain",
"请选择域名": "Select a domain",
"配额 (GB)": "Quota (GB)",
"启用账户": "Active account",
"保存更改": "Save changes",
"创建用户": "Create user",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "Domains",
"暂无域名": "No domains",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "Delete domain %s? Users of this domain will no longer be able to send or receive mail!",
"编辑域名": "Edit domain",
"SMTP 端口": "SMTP port",
"IMAP 端口": "IMAP port",
"POP3 端口": "POP3 port",
"启用 TLS": "Enable TLS",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "When checked, ports switch to standard SSL/TLS ports automatically",
"TLS 私钥 PEM": "TLS private key (PEM)",
"TLS 公钥证书 PEM": "TLS certificate (PEM)",
"私钥不会回显;留空则保留现有私钥。": "The private key is not shown; leave blank to keep the current key.",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ Certificate configured; uploading a new certificate hot-reloads automatically.",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ No certificate configured. To enable TLS you must provide both the private key and the certificate.",
"从 Caddy 获取证书": "Fetch certificate from Caddy",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "If this domain is served by local Caddy with HTTPS (automatic certificates), import its certificate and private key with one click; TLS is enabled and hot-reloaded, no restart needed.",
"🔒 从 Caddy 获取证书": "🔒 Fetch certificate from Caddy",
"获取中…": "Fetching...",
"获取失败": "Fetch failed",
"请求失败": "Request failed",
"重新生成 DKIM 密钥": "Regenerate DKIM key",
"✅ DKIM 密钥已配置": "✅ DKIM key configured",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM key not configured",
"创建域名": "Create domain",
"DNS配置": "DNS configuration",
"DNS 配置提示 — %s": "DNS configuration hint — %s",
"请在您的 DNS 服务商处添加以下记录:": "Add the following records at your DNS provider:",
"MX 记录": "MX record",
"SPF 记录 (TXT)": "SPF record (TXT)",
"DKIM 记录 (TXT)": "DKIM record (TXT)",
"DMARC 记录": "DMARC record",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM key not generated yet. Edit the domain to regenerate it.",
"返回域名列表": "Back to domains",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "From",
"所属用户": "Owner",
"日期": "Date",
"全部": "All",
"返回邮件列表": "Back to list",
"发件人:": "From:",
"收件人:": "To:",
"抄送:": "Cc:",
"所属用户:": "Owner:",
"文件夹:": "Folder:",
"时间:": "Time:",
"附件列表": "Attachments",
// ---------- 外发队列(outbound.html ----------
"队列为空": "Queue is empty",
"待发送": "Pending",
"发送中": "Sending",
"已送达": "Delivered",
"等待重试": "Waiting to retry",
"失败": "Failed",
"已取消": "Canceled",
"尝试次数": "Attempts",
"下次重试": "Next retry",
"最后响应 / 错误": "Last response / error",
"创建时间": "Created",
"重试": "Retry",
"确认取消该投递任务?": "Cancel this delivery task?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "Protocol logs (SMTP / IMAP / POP3)",
"清理旧日志": "Clean old logs",
"确认清理 %d 天前的协议日志?": "Delete protocol logs older than %d days?",
"今日 SMTP 失败": "SMTP failures today",
"今日 IMAP 失败": "IMAP failures today",
"今日 POP3 失败": "POP3 failures today",
"历史失败(全部)": "Historical failures (all)",
"全部协议": "All protocols",
"全部状态": "All statuses",
"成功": "Success",
"来源 IP(模糊)": "Source IP (fuzzy)",
"用户名(模糊)": "Username (fuzzy)",
"至": "to",
"筛选": "Filter",
"重置": "Reset",
"时间": "Time",
"端口": "Port",
"失败原因": "Failure reason",
"操作摘要": "Summary",
"消息数": "Messages",
"时长": "Duration",
"暂无记录": "No records",
"第 %d / %d 页(共 %d 条)": "Page %d of %d (%d entries)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "Active connections (SMTP / IMAP / POP3)",
"每 5 秒自动刷新": "Auto-refreshes every 5 seconds",
"当前连接总数": "Total active connections",
"协议": "Protocol",
"来源 IP": "Source IP",
"TLS": "TLS",
"明文": "Plain",
"连接时间": "Connected",
"最后活跃": "Last active",
"断开并封禁": "Disconnect & ban",
"当前没有活动连接": "No active connections",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "Disconnect all connections of IP %s and add to blacklist (180 days)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP blacklist",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "Staged bans: banning starts at the 4th trigger; 30 min → 3 h → 3 mo → 6 mo (max)",
"封禁次数": "Ban count",
"到期时间": "Expires",
"封禁中": "Banned",
"已过期": "Expired",
"仅计数": "Counting only",
"(已过期)": " (expired)",
"解封": "Unban",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "Unban IP %s? Its ban level will be reset.",
"暂无封禁记录": "No ban records",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "Please enter your email and password",
"用户名或密码错误,还剩 %d 次尝试机会": "Incorrect username or password. %d attempts remaining",
"会话保存失败,请重试": "Failed to save session, please retry",
"请输入LDAP用户名和密码": "Please enter your LDAP username and password",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP authentication failed. %d attempts remaining",
"LDAP 账号未接入本系统,请联系管理员": "LDAP account is not linked to this system. Contact the administrator",
"用户已被禁用": "User is disabled",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 state validation failed, please log in again",
"OAuth2 授权码缺失": "Missing OAuth2 authorization code",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 authentication failed. Please retry or contact the administrator",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 account is not linked to this system. Contact the administrator",
"OAuth2 未启用": "OAuth2 is not enabled",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 login is temporarily unavailable, please try again later",
"登录失败次数过多": "Too many failed login attempts",
"LDAP 登录失败次数过多": "Too many LDAP login failures",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "Please enter a recipient",
"配额检查失败,请稍后重试": "Quota check failed, please try again later",
"附件超出配额限制。已用 %s / 总配额 %s": "Attachments exceed the quota. Used %s / total %s",
"外部投递未启用": "Outbound delivery is not enabled",
"外部收件人过多:最多 %d 个": "Too many external recipients: maximum %d",
"外发邮件入队失败 (%s): %v": "Failed to enqueue outbound mail (%s): %v",
"投递邮件失败: %v": "Failed to deliver mail: %v",
"保存邮件失败: %v": "Failed to save mail: %v",
"当前密码不正确": "Current password is incorrect",
"新密码不能为空": "New password cannot be empty",
"两次输入的密码不一致": "The two passwords do not match",
"密码加密失败": "Failed to encrypt password",
"密码更新失败": "Failed to update password",
"密码修改成功": "Password changed successfully",
"邮箱不存在": "Mailbox does not exist",
"加载邮件列表失败: %v": "Failed to load messages: %v",
"无效的邮件ID": "Invalid message ID",
"邮件不存在": "Message does not exist",
"禁止访问": "Access denied",
"无效的附件ID": "Invalid attachment ID",
"附件不存在": "Attachment does not exist",
"读取附件失败": "Failed to read attachment",
"删除失败": "Delete failed",
"恢复失败": "Restore failed",
"清空文件夹失败: %v": "Failed to empty folder: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "Invalid connection ID",
"连接不存在或已断开": "Connection does not exist or is disconnected",
"管理员手动封禁(连接断开)": "Banned manually by admin (connection disconnected)",
"封禁失败: %v": "Ban failed: %v",
"加载域名列表失败: %v": "Failed to load domains: %v",
"请输入域名": "Please enter a domain name",
"创建域名失败: %v": "Failed to create domain: %v",
"无效的域名ID": "Invalid domain ID",
"域名不存在": "Domain does not exist",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Certificate fetched from Caddy and saved to the domain TLS directory; TLS enabled and hot-reloaded, no restart needed.",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS private key and certificate are required to enable TLS",
"TLS 私钥和公钥证书必须同时填写": "TLS private key and certificate must be provided together",
"TLS 证书或私钥无效: %v": "Invalid TLS certificate or private key: %v",
"创建 TLS 证书目录失败: %v": "Failed to create TLS directory: %v",
"保存 TLS 公钥证书失败: %v": "Failed to save TLS certificate: %v",
"保存 TLS 私钥失败: %v": "Failed to save TLS private key: %v",
"从 Caddy 获取证书失败: %v": "Failed to fetch certificate from Caddy: %v",
"更新域名记录失败: %v": "Failed to update domain record: %v",
"更新域名失败: %v": "Failed to update domain: %v",
"删除域名失败: %v": "Failed to delete domain: %v",
"加载用户列表失败: %v": "Failed to load users: %v",
"请填写所有必填字段": "Please fill in all required fields",
"创建用户失败: %v": "Failed to create user: %v",
"无效的用户ID": "Invalid user ID",
"不能删除自己的账户": "You cannot delete your own account",
"删除用户失败: %v": "Failed to delete user: %v",
"用户不存在": "User does not exist",
"更新用户失败: %v": "Failed to update user: %v",
"加载黑名单失败: %v": "Failed to load blacklist: %v",
"无效的记录ID": "Invalid record ID",
"解封失败: %v": "Unban failed: %v",
"加载协议日志失败: %v": "Failed to load protocol logs: %v",
"加载外发队列失败: %v": "Failed to load outbound queue: %v",
"外发服务不可用": "Outbound service is unavailable",
"重试失败: %v": "Retry failed: %v",
"取消失败: %v": "Cancel failed: %v",
"无效的队列ID": "Invalid queue ID",
"禁止访问:需要管理员权限": "Access denied: administrator privileges required",
}
// ja 日文目录:key 为界面中的原始中文字符串;缺译回退英文目录。
var ja = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "午前",
"下午": "午後",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "受信トレイ",
"已发送": "送信済み",
"草稿箱": "下書き",
"已删除": "ゴミ箱",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "メール",
"搜索邮件(发件人 / 主题)": "メールを検索(送信者 / 件名)",
"管理后台": "管理画面",
"设置": "設定",
"退出登录": "ログアウト",
"写信": "作成",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "すべて選択 / 選択解除",
"全选": "すべて選択",
"刷新": "更新",
"删除": "削除",
"彻底删除": "完全に削除",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "「ゴミ箱」フォルダを空にしますか?この操作は元に戻せません。",
"清空文件夹": "フォルダを空にする",
"共 %d 封": "%d 件",
"暂无邮件": "メールはありません",
"收件人:%s": "宛先:%s",
"致:": "宛先:",
"(无主题)": "(件名なし)",
"[HTML 邮件]": "[HTML メール]",
"确定要恢复这封邮件吗?": "このメールを復元しますか?",
"恢复到收件箱": "受信トレイへ復元",
"确定要删除这封邮件吗?": "このメールを削除しますか?",
"第 %d / %d 页": "%d / %d ページ",
"上一页": "前へ",
"下一页": "次へ",
"确定要删除选中的 %s 封邮件吗?": "選択した %s 件のメールを削除しますか?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "メールを表示",
"返回": "戻る",
"回复": "返信",
"恢复": "復元",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "このメールを完全に削除しますか?この操作は元に戻せません。",
"删除邮件": "メールを削除",
"抄送:": "Cc",
// ---------- 写信(compose.html ----------
"发送": "送信",
"附件": "添付",
"取消": "キャンセル",
"撰写新邮件": "新しいメールを作成",
"收件人": "宛先",
"输入收件人邮箱地址": "宛先メールアドレスを入力",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "複数のアドレスはカンマ区切り(任意)",
"主题": "件名",
"输入邮件主题": "メールの件名を入力",
"请输入邮件内容...": "メール本文を入力してください...",
"附件配额": "添付ファイル容量",
"移除": "削除",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ 初回ログインまたはパスワードリセット後です。メール機能を利用する前にパスワードを変更してください。",
"账号信息": "アカウント情報",
"已用 %s / 配额 %s": "使用 %s / 容量 %s",
"管理员": "管理者",
"修改密码": "パスワード変更",
"当前密码": "現在のパスワード",
"请输入当前密码": "現在のパスワードを入力",
"新密码": "新しいパスワード",
"请输入新密码": "新しいパスワードを入力",
"确认新密码": "新しいパスワード(確認)",
"请再次输入新密码": "新しいパスワードを再入力",
"界面语言": "表示言語",
"自动(跟随浏览器)": "自動(ブラウザに従う)",
"保存": "保存",
"语言偏好已保存": "言語設定を保存しました",
"语言偏好保存失败": "言語設定の保存に失敗しました",
"帮助": "ヘルプ",
"客户端收发信(IMAP / SMTP)配置:": "クライアント設定(IMAP / SMTP):",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP サーバー:%s ポート %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP サーバー:%s ポート %d(サブミッション)/ SSL %d",
// ---------- 登录(login.html ----------
"登录": "ログイン",
"登录您的邮箱账户": "メールアカウントにログイン",
"邮箱地址": "メールアドレス",
"密码": "パスワード",
"请输入密码": "パスワードを入力",
"登 录": "ログイン",
"或": "または",
"LDAP 用户名": "LDAP ユーザー名",
"LDAP 密码": "LDAP パスワード",
"LDAP 登录": "LDAP ログイン",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "アクセスが禁止されています",
"您的 IP 已被暂时禁止访问": "お使いの IP は一時的にアクセス禁止されています",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "ログイン失敗が多すぎるため、IP アドレスが一時的にブロックされています。",
"IP 地址": "IP アドレス",
"封禁档位": "ブロック段階",
"第 %d 次封禁": "%d 回目のブロック",
"原因": "理由",
"失败次数": "失敗回数",
"%d 次": "%d 回",
"解封时间": "ブロック解除時刻",
"请在解封时间后重试,或联系管理员。": "ブロック解除後にもう一度お試しいただくか、管理者に連絡してください。",
"返回登录页": "ログインページに戻る",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "メールボックスに戻る",
"控制面板": "ダッシュボード",
"域名管理": "ドメイン管理",
"用户管理": "ユーザー管理",
"所有邮件": "すべてのメール",
"外发队列": "送信キュー",
"协议日志": "プロトコルログ",
"当前连接": "現在の接続",
"IP黑名单": "IP ブラックリスト",
// ---------- 控制面板(dashboard.html ----------
"域名数": "ドメイン数",
"用户数": "ユーザー数",
"邮件总数": "メール総数",
"被封IP": "ブロック済み IP",
"邮件分布": "メール分布",
"文件夹": "フォルダ",
"邮件数": "メール数",
"占用空间": "使用容量",
"合计": "合計",
"收发统计": "送受信統計",
"时间段": "期間",
"收件": "受信",
"发件": "送信",
"今日": "今日",
"近 7 天": "過去 7 日間",
"快捷操作": "クイック操作",
"新增域名": "ドメインを追加",
"新增用户": "ユーザーを追加",
"查看所有邮件": "すべてのメールを表示",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "ユーザー一覧",
"暂无用户": "ユーザーはいません",
"用户名": "ユーザー名",
"域名ID": "ドメイン ID",
"配额": "容量",
"已用": "使用量",
"状态": "状態",
"操作": "操作",
"编辑": "編集",
"确定要删除用户 %s 吗?": "ユーザー %s を削除しますか?",
"编辑用户": "ユーザーを編集",
"新密码(留空则不修改)": "新しいパスワード(空欄なら変更なし)",
"域名": "ドメイン",
"请选择域名": "ドメインを選択",
"配额 (GB)": "容量 (GB)",
"启用账户": "アカウントを有効にする",
"保存更改": "変更を保存",
"创建用户": "ユーザーを作成",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "ドメイン一覧",
"暂无域名": "ドメインはありません",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "ドメイン %s を削除しますか?関連ユーザーはメールの送受信ができなくなります!",
"编辑域名": "ドメインを編集",
"SMTP 端口": "SMTP ポート",
"IMAP 端口": "IMAP ポート",
"POP3 端口": "POP3 ポート",
"启用 TLS": "TLS を有効にする",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "チェックするとポートは標準の SSL/TLS ポートに自動的に切り替わります",
"TLS 私钥 PEM": "TLS 秘密鍵 (PEM)",
"TLS 公钥证书 PEM": "TLS 公開鍵証明書 (PEM)",
"私钥不会回显;留空则保留现有私钥。": "秘密鍵は表示されません。空欄の場合は現在の鍵を保持します。",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ 証明書は設定済みです。新しい証明書をアップロードすると自動的にホットリロードされます。",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ 証明書が未設定です。TLS を有効にするには秘密鍵と証明書の両方が必要です。",
"从 Caddy 获取证书": "Caddy から証明書を取得",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "このドメインがローカル Caddy で HTTPS 運用されている場合(自動証明書)、証明書と秘密鍵をワンクリックでインポートし、TLS を自動で有効化します。ホットリロードされるため再起動は不要です。",
"🔒 从 Caddy 获取证书": "🔒 Caddy から証明書を取得",
"获取中…": "取得中...",
"获取失败": "取得失敗",
"请求失败": "リクエスト失敗",
"重新生成 DKIM 密钥": "DKIM キーを再生成",
"✅ DKIM 密钥已配置": "✅ DKIM キー設定済み",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM キー未設定",
"创建域名": "ドメインを作成",
"DNS配置": "DNS 設定",
"DNS 配置提示 — %s": "DNS 設定ヒント — %s",
"请在您的 DNS 服务商处添加以下记录:": "DNS プロバイダーで次のレコードを追加してください:",
"MX 记录": "MX レコード",
"SPF 记录 (TXT)": "SPF レコード (TXT)",
"DKIM 记录 (TXT)": "DKIM レコード (TXT)",
"DMARC 记录": "DMARC レコード",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM キーが未生成です。ドメインを編集して再生成してください。",
"返回域名列表": "ドメイン一覧に戻る",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "送信者",
"所属用户": "所有者",
"日期": "日付",
"全部": "すべて",
"返回邮件列表": "一覧に戻る",
"发件人:": "送信者:",
"收件人:": "宛先:",
"抄送:": "Cc",
"所属用户:": "所有者:",
"文件夹:": "フォルダ:",
"时间:": "時刻:",
"附件列表": "添付ファイル",
// ---------- 外发队列(outbound.html ----------
"队列为空": "キューは空です",
"待发送": "待機中",
"发送中": "送信中",
"已送达": "送信済み",
"等待重试": "再試行待ち",
"失败": "失敗",
"已取消": "キャンセル済み",
"尝试次数": "試行回数",
"下次重试": "次回再試行",
"最后响应 / 错误": "最終レスポンス / エラー",
"创建时间": "作成日時",
"重试": "再試行",
"确认取消该投递任务?": "この配信タスクをキャンセルしますか?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "プロトコルログ(SMTP / IMAP / POP3",
"清理旧日志": "古いログを削除",
"确认清理 %d 天前的协议日志?": "%d 日前より前のプロトコルログを削除しますか?",
"今日 SMTP 失败": "今日の SMTP 失敗",
"今日 IMAP 失败": "今日の IMAP 失敗",
"今日 POP3 失败": "今日の POP3 失敗",
"历史失败(全部)": "過去の失敗(すべて)",
"全部协议": "すべてのプロトコル",
"全部状态": "すべての状態",
"成功": "成功",
"来源 IP(模糊)": "送信元 IP(あいまい)",
"用户名(模糊)": "ユーザー名(あいまい)",
"至": "〜",
"筛选": "フィルター",
"重置": "リセット",
"时间": "時刻",
"端口": "ポート",
"失败原因": "失敗理由",
"操作摘要": "操作概要",
"消息数": "メッセージ数",
"时长": "所要時間",
"暂无记录": "記録はありません",
"第 %d / %d 页(共 %d 条)": "%d / %d ページ(全 %d 件)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "現在の接続(SMTP / IMAP / POP3",
"每 5 秒自动刷新": "5 秒ごとに自動更新",
"当前连接总数": "現在の接続数",
"协议": "プロトコル",
"来源 IP": "送信元 IP",
"TLS": "TLS",
"明文": "平文",
"连接时间": "接続時刻",
"最后活跃": "最終アクティブ",
"断开并封禁": "切断してブロック",
"当前没有活动连接": "アクティブな接続はありません",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "IP %s のすべての接続を切断してブラックリストに追加しますか(180 日)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP ブラックリスト",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "段階的ブロック:4 回目の発動からブロック開始、30分 → 3時間 → 3か月 → 半年(上限)",
"封禁次数": "ブロック回数",
"到期时间": "解除時刻",
"封禁中": "ブロック中",
"已过期": "期限切れ",
"仅计数": "カウントのみ",
"(已过期)": "(期限切れ)",
"解封": "解除",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "IP %s のブロックを解除しますか?ブロック段階はリセットされます。",
"暂无封禁记录": "ブロック記録はありません",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "メールアドレスとパスワードを入力してください",
"用户名或密码错误,还剩 %d 次尝试机会": "ユーザー名またはパスワードが違います。あと %d 回試行できます",
"会话保存失败,请重试": "セッションの保存に失敗しました。もう一度お試しください",
"请输入LDAP用户名和密码": "LDAP ユーザー名とパスワードを入力してください",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP 認証に失敗しました。あと %d 回試行できます",
"LDAP 账号未接入本系统,请联系管理员": "LDAP アカウントはこのシステムに接続されていません。管理者に連絡してください",
"用户已被禁用": "ユーザーは無効です",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 の state 検証に失敗しました。もう一度ログインしてください",
"OAuth2 授权码缺失": "OAuth2 認可コードがありません",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 認証に失敗しました。再試行するか管理者に連絡してください",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 アカウントはこのシステムに接続されていません。管理者に連絡してください",
"OAuth2 未启用": "OAuth2 は有効ではありません",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 ログインは一時的に利用できません。後でもう一度お試しください",
"登录失败次数过多": "ログイン失敗が多すぎます",
"LDAP 登录失败次数过多": "LDAP ログイン失敗が多すぎます",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "宛先を入力してください",
"配额检查失败,请稍后重试": "容量チェックに失敗しました。後でもう一度お試しください",
"附件超出配额限制。已用 %s / 总配额 %s": "添付ファイルが容量を超えています。使用 %s / 合計 %s",
"外部投递未启用": "外部送信は有効ではありません",
"外部收件人过多:最多 %d 个": "外部宛先が多すぎます:最大 %d 件",
"外发邮件入队失败 (%s): %v": "外部メールのキュー登録に失敗しました (%s): %v",
"投递邮件失败: %v": "メールの配信に失敗しました: %v",
"保存邮件失败: %v": "メールの保存に失敗しました: %v",
"当前密码不正确": "現在のパスワードが正しくありません",
"新密码不能为空": "新しいパスワードは必須です",
"两次输入的密码不一致": "パスワードが一致しません",
"密码加密失败": "パスワードの暗号化に失敗しました",
"密码更新失败": "パスワードの更新に失敗しました",
"密码修改成功": "パスワードを変更しました",
"邮箱不存在": "メールボックスが存在しません",
"加载邮件列表失败: %v": "メール一覧の読み込みに失敗しました: %v",
"无效的邮件ID": "無効なメール ID",
"邮件不存在": "メールが存在しません",
"禁止访问": "アクセスが拒否されました",
"无效的附件ID": "無効な添付ファイル ID",
"附件不存在": "添付ファイルが存在しません",
"读取附件失败": "添付ファイルの読み込みに失敗しました",
"删除失败": "削除に失敗しました",
"恢复失败": "復元に失敗しました",
"清空文件夹失败: %v": "フォルダを空にできませんでした: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "無効な接続 ID",
"连接不存在或已断开": "接続が存在しないか切断されています",
"管理员手动封禁(连接断开)": "管理者による手動ブロック(接続切断)",
"封禁失败: %v": "ブロックに失敗しました: %v",
"加载域名列表失败: %v": "ドメイン一覧の読み込みに失敗しました: %v",
"请输入域名": "ドメイン名を入力してください",
"创建域名失败: %v": "ドメインの作成に失敗しました: %v",
"无效的域名ID": "無効なドメイン ID",
"域名不存在": "ドメインが存在しません",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Caddy から証明書を取得し、ドメインの TLS ディレクトリに保存しました。TLS が有効になり、ホットリロード済みです。再起動は不要です。",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS を有効にするには秘密鍵と証明書が必要です",
"TLS 私钥和公钥证书必须同时填写": "秘密鍵と証明書は両方必要です",
"TLS 证书或私钥无效: %v": "TLS 証明書または秘密鍵が無効です: %v",
"创建 TLS 证书目录失败: %v": "TLS ディレクトリの作成に失敗しました: %v",
"保存 TLS 公钥证书失败: %v": "TLS 証明書の保存に失敗しました: %v",
"保存 TLS 私钥失败: %v": "TLS 秘密鍵の保存に失敗しました: %v",
"从 Caddy 获取证书失败: %v": "Caddy からの証明書取得に失敗しました: %v",
"更新域名记录失败: %v": "ドメイン情報の更新に失敗しました: %v",
"更新域名失败: %v": "ドメインの更新に失敗しました: %v",
"删除域名失败: %v": "ドメインの削除に失敗しました: %v",
"加载用户列表失败: %v": "ユーザー一覧の読み込みに失敗しました: %v",
"请填写所有必填字段": "必須項目をすべて入力してください",
"创建用户失败: %v": "ユーザーの作成に失敗しました: %v",
"无效的用户ID": "無効なユーザー ID",
"不能删除自己的账户": "自分のアカウントは削除できません",
"删除用户失败: %v": "ユーザーの削除に失敗しました: %v",
"用户不存在": "ユーザーが存在しません",
"更新用户失败: %v": "ユーザーの更新に失敗しました: %v",
"加载黑名单失败: %v": "ブラックリストの読み込みに失敗しました: %v",
"无效的记录ID": "無効なレコード ID",
"解封失败: %v": "ブロック解除に失敗しました: %v",
"加载协议日志失败: %v": "プロトコルログの読み込みに失敗しました: %v",
"加载外发队列失败: %v": "送信キューの読み込みに失敗しました: %v",
"外发服务不可用": "送信サービスを利用できません",
"重试失败: %v": "再試行に失敗しました: %v",
"取消失败: %v": "キャンセルに失敗しました: %v",
"无效的队列ID": "無効なキュー ID",
"禁止访问:需要管理员权限": "アクセスが拒否されました:管理者権限が必要です",
}
+139
View File
@@ -0,0 +1,139 @@
// Package i18n provides lightweight UI localization for the MailGo web
// frontend. Supported languages: zh (Chinese), en (English, fallback) and
// ja (Japanese). Users may also pick "auto" so the effective language is
// derived from their browser's Accept-Language header.
//
// Translation keys are the original Chinese UI strings themselves: the zh
// catalog is the identity map, and the en/ja catalogs map those keys to
// their translations. Missing translations fall back to English, then to
// the key itself.
package i18n
import (
"fmt"
"sort"
"strconv"
"strings"
)
// Language preference values stored on the user record.
const (
// LangAuto 跟随浏览器语言(Accept-Language)。
LangAuto = "auto"
// LangEn 英语:兜底语言。
LangEn = "en"
// LangZh 中文。
LangZh = "zh"
// LangJa 日文。
LangJa = "ja"
)
// Supported 判断 lang 是否为可显式选择的界面语言(不含 auto)。
func Supported(lang string) bool {
switch lang {
case LangEn, LangZh, LangJa:
return true
default:
return false
}
}
// Normalize 把浏览器语言标签规整为受支持的语言代码:
// zh-CN / zh-TW / zh-Hans → zhja-JP → jaen-US / en-GB → en
// 其余返回空串(无法识别)。
func Normalize(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
if i := strings.IndexByte(tag, '-'); i >= 0 {
tag = tag[:i]
}
if i := strings.IndexByte(tag, '_'); i >= 0 {
tag = tag[:i]
}
switch tag {
case LangZh, "zh-hans", "zh-hant":
return LangZh
case LangJa:
return LangJa
case LangEn:
return LangEn
default:
return ""
}
}
// FromAcceptLanguage 从 Accept-Language 请求头解析首选语言。
// 按 q 值(缺省 1.0)降序取第一个受支持的语言;都不支持时回退英语。
func FromAcceptLanguage(header string) string {
type entry struct {
lang string
q float64
}
var entries []entry
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
tag := part
q := 1.0
if i := strings.IndexByte(part, ';'); i >= 0 {
tag = strings.TrimSpace(part[:i])
for _, param := range strings.Split(part[i+1:], ";") {
param = strings.TrimSpace(param)
if strings.HasPrefix(param, "q=") {
if v, err := strconv.ParseFloat(param[2:], 64); err == nil {
q = v
}
}
}
}
if lang := Normalize(tag); lang != "" {
entries = append(entries, entry{lang: lang, q: q})
}
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].q > entries[j].q })
for _, e := range entries {
if e.q > 0 {
return e.lang
}
}
return LangEn
}
// Resolve 计算有效界面语言:用户偏好为显式语言时直接采用;
// auto(或空/未知值)时按浏览器 Accept-Language 解析;最终兜底英语。
func Resolve(pref, acceptLanguage string) string {
if Supported(pref) {
return pref
}
return FromAcceptLanguage(acceptLanguage)
}
// T 返回 key 在 lang 下的译文。zh 直接返回 key;
// ja 优先查日文表,缺译回退英文表;其余语言(含未知)查英文表,
// 仍缺失时原样返回 key(保证界面永不出现空文本)。
func T(lang, key string) string {
switch Normalize(lang) {
case LangZh:
return key
case LangJa:
if s, ok := ja[key]; ok {
return s
}
if s, ok := en[key]; ok {
return s
}
return key
default:
if s, ok := en[key]; ok {
return s
}
return key
}
}
// TF 是 T 的格式化版本:对译文执行 fmt.Sprintf。
// 用于带占位符的消息(如「还剩 %d 次尝试机会」)。
func TF(lang, key string, args ...interface{}) string {
return fmt.Sprintf(T(lang, key), args...)
}
+114
View File
@@ -0,0 +1,114 @@
package i18n
import "testing"
func TestNormalize(t *testing.T) {
cases := map[string]string{
"zh": "zh",
"zh-CN": "zh",
"zh-TW": "zh",
"zh-Hans": "zh",
"ja": "ja",
"ja-JP": "ja",
"en": "en",
"en-US": "en",
"en_GB": "en",
"fr": "",
"": "",
}
for in, want := range cases {
if got := Normalize(in); got != want {
t.Errorf("Normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestFromAcceptLanguage(t *testing.T) {
cases := []struct {
header string
want string
}{
{"zh-CN,zh;q=0.9,en;q=0.8", "zh"},
{"ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"en-US,en;q=0.9", "en"},
{"en;q=0.5,zh;q=0.9", "zh"}, // q 值优先
{"fr-FR,de-DE", "en"}, // 全部不支持 → 兜底英语
{"", "en"},
}
for _, tc := range cases {
if got := FromAcceptLanguage(tc.header); got != tc.want {
t.Errorf("FromAcceptLanguage(%q) = %q, want %q", tc.header, got, tc.want)
}
}
}
func TestResolve(t *testing.T) {
cases := []struct {
pref, header, want string
}{
{"auto", "ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"auto", "en-US,en;q=0.9", "en"},
{"auto", "fr-FR", "en"},
{"zh", "en-US,en;q=0.9", "zh"}, // 显式偏好优先于浏览器
{"ja", "en-US", "ja"},
{"en", "zh-CN,zh;q=0.9", "en"},
{"", "zh-CN", "zh"},
{"fr", "zh-CN", "zh"}, // 非法偏好按 auto 处理
}
for _, tc := range cases {
if got := Resolve(tc.pref, tc.header); got != tc.want {
t.Errorf("Resolve(%q, %q) = %q, want %q", tc.pref, tc.header, got, tc.want)
}
}
}
func TestT(t *testing.T) {
key := "收件箱"
// zh 恒等于 key 本身
if got := T("zh", key); got != key {
t.Errorf("T(zh) = %q, want %q", got, key)
}
// en
if got, want := T("en", key), "Inbox"; got != want {
t.Errorf("T(en) = %q, want %q", got, want)
}
// ja
if got, want := T("ja", key), "受信トレイ"; got != want {
t.Errorf("T(ja) = %q, want %q", got, want)
}
// 未知语言 → 英文兜底
if got := T("fr", key); got != "Inbox" {
t.Errorf("T(fr) = %q, want Inbox", got)
}
// 无此键 → 原样返回 key
if got := T("en", "不存在的键"); got != "不存在的键" {
t.Errorf("T(missing) = %q, want key itself", got)
}
}
func TestTF(t *testing.T) {
key := "用户名或密码错误,还剩 %d 次尝试机会"
if got, want := TF("zh", key, 3), "用户名或密码错误,还剩 3 次尝试机会"; got != want {
t.Errorf("TF(zh) = %q, want %q", got, want)
}
if got, want := TF("en", key, 2), "Incorrect username or password. 2 attempts remaining"; got != want {
t.Errorf("TF(en) = %q, want %q", got, want)
}
if got, want := TF("ja", key, 1), "ユーザー名またはパスワードが違います。あと 1 回試行できます"; got != want {
t.Errorf("TF(ja) = %q, want %q", got, want)
}
}
// TestCatalogCoverage 校验 en/ja 目录中不包含空译文。
func TestCatalogCoverage(t *testing.T) {
for k, v := range en {
if v == "" {
t.Errorf("en 目录空译文: %q", k)
}
}
for k, v := range ja {
if v == "" {
t.Errorf("ja 目录空译文: %q", k)
}
}
}
-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()
}
+532
View File
@@ -0,0 +1,532 @@
// 集成测试:启动真实 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)
}
// TestSeqOrderArrival 验证序号按到达顺序(id ASC,最早 = seq 1)分配:
// 与主流服务器行为一致——新邮件永远追加到末尾(seq = 新 EXISTS 数),
// 既不位移既有邮件序号,也能被 seq 增量同步(seq 4)正确获取;
// INTERNALDATE 返回到达时间(CreatedAt)而非 Date 头。
func TestSeqOrderArrival(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
c := loginAndSelect(t, addr)
msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
if err != nil {
t.Fatalf("fetch: %v", err)
}
seqOf := map[imap.UID]uint32{}
for _, m := range msgs {
seqOf[m.UID] = m.SeqNum
}
if seqOf[imap.UID(ids[0])] != 1 || seqOf[imap.UID(ids[1])] != 2 || seqOf[imap.UID(ids[2])] != 3 {
t.Fatalf("seq mapping = %v, want ids[0]=1 ids[1]=2 ids[2]=3", seqOf)
}
// 新邮件到达(Date 头较旧):仍追加到末尾,不移位既有邮件
late := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "x@y",
ToAddr: "alice@example.com",
Subject: "late",
Date: time.Now().Add(-24 * time.Hour),
CreatedAt: time.Now(),
}
if err := stores.Mails.Create(late); err != nil {
t.Fatalf("create late message: %v", err)
}
msgs, err = c.Fetch(imap.SeqSetNum(4), &imap.FetchOptions{UID: true, InternalDate: true}).Collect()
if err != nil {
t.Fatalf("fetch seq 4: %v", err)
}
if len(msgs) != 1 || msgs[0].UID != imap.UID(late.ID) {
t.Fatalf("seq 4 = %v, want 新邮件 uid=%d", msgs, late.ID)
}
// INTERNALDATE = 到达时间(CreatedAt),而不是 Date 头(协议格式仅到秒)
stored, err := stores.Mails.GetByID(late.ID)
if err != nil {
t.Fatalf("get late msg: %v", err)
}
if !msgs[0].InternalDate.Equal(stored.CreatedAt.Truncate(time.Second)) {
t.Fatalf("internaldate = %v, want CreatedAt %v", msgs[0].InternalDate, stored.CreatedAt)
}
}
// 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])
}
}
+206 -31
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.
type IMAPServer struct {
stores *store.Stores
cfg config.IMAPConfig
// 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)
}
// NewIMAPServer creates a new IMAP server instance.
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores) *IMAPServer {
// 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. 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,
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
+25 -13
View File
@@ -49,11 +49,12 @@ func newPermError(format string, args ...interface{}) *DeliveryError {
// RelayConfig describes a smarthost through which all external mail is sent.
type RelayConfig struct {
Host string
Port int // 465 = implicit TLS; other ports may use STARTTLS
Username string // AUTH PLAIN credentials (empty = no authentication)
Password string
StartTLS bool // use STARTTLS on non-465 ports
Host string
Port int // 465 = implicit TLS; other ports may use STARTTLS
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
}
+114 -23
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 != "" {
@@ -64,30 +84,36 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
if cfg.RelayHost != "" {
m.mailer.Relay = &RelayConfig{
Host: cfg.RelayHost,
Port: cfg.RelayPort,
Username: cfg.RelayUser,
Password: cfg.RelayPassword,
StartTLS: cfg.RelayStartTLS,
Host: cfg.RelayHost,
Port: cfg.RelayPort,
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
}
}
// deliverOne performs a single delivery attempt for a queue item.
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
// acquireDomain 获取收件域(或中继)的并发信号量,限制对同一目标域同时
// 打开的 SMTP 连接数。limit <= 0 表示不限制。
func (m *Manager) acquireDomain(domain string) func() {
limit := m.cfg.MaxConcurrentPerDomain
if limit <= 0 {
return func() {}
}
resp, err := m.mailer.Deliver(item.FromAddr, item.ToAddr, []byte(item.RawData))
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) {
release := m.acquireDomain(item.ToAddr)
defer release()
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)
}
}
+170 -26
View File
@@ -12,32 +12,37 @@ 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.
type POP3Server struct {
listener net.Listener
stores *store.Stores
cfg config.POP3Config
wg sync.WaitGroup
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)
}
}
+191 -24
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"
@@ -31,27 +36,28 @@ const (
// SMTPServer wraps go-smtp servers and provides local mail delivery.
type SMTPServer struct {
stores *store.Stores
storage *storage.AttachmentStorage
outbound *outbound.Manager
cfg config.SMTPConfig
stores *store.Stores
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),
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
}
+70 -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 的邮件
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
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
}
@@ -75,7 +93,7 @@ func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, si
}
offset := (page - 1) * size
if err := query.Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
if err := query.Order("date DESC, id DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
return nil, 0, err
}
return messages, total, nil
@@ -96,11 +114,56 @@ 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 的邮件
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
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("id ASC").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,7 +181,12 @@ 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.
// 按 id ASC(到达顺序,最早在前)排序:新邮件永远获得最大序号(seq =
// EXISTS 数),与主流服务器(Dovecot/Courier)行为一致,依赖「新邮件 =
// seq N+1」做增量同步的客户端不会漏收或标错邮件;新邮件到达不会使既有
// 邮件序号位移(只有 EXPUNGE 才会,属正常行为)。所有 IMAP 序号相关路径
// Status/Fetch/Search/Store/Copy/Move/Expunge/推送/seqOf)共用本排序,
// 保证序号全链路一致。INTERNALDATE 使用 CreatedAt(到达时间),与排序一致。
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).
+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
}
+21 -12
View File
@@ -8,23 +8,29 @@ import (
// Stores aggregates all store interfaces for convenient access.
type Stores struct {
Users UserStore
Mails MailStore
Domains DomainStore
Attachments AttachmentStore
Bans BanStore
Outbound OutboundStore
Users UserStore
Mails MailStore
Domains DomainStore
Attachments AttachmentStore
Bans BanStore
Outbound OutboundStore
ProtocolLogs ProtocolLogStore
MailboxState MailboxStateStore
Mailboxes MailboxStore
}
// NewStores creates a new Stores instance with all GORM-backed implementations.
func NewStores(database *gorm.DB) *Stores {
return &Stores{
Users: newUserStore(database),
Mails: newMailStore(database),
Domains: newDomainStore(database),
Attachments: newAttachmentStore(database),
Bans: newBanStore(database),
Outbound: newOutboundStore(database),
Users: newUserStore(database),
Mails: newMailStore(database),
Domains: newDomainStore(database),
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{}
+95 -2
View File
@@ -16,12 +16,25 @@ 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
// UpdateLanguage 更新用户界面语言偏好(auto | en | zh | ja)。
UpdateLanguage(userID uint, lang string) error
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
TryReserveQuota(userID uint, delta int64) (bool, error)
}
// userStoreGorm implements UserStore using GORM.
@@ -91,6 +104,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 +185,41 @@ 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
}
// UpdateLanguage 更新用户界面语言偏好。非法值(非 auto/en/zh/ja)回退 auto。
func (s *userStoreGorm) UpdateLanguage(userID uint, lang string) error {
switch lang {
case "auto", "en", "zh", "ja":
default:
lang = "auto"
}
return s.db.Model(&db.User{}).Where("id = ?", userID).
Update("language", lang).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()
}
File diff suppressed because it is too large Load Diff
+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("zh", 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("zh", 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("zh", domain, certB, "")
if err == nil || !strings.Contains(err.Error(), "必须同时填写") {
t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err)
}
}
+126 -76
View File
@@ -1,6 +1,9 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"net/http"
@@ -8,7 +11,7 @@ import (
"mail_go/config"
"mail_go/internal/auth"
"mail_go/internal/db"
"mail_go/internal/i18n"
"mail_go/internal/store"
"github.com/gin-contrib/sessions"
@@ -35,12 +38,12 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
c.Redirect(302, "/inbox")
return
}
c.HTML(200, "login", gin.H{
c.HTML(200, "login", withLang(c, gin.H{
"error": "",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
}
// DoLogin processes the login form submission.
@@ -52,7 +55,7 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
// Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip)
if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return
}
@@ -60,58 +63,55 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
password := c.PostForm("password")
if email == "" || password == "" {
c.HTML(200, "login", gin.H{
"error": "请输入邮箱和密码",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "请输入邮箱和密码"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
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", withLang(c, gin.H{"entry": entry}))
return
}
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("用户名或密码错误,还剩 %d 次尝试机会", remaining),
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.TF(langOf(c), "用户名或密码错误,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
// 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": "会话保存失败,请重试",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -125,7 +125,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
// Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip)
if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return
}
@@ -133,12 +133,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
password := c.PostForm("password")
if username == "" || password == "" {
c.HTML(200, "login", gin.H{
"error": "请输入LDAP用户名和密码",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "请输入LDAP用户名和密码"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -150,102 +150,149 @@ 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", withLang(c, gin.H{"entry": entry}))
return
}
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会: %v", remaining, err),
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.TF(langOf(c), "LDAP 认证失败,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
// Look up or auto-create user by email
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 用户 %s 在系统中不存在", email),
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "LDAP 账号未接入本系统,请联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
if !user.IsActive {
c.HTML(200, "login", gin.H{
"error": "用户已被禁用",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "用户已被禁用"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
// 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": "会话保存失败,请重试",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
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 {
c.String(http.StatusBadRequest, "OAuth2 未启用")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
return
}
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, i18n.T(langOf(c), "OAuth2 登录暂不可用,请稍后重试"))
return
}
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
c.Redirect(http.StatusFound, provider.GetAuthURL(state))
}
// OAuth2Callback handles the OAuth2 provider's callback after user authorization.
func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
c.String(http.StatusBadRequest, "OAuth2 未启用")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
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", withLang(c, func() gin.H {
v := h.oauth2LoginVars()
v["error"] = i18n.T(langOf(c), "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{
"error": "OAuth2 授权码缺失",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "OAuth2 授权码缺失"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -253,49 +300,52 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
email, err := provider.HandleCallback(code)
if err != nil {
log.Printf("OAuth2 回调失败: %v", err)
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 认证失败: %v", err),
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "OAuth2 认证失败,请重试或联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
// Look up user by email
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 用户 %s 在系统中不存在", email),
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "OAuth2 账号未接入本系统,请联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
if !user.IsActive {
c.HTML(200, "login", gin.H{
"error": "用户已被禁用",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "用户已被禁用"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
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": "会话保存失败,请重试",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
+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)
}
}
+39
View File
@@ -0,0 +1,39 @@
package handlers
import (
"mail_go/internal/i18n"
"github.com/gin-gonic/gin"
)
// langOf 返回当前请求的界面语言:优先取中间件解析好的 context 值
// (登录用户偏好 / 浏览器语言),未设置时按浏览器 Accept-Language 兜底。
func langOf(c *gin.Context) string {
if v, ok := c.Get("lang"); ok {
if s, ok := v.(string); ok && i18n.Supported(s) {
return s
}
}
return i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
}
// withLang 把当前请求的界面语言注入模板数据(key "lang"),
// 所有 c.HTML 渲染入口都应经过它,保证模板可调用 {{t .Lang "..."}}。
func withLang(c *gin.Context, data gin.H) gin.H {
if data == nil {
data = gin.H{}
}
data["Lang"] = langOf(c)
return data
}
// normLang 把表单提交的语言偏好规整为合法值(auto | en | zh | ja),
// 非法值回退 auto(跟随浏览器)。
func normLang(v string) string {
switch v {
case i18n.LangAuto, i18n.LangEn, i18n.LangZh, i18n.LangJa:
return v
default:
return i18n.LangAuto
}
}
File diff suppressed because it is too large Load Diff
+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)
}
}
}
+226
View File
@@ -0,0 +1,226 @@
package handlers
// P1 #2 回归测试:OAuth2 state 必须随机、回调必须校验。
// 旧实现 state 为硬编码常量且回调完全不校验(登录 CSRF / 授权码注入)。
import (
"encoding/json"
"fmt"
"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 },
"t": func(lang, key string) string { return key },
"tf": func(lang, key string, args ...interface{}) string { return fmt.Sprintf(key, args...) },
"htmlLang": func(lang string) string { return "zh-CN" },
"shortDate": func(lang string, t time.Time) string { return t.Format("2006-01-02") },
"localTime": func(t time.Time) time.Time { return t },
"time12": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04:05") },
"time12m": func(lang string, 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(lang, 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)
}
}
+9 -2
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"mail_go/internal/db"
"mail_go/internal/i18n"
"github.com/gin-gonic/gin"
)
@@ -12,16 +13,22 @@ import (
// Must be used after AuthMiddleware so that "currentUser" is available.
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
if v, ok := c.Get("lang"); ok {
if s, ok := v.(string); ok && i18n.Supported(s) {
lang = s
}
}
userVal, exists := c.Get("currentUser")
if !exists {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(lang, "禁止访问"))
c.Abort()
return
}
user, ok := userVal.(*db.User)
if !ok || !user.IsAdmin {
c.String(http.StatusForbidden, "禁止访问:需要管理员权限")
c.String(http.StatusForbidden, i18n.T(lang, "禁止访问:需要管理员权限"))
c.Abort()
return
}
+57
View File
@@ -1,12 +1,41 @@
package middleware
import (
"time"
"mail_go/internal/i18n"
"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 +48,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 +94,17 @@ 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
}
// 解析界面语言:用户偏好 auto 时按浏览器 Accept-Language 选择,
// 最终兜底英语。模板数据与错误提示统一使用该值。
c.Set("lang", i18n.Resolve(user.Language, c.GetHeader("Accept-Language")))
c.Set("currentUser", user)
c.Set("userID", id)
c.Next()
+5
View File
@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"mail_go/internal/i18n"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
@@ -15,8 +16,12 @@ func BanMiddleware(stores *store.Stores) gin.HandlerFunc {
ip := c.ClientIP()
banned, entry := stores.Bans.IsBanned(ip)
if banned {
// 封禁页无登录用户,按浏览器语言渲染
lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
c.Set("lang", lang)
c.HTML(http.StatusForbidden, "banned", gin.H{
"entry": entry,
"Lang": lang,
})
c.Abort()
return
+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{"Lang": "zh", "error": ""}},
{"banned", ginH{"Lang": "zh", "entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
{"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
{"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
{"view", ginH{"Lang": "zh",
"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{"Lang": "zh",
"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{"Lang": "zh", "currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
{"admin_dashboard", ginH{"Lang": "zh", "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{"Lang": "zh",
"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{"Lang": "zh",
"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{"Lang": "zh",
"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{}
+322 -41
View File
@@ -1,15 +1,25 @@
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/i18n"
"mail_go/internal/imap_server"
"mail_go/internal/mailutil"
"mail_go/internal/outbound"
"mail_go/internal/storage"
@@ -38,25 +48,33 @@ func formatBytes(b int64) string {
// WebServer wraps the Gin engine and its dependencies.
type WebServer struct {
engine *gin.Engine
stores *store.Stores
storage *storage.AttachmentStorage
cfg config.WebConfig
storageCfg config.StorageConfig
authCfg config.AuthConfig
banCfg config.BanConfig
outbound *outbound.Manager
engine *gin.Engine
stores *store.Stores
storage *storage.AttachmentStorage
cfg config.WebConfig
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.
func templateFuncs() 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))) },
"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 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 {
result := make([]int, n)
for i := 0; i < n; i++ {
@@ -67,11 +85,30 @@ 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)
// t / tf 多语言翻译(见 internal/i18n):t 返回译文,
// tf 对译文做 fmt.Sprintf(带占位符的消息)。
"t": i18n.T,
"tf": i18n.TF,
// htmlLang 返回 <html lang="..."> 属性值(zh → zh-CN)。
"htmlLang": func(lang string) string {
switch i18n.Normalize(lang) {
case i18n.LangZh:
return "zh-CN"
case i18n.LangJa:
return "ja"
default:
return "en"
}
},
"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 +116,232 @@ 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 时区。
"time12": time12,
// time12m 同上但不含秒。
"time12m": time12m,
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
"localTime": localTime,
// avatarStyle 根据字符串哈希生成头像背景/前景色。
"avatarStyle": avatarStyle,
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
"urlPath": url.PathEscape,
// folderLabel 返回文件夹的界面显示名(系统文件夹按语言翻译,自定义原名)。
"folderLabel": func(lang, name string) string {
return folderLabel(lang, name)
},
}
}
// folderLabel 返回文件夹的界面显示名:系统文件夹按界面语言翻译,
// 自定义文件夹原样返回。
func folderLabel(lang, name string) string {
switch name {
case "INBOX":
return i18n.T(lang, "收件箱")
case "Sent":
return i18n.T(lang, "已发送")
case "Drafts":
return i18n.T(lang, "草稿箱")
case "Trash":
return i18n.T(lang, "已删除")
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 小时制的时间段与小时:按界面语言输出
// 上午/下午(zh)、AM/PM(en)、午前/午後(ja),小时 1-12。
func periodOf(lang string, t time.Time) (string, int) {
period := i18n.T(lang, "上午")
if t.Hour() >= 12 {
period = i18n.T(lang, "下午")
}
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(lang string, t time.Time) string {
t = inWebTZ(t)
now := time.Now().In(t.Location())
period, h := periodOf(lang, 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(lang string, t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(lang, t)
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
}
// time12m 完整时间(12 小时制,不含秒)。
func time12m(lang string, t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(lang, 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,29 +353,46 @@ 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,
storage: attStorage,
cfg: cfg,
storageCfg: storageCfg,
authCfg: authCfg,
banCfg: banCfg,
outbound: ob,
engine: engine,
stores: stores,
storage: attStorage,
cfg: cfg,
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 +410,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 +446,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 +462,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)
}
}
}
+40 -32
View File
@@ -1,10 +1,10 @@
{{define "admin_bans"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IP黑名单 - MailGo</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{t .Lang "IP黑名单"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,64 +12,72 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "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>
<h2>{{t .Lang "IP 黑名单"}}</h2>
<span style="color:#7f8c8d;font-size:13px;">{{t .Lang "阶段性封禁:第 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>{{t .Lang "IP 地址"}}</th>
<th>{{t .Lang "状态"}}</th>
<th>{{t .Lang "封禁次数"}}</th>
<th>{{t .Lang "失败次数"}}</th>
<th>{{t .Lang "原因"}}</th>
<th>{{t .Lang "到期时间"}}</th>
<th>{{t .Lang "操作"}}</th>
</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;">{{t $.Lang "封禁中"}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang "已过期"}}</span>{{end}}
</td>
<td>
{{if .BanCount}}
{{if gt .BanCount 3}}{{tf $.Lang "第 %d 次封禁" (sub .BanCount 3)}}{{else}}{{t $.Lang "仅计数"}}{{end}}
{{else}}—{{end}}
</td>
<td>{{.FailCount}}</td>
<td>{{.Reason}}</td>
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{if .Reason}}{{t $.Lang .Reason}}{{else}}—{{end}}</td>
<td>{{time12 $.Lang .ExpiresAt}}{{if not .Active}}{{t $.Lang "(已过期)"}}{{end}}</td>
<td>
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?');">
<button type="submit" class="btn btn-primary btn-sm">解封</button>
onsubmit="return confirm('{{tf $.Lang "确定要解封 IP %s 解封后该 IP 的封禁档位将清零" .IPAddress}}');">
<button type="submit" class="btn btn-primary btn-sm">{{t $.Lang "解封"}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .rows}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无封禁记录"}}</p>
{{end}}
</div>
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/bans?page={{sub .page 1}}">上一页</a>
<a href="/admin/bans?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}}</span>
<span>{{tf $.Lang "第 %d / %d 页(共 %d 条)" .page .totalPages .total}}</span>
{{if lt .page .totalPages}}
<a href="/admin/bans?page={{add .page 1}}">下一页</a>
<a href="/admin/bans?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
@@ -0,0 +1,101 @@
{{define "admin_connections"}}
<!DOCTYPE html>
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{t .Lang "当前连接"}} - 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">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>{{t .Lang "当前连接(SMTP / IMAP / POP3"}}</h2>
<span style="color:#7f8c8d;font-size:13px;">{{t .Lang "每 5 秒自动刷新"}}</span>
</div>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{.total}}</h3>
<p>{{t .Lang "当前连接总数"}}</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>{{t .Lang "协议"}}</th>
<th>{{t .Lang "来源 IP"}}</th>
<th>{{t .Lang "端口"}}</th>
<th>{{t .Lang "用户名"}}</th>
<th>TLS</th>
<th>{{t .Lang "连接时间"}}</th>
<th>{{t .Lang "时长"}}</th>
<th>{{t .Lang "最后活跃"}}</th>
<th>{{t .Lang "操作"}}</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;">{{t $.Lang "明文"}}</span>{{end}}</td>
<td>{{time12 $.Lang .Connected}}</td>
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
<td>{{time12 $.Lang .LastActive}}</td>
<td>
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
onsubmit="return confirm('{{tf $.Lang "确定要断开 IP %s 的所有连接并加入黑名单180 " .IP}}');">
<button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "断开并封禁"}}</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">{{t $.Lang "当前没有活动连接"}}</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
{{end}}
+35 -33
View File
@@ -1,10 +1,10 @@
{{define "admin_dashboard"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<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>{{t .Lang "管理后台"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,72 +12,74 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<h2 style="margin-bottom:24px;">管理后台</h2>
<h2 style="margin-bottom:24px;">{{t .Lang "管理后台"}}</h2>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{.domainCount}}</h3>
<p>域名数</p>
<p>{{t .Lang "域名数"}}</p>
</div>
<div class="stat-card">
<h3>{{.userCount}}</h3>
<p>用户数</p>
<p>{{t .Lang "用户数"}}</p>
</div>
<div class="stat-card">
<h3>{{.totalMails}}</h3>
<p>邮件总数</p>
<p>{{t .Lang "邮件总数"}}</p>
</div>
<div class="stat-card">
<h3>{{.banCount}}</h3>
<p>被封IP</p>
<p>{{t .Lang "被封IP"}}</p>
</div>
</div>
<div class="card">
<h3>邮件分布</h3>
<h3>{{t .Lang "邮件分布"}}</h3>
<table style="margin-top:12px;">
<thead>
<tr>
<th>文件夹</th>
<th>邮件数</th>
<th>占用空间</th>
<th>{{t .Lang "文件夹"}}</th>
<th>{{t .Lang "邮件数"}}</th>
<th>{{t .Lang "占用空间"}}</th>
</tr>
</thead>
<tbody>
<tr><td>收件箱 (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
<tr><td>发件箱 (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
<tr><td>草稿箱 (Drafts)</td><td>{{.draftsCount}}</td><td></td></tr>
<tr><td>垃圾箱 (Trash)</td><td>{{.trashCount}}</td><td></td></tr>
<tr style="font-weight:bold;"><td>合计</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
<tr><td>{{folderLabel .Lang "INBOX"}} (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
<tr><td>{{folderLabel .Lang "Sent"}} (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
<tr><td>{{folderLabel .Lang "Drafts"}} (Drafts)</td><td>{{.draftsCount}}</td><td></td></tr>
<tr><td>{{folderLabel .Lang "Trash"}} (Trash)</td><td>{{.trashCount}}</td><td></td></tr>
<tr style="font-weight:bold;"><td>{{t .Lang "合计"}}</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h3>收发统计</h3>
<h3>{{t .Lang "收发统计"}}</h3>
<table style="margin-top:12px;">
<thead>
<tr><th>时间段</th><th>收件</th><th>发件</th></tr>
<tr><th>{{t .Lang "时间段"}}</th><th>{{t .Lang "收件"}}</th><th>{{t .Lang "发件"}}</th></tr>
</thead>
<tbody>
<tr><td>今日</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
<tr><td>近 7 天</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
<tr><td>{{t .Lang "今日"}}</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
<tr><td>{{t .Lang "近 7 天"}}</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h3>快捷操作</h3>
<h3>{{t .Lang "快捷操作"}}</h3>
<p style="margin-top:12px;">
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">新增用户</a>
<a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">查看所有邮件</a>
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">IP黑名单</a>
<a href="/admin/domains/new" class="btn btn-primary">{{t .Lang "新增域名"}}</a>
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "新增用户"}}</a>
<a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "查看所有邮件"}}</a>
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "IP黑名单"}}</a>
</p>
</div>
</div>
+20 -18
View File
@@ -1,10 +1,10 @@
{{define "admin_dns_hint"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DNS配置 - MailGo</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{t .Lang "DNS配置"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,37 +12,39 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">DNS 配置提示 — {{.domain.Name}}</h2>
<p style="margin-bottom:16px;color:#7f8c8d;">请在您的 DNS 服务商处添加以下记录:</p>
<h2 style="margin-bottom:16px;">{{tf .Lang "DNS 配置提示 — %s" .domain.Name}}</h2>
<p style="margin-bottom:16px;color:#7f8c8d;">{{t .Lang "请在您的 DNS 服务商处添加以下记录:"}}</p>
<h4 style="margin-bottom:8px;">MX 记录</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "MX 记录"}}</h4>
<div class="dns-record">@ IN MX 10 mail.{{.domain.Name}}.</div>
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "SPF 记录 (TXT)"}}</h4>
<div class="dns-record">@ IN TXT "v=spf1 mx -all"</div>
<h4 style="margin-bottom:8px;">DKIM 记录 (TXT)</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "DKIM 记录 (TXT)"}}</h4>
{{if .domain.DkimPublicKey}}
<div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div>
{{else}}
<div class="dns-record" style="color:#e67e22;">⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。</div>
<div class="dns-record" style="color:#e67e22;">{{t .Lang "⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。"}}</div>
{{end}}
<h4 style="margin-bottom:8px;">DMARC 记录</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "DMARC 记录"}}</h4>
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
<div style="margin-top:24px;">
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">返回域名列表</a>
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">{{t .Lang "返回域名列表"}}</a>
</div>
</div>
</div>
+58 -27
View File
@@ -1,10 +1,10 @@
{{define "admin_domain_form"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{end}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,21 +12,24 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2>
<h2 style="margin-bottom:16px;">{{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{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>
<label>{{t .Lang "域名"}}</label>
{{if .isEdit}}
<input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;">
{{else}}
@@ -34,41 +37,69 @@
{{end}}
</div>
<div class="form-group">
<label>SMTP 端口</label>
<label>{{t .Lang "SMTP 端口"}}</label>
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
</div>
<div class="form-group">
<label>IMAP 端口</label>
<label>{{t .Lang "IMAP 端口"}}</label>
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
</div>
<div class="form-group">
<label>POP3 端口</label>
<label>{{t .Lang "POP3 端口"}}</label>
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="tls_enabled" id="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}} onchange="togglePorts()">
启用 TLS
{{t .Lang "启用 TLS"}}
</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">勾选后端口将自动切换为 SSL/TLS 标准端口</p>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">{{t .Lang "勾选后端口将自动切换为 SSL/TLS 标准端口"}}</p>
</div>
{{if .isEdit}}
<div id="tls_cert_fields" style="display:none;">
<div class="form-group">
<label>TLS 私钥 PEM</label>
<label>{{t .Lang "TLS 私钥 PEM"}}</label>
<textarea name="tls_private_key" rows="8" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" style="font-family:monospace;"></textarea>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">私钥不会回显;留空则保留现有私钥。</p>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">{{t .Lang "私钥不会回显;留空则保留现有私钥。"}}</p>
</div>
<div class="form-group">
<label>TLS 公钥证书 PEM</label>
<label>{{t .Lang "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;">{{t .Lang "✅ TLS 证书已配置;上传新证书后自动热加载生效。"}}</p>
{{else}}
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。</p>
<p style="color:#e67e22;font-size:12px;margin-top:4px;">{{t .Lang "⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。"}}</p>
{{end}}
</div>
</div>
<div class="form-group" style="margin-top:4px;">
<label>{{t .Lang "从 Caddy 获取证书"}}</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">{{t .Lang "若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。"}}</p>
<button type="button" class="btn" id="btn_fetch_caddy" onclick="fetchCaddyCert()" style="background:#2e86de;color:#fff;">{{t .Lang "🔒 从 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 = '{{t .Lang "获取中…"}}';
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 = '{{t .Lang "获取失败"}}: ' + await resp.text();
} catch (e) {
msg.textContent = '{{t .Lang "请求失败"}}: ' + e;
}
btn.disabled = false;
btn.textContent = oldText;
}
</script>
</div>
{{end}}
<script>
function togglePorts() {
@@ -87,17 +118,17 @@
<div class="form-group">
<label>
<input type="checkbox" name="regenerate_dkim">
重新生成 DKIM 密钥
{{t .Lang "重新生成 DKIM 密钥"}}
</label>
{{if .domain.DkimPublicKey}}
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ DKIM 密钥已配置</p>
<p style="color:#27ae60;font-size:12px;margin-top:4px;">{{t .Lang "✅ DKIM 密钥已配置"}}</p>
{{else}}
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ DKIM 密钥未配置</p>
<p style="color:#e67e22;font-size:12px;margin-top:4px;">{{t .Lang "⚠️ DKIM 密钥未配置"}}</p>
{{end}}
</div>
{{end}}
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
<button type="submit" class="btn btn-primary">{{if .isEdit}}{{t .Lang "保存更改"}}{{else}}{{t .Lang "创建域名"}}{{end}}</button>
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
</form>
</div>
</div>
+23 -21
View File
@@ -1,10 +1,10 @@
{{define "admin_domains"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<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>{{t .Lang "域名管理"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,33 +12,35 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>域名列表</h2>
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
<h2>{{t .Lang "域名列表"}}</h2>
<a href="/admin/domains/new" class="btn btn-primary">{{t .Lang "新增域名"}}</a>
</div>
{{if not .domains}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无域名</p>
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无域名"}}</p>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>域名</th>
<th>{{t $.Lang "域名"}}</th>
<th>SMTP</th>
<th>IMAP</th>
<th>POP3</th>
<th>TLS</th>
<th>操作</th>
<th>{{t $.Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -51,11 +53,11 @@
<td>{{.Pop3Port}}</td>
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
<td>
<a href="/admin/domains/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
<a href="/admin/domains/{{.ID}}/edit" class="btn btn-primary btn-sm">{{t $.Lang "编辑"}}</a>
<a href="/admin/domains/{{.ID}}/dns" class="btn btn-primary btn-sm">DNS</a>
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
onsubmit="return confirm('{{tf $.Lang "确定要删除域名 %s 删除域名将导致关联用户无法收发邮件" .Name}}');">
<button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
</form>
</td>
</tr>
@@ -67,11 +69,11 @@
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/domains?page={{sub .page 1}}">上一页</a>
<a href="/admin/domains?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}}</span>
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}
<a href="/admin/domains?page={{add .page 1}}">下一页</a>
<a href="/admin/domains?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
+22 -20
View File
@@ -1,10 +1,10 @@
{{define "admin_mail_view"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<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>{{t .Lang "查看邮件"}} - MailGo {{t .Lang "管理后台"}}</title>
{{template "styles" .}}
<style>
.mail-body-iframe {
@@ -21,38 +21,40 @@
<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" class="active">所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" class="active">{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<div style="margin-bottom:16px;">
<a href="/admin/mails" class="btn" style="background:#bdc3c7;color:#fff;">返回邮件列表</a>
<a href="/admin/mails" class="btn" style="background:#bdc3c7;color:#fff;">{{t .Lang "返回邮件列表"}}</a>
</div>
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h2>
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}{{t .Lang "(无主题)"}}{{end}}</h2>
<div class="mail-meta" style="margin-top:12px;">
<p><strong>发件人:</strong> {{decodeHeader .message.FromAddr}}</p>
<p><strong>收件人:</strong> {{.message.ToAddr}}</p>
{{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>{{t .Lang "发件人:"}}</strong> {{decodeHeader .message.FromAddr}}</p>
<p><strong>{{t .Lang "收件人:"}}</strong> {{.message.ToAddr}}</p>
{{if .message.CcAddr}}<p><strong>{{t .Lang "抄送:"}}</strong> {{.message.CcAddr}}</p>{{end}}
<p><strong>{{t .Lang "所属用户:"}}</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
<p><strong>{{t .Lang "文件夹:"}}</strong> {{.message.Folder}}</p>
<p><strong>{{t .Lang "时间:"}}</strong> {{time12 .Lang .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}}
</div>
{{if .attachments}}
<div class="attachment-list">
<h4 style="margin-bottom:8px;">附件</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "附件列表"}}</h4>
{{range .attachments}}
<div class="attachment-item">
📎 <a href="/admin/attachment/{{.ID}}">{{.FileName}}</a>
+25 -23
View File
@@ -1,10 +1,10 @@
{{define "admin_mails"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<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>{{t .Lang "所有邮件"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,36 +12,38 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">所有邮件</h2>
<h2 style="margin-bottom:16px;">{{t .Lang "所有邮件"}}</h2>
<div style="margin-bottom:16px;">
<a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>全部</a>
<a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>{{t .Lang "全部"}}</a>
<a href="/admin/mails?folder=INBOX" {{if eq .folder "INBOX"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>INBOX</a>
<a href="/admin/mails?folder=Sent" {{if eq .folder "Sent"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Sent</a>
<a href="/admin/mails?folder=Drafts" {{if eq .folder "Drafts"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Drafts</a>
<a href="/admin/mails?folder=Trash" {{if eq .folder "Trash"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Trash</a>
</div>
{{if not .messages}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无邮件"}}</p>
{{else}}
<table>
<thead>
<tr>
<th>发件人</th>
<th>收件人</th>
<th>主题</th>
<th>所属用户</th>
<th>文件夹</th>
<th>日期</th>
<th>{{t $.Lang "发件人"}}</th>
<th>{{t $.Lang "收件人"}}</th>
<th>{{t $.Lang "主题"}}</th>
<th>{{t $.Lang "所属用户"}}</th>
<th>{{t $.Lang "文件夹"}}</th>
<th>{{t $.Lang "日期"}}</th>
</tr>
</thead>
<tbody>
@@ -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 $.Lang .Date}}</td>
</tr>
{{end}}
</tbody>
@@ -62,11 +64,11 @@
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">上一页</a>
<a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}}</span>
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}
<a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">下一页</a>
<a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
+45 -43
View File
@@ -1,10 +1,10 @@
{{define "admin_outbound"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<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>{{t .Lang "外发队列"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,58 +12,60 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<h2 style="margin-bottom:24px;">外发队列</h2>
<h2 style="margin-bottom:24px;">{{t .Lang "外发队列"}}</h2>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{index .statCounts "pending"}}</h3>
<p>待发送</p>
<p>{{t .Lang "待发送"}}</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "deferred"}}</h3>
<p>等待重试</p>
<p>{{t .Lang "等待重试"}}</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "sent"}}</h3>
<p>已送达</p>
<p>{{t .Lang "已送达"}}</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "failed"}}</h3>
<p>失败</p>
<p>{{t .Lang "失败"}}</p>
</div>
</div>
<div class="card">
<div style="margin-bottom:12px;">
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">全部</a>
<a href="/admin/outbound?status=pending" class="btn btn-sm">待发送</a>
<a href="/admin/outbound?status=deferred" class="btn btn-sm">等待重试</a>
<a href="/admin/outbound?status=sent" class="btn btn-sm">已送达</a>
<a href="/admin/outbound?status=failed" class="btn btn-sm">失败</a>
<a href="/admin/outbound?status=canceled" class="btn btn-sm">已取消</a>
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">{{t .Lang "全部"}}</a>
<a href="/admin/outbound?status=pending" class="btn btn-sm">{{t .Lang "待发送"}}</a>
<a href="/admin/outbound?status=deferred" class="btn btn-sm">{{t .Lang "等待重试"}}</a>
<a href="/admin/outbound?status=sent" class="btn btn-sm">{{t .Lang "已送达"}}</a>
<a href="/admin/outbound?status=failed" class="btn btn-sm">{{t .Lang "失败"}}</a>
<a href="/admin/outbound?status=canceled" class="btn btn-sm">{{t .Lang "已取消"}}</a>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>发件人</th>
<th>收件人</th>
<th>状态</th>
<th>尝试次数</th>
<th>下次重试</th>
<th>最后响应 / 错误</th>
<th>创建时间</th>
<th>操作</th>
<th>{{t .Lang "发件人"}}</th>
<th>{{t .Lang "收件人"}}</th>
<th>{{t .Lang "状态"}}</th>
<th>{{t .Lang "尝试次数"}}</th>
<th>{{t .Lang "下次重试"}}</th>
<th>{{t .Lang "最后响应 / 错误"}}</th>
<th>{{t .Lang "创建时间"}}</th>
<th>{{t .Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -73,40 +75,40 @@
<td>{{.FromAddr}}</td>
<td>{{.ToAddr}}</td>
<td>
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{call $.statusText .Status}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{t $.Lang (call $.statusText .Status)}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang (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 $.Lang .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 $.Lang .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;">
<button type="submit" class="btn btn-sm btn-primary">重试</button>
<button type="submit" class="btn btn-sm btn-primary">{{t $.Lang "重试"}}</button>
</form>
{{end}}
{{if or (eq .Status "pending") (eq .Status "deferred")}}
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('确认取消该投递任务?');">
<button type="submit" class="btn btn-sm btn-danger">取消</button>
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('{{t $.Lang "确认取消该投递任务"}}');">
<button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "取消"}}</button>
</form>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">队列为空</td></tr>
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">{{t $.Lang "队列为空"}}</td></tr>
{{end}}
</tbody>
</table>
{{if gt .totalPages 1}}
<div class="pagination">
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">上一页</a>{{end}}
<span class="current">第 {{.page}} / {{.totalPages}}</span>
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">下一页</a>{{end}}
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">{{t $.Lang "上一页"}}</a>{{end}}
<span class="current">{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">{{t $.Lang "下一页"}}</a>{{end}}
</div>
{{end}}
</div>
@@ -0,0 +1,132 @@
{{define "admin_protocol_logs"}}
<!DOCTYPE html>
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{t .Lang "协议日志"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>{{t .Lang "协议日志(SMTP / IMAP / POP3"}}</h2>
<form method="POST" action="/admin/protocol-logs/cleanup" style="display:inline;"
onsubmit="return confirm('{{tf .Lang "确认清理 %d 天前的协议日志" .keepDays}}');">
<button type="submit" class="btn btn-primary">{{t .Lang "清理旧日志"}}</button>
</form>
</div>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{index .todayStats "smtp" "fail"}}</h3>
<p>{{t .Lang "今日 SMTP 失败"}}</p>
</div>
<div class="stat-card">
<h3>{{index .todayStats "imap" "fail"}}</h3>
<p>{{t .Lang "今日 IMAP 失败"}}</p>
</div>
<div class="stat-card">
<h3>{{index .todayStats "pop3" "fail"}}</h3>
<p>{{t .Lang "今日 POP3 失败"}}</p>
</div>
<div class="stat-card">
<h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3>
<p>{{t .Lang "历史失败(全部)"}}</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="">{{t .Lang "全部协议"}}</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="">{{t .Lang "全部状态"}}</option>
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>{{t .Lang "成功"}}</option>
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>{{t .Lang "失败"}}</option>
</select>
<input type="text" name="ip" placeholder="{{t .Lang "来源 IP模糊"}}" value="{{.filter.ip}}" style="padding:6px 10px;width:160px;">
<input type="text" name="username" placeholder="{{t .Lang "用户名模糊"}}" value="{{.filter.username}}" style="padding:6px 10px;width:160px;">
<input type="date" name="from" value="{{.filter.from}}" style="padding:6px 10px;">
<span>{{t .Lang "至"}}</span>
<input type="date" name="to" value="{{.filter.to}}" style="padding:6px 10px;">
<button type="submit" class="btn btn-sm btn-primary">{{t .Lang "筛选"}}</button>
<a href="/admin/protocol-logs" class="btn btn-sm">{{t .Lang "重置"}}</a>
</div>
</form>
<table>
<thead>
<tr>
<th>{{t .Lang "时间"}}</th>
<th>{{t .Lang "协议"}}</th>
<th>{{t .Lang "端口"}}</th>
<th>{{t .Lang "来源 IP"}}</th>
<th>{{t .Lang "用户名"}}</th>
<th>{{t .Lang "状态"}}</th>
<th>{{t .Lang "失败原因"}}</th>
<th>{{t .Lang "操作摘要"}}</th>
<th>{{t .Lang "消息数"}}</th>
<th>{{t .Lang "时长"}}</th>
</tr>
</thead>
<tbody>
{{range .logs}}
<tr>
<td style="white-space:nowrap;">{{time12 $.Lang .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;">{{t $.Lang "成功"}}</span>
{{else}}<span class="badge badge-unread">{{t $.Lang "失败"}}</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;">{{t $.Lang "暂无记录"}}</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}}">{{t $.Lang "上一页"}}</a>{{end}}
<span class="current">{{tf $.Lang "第 %d / %d 页(共 %d 条)" .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}}">{{t $.Lang "下一页"}}</a>{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
</body>
</html>
{{end}}
+32 -21
View File
@@ -1,10 +1,10 @@
{{define "admin_user_form"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}{{t .Lang "编辑用户"}}{{else}}{{t .Lang "新增用户"}}{{end}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,56 +12,67 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑用户{{else}}新增用户{{end}}</h2>
<h2 style="margin-bottom:16px;">{{if .isEdit}}{{t .Lang "编辑用户"}}{{else}}{{t .Lang "新增用户"}}{{end}}</h2>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
<div class="form-group">
<label>用户名</label>
<label>{{t .Lang "用户名"}}</label>
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
</div>
<div class="form-group">
<label>{{if .isEdit}}新密码(留空则不修改){{else}}密码{{end}}</label>
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="请输入密码">
<label>{{if .isEdit}}{{t .Lang "新密码(留空则不修改)"}}{{else}}{{t .Lang "密码"}}{{end}}</label>
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="{{t .Lang "请输入密码"}}">
</div>
<div class="form-group">
<label>域名</label>
<label>{{t .Lang "界面语言"}}</label>
<select name="language">
<option value="auto" {{if eq .user.Language "auto"}}selected{{end}}>{{t .Lang "自动(跟随浏览器)"}}</option>
<option value="en" {{if eq .user.Language "en"}}selected{{end}}>English</option>
<option value="zh" {{if eq .user.Language "zh"}}selected{{end}}>中文</option>
<option value="ja" {{if eq .user.Language "ja"}}selected{{end}}>日本語</option>
</select>
</div>
<div class="form-group">
<label>{{t .Lang "域名"}}</label>
<select name="domain_id" required>
<option value="">请选择域名</option>
<option value="">{{t .Lang "请选择域名"}}</option>
{{range .domains}}
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group">
<label>配额 (GB)</label>
<label>{{t .Lang "配额 (GB)"}}</label>
<input type="number" name="quota_gb" min="1" value="5" placeholder="5">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
管理员
{{t .Lang "管理员"}}
</label>
</div>
{{if .isEdit}}
<div class="form-group">
<label>
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
启用账户
{{t .Lang "启用账户"}}
</label>
</div>
{{end}}
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建用户{{end}}</button>
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
<button type="submit" class="btn btn-primary">{{if .isEdit}}{{t .Lang "保存更改"}}{{else}}{{t .Lang "创建用户"}}{{end}}</button>
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
</form>
</div>
</div>
+28 -26
View File
@@ -1,10 +1,10 @@
{{define "admin_users"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<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>{{t .Lang "用户管理"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,34 +12,36 @@
<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/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>用户列表</h2>
<a href="/admin/users/new" class="btn btn-primary">新增用户</a>
<h2>{{t .Lang "用户列表"}}</h2>
<a href="/admin/users/new" class="btn btn-primary">{{t .Lang "新增用户"}}</a>
</div>
{{if not .users}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无用户</p>
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无用户"}}</p>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>域名ID</th>
<th>配额</th>
<th>已用</th>
<th>状态</th>
<th>管理员</th>
<th>操作</th>
<th>{{t $.Lang "用户名"}}</th>
<th>{{t $.Lang "域名ID"}}</th>
<th>{{t $.Lang "配额"}}</th>
<th>{{t $.Lang "已用"}}</th>
<th>{{t $.Lang "状态"}}</th>
<th>{{t $.Lang "管理员"}}</th>
<th>{{t $.Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -53,10 +55,10 @@
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
<td>
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">{{t $.Lang "编辑"}}</a>
<form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('确定要删除用户 {{.Username}} 吗?');">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
onsubmit="return confirm('{{tf $.Lang "确定要删除用户 %s " .Username}}');">
<button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
</form>
</td>
</tr>
@@ -68,11 +70,11 @@
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/users?page={{sub .page 1}}">上一页</a>
<a href="/admin/users?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}}</span>
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}
<a href="/admin/users?page={{add .page 1}}">下一页</a>
<a href="/admin/users?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
+47 -24
View File
@@ -1,53 +1,76 @@
{{define "banned"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<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>{{t .Lang "访问被禁止"}} - 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>
<body>
<div class="banned-card">
<div class="banned-icon">🚫</div>
<h1>您的 IP 已被暂时禁止访问</h1>
<p>由于登录失败次数过多,您的 IP 地址已被暂时封禁。</p>
<h1>{{t .Lang "您的 IP 已被暂时禁止访问"}}</h1>
<p>{{t .Lang "由于登录失败次数过多,您的 IP 地址已被暂时封禁。"}}</p>
{{if .entry}}
<div class="detail">
<div class="detail-row">
<span class="detail-label">IP 地址</span>
<span class="detail-label">{{t .Lang "IP 地址"}}</span>
<span class="detail-value">{{.entry.IPAddress}}</span>
</div>
<div class="detail-row">
<span class="detail-label">原因</span>
<span class="detail-value">{{.entry.Reason}}</span>
<span class="detail-label">{{t .Lang "封禁档位"}}</span>
<span class="detail-value">{{if gt .entry.BanCount 3}}{{tf .Lang "第 %d 次封禁" (sub .entry.BanCount 3)}}{{else}}—{{end}}</span>
</div>
<div class="detail-row">
<span class="detail-label">失败次数</span>
<span class="detail-value">{{.entry.FailCount}} 次</span>
<span class="detail-label">{{t .Lang "原因"}}</span>
<span class="detail-value">{{t .Lang .entry.Reason}}</span>
</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-label">{{t .Lang "失败次数"}}</span>
<span class="detail-value">{{tf .Lang "%d 次" .entry.FailCount}}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{t .Lang "解封时间"}}</span>
<span class="detail-value">{{time12 .Lang .entry.ExpiresAt}}</span>
</div>
</div>
{{end}}
<p style="font-size:13px;">请在解封时间后重试,或联系管理员。</p>
<a href="/login" class="back-link">返回登录页</a>
<p style="font-size:13px;">{{t .Lang "请在解封时间后重试,或联系管理员。"}}</p>
<a href="/login" class="back-link">{{t .Lang "返回登录页"}}</a>
</div>
</body>
</html>
+609 -55
View File
@@ -1,66 +1,620 @@
{{define "styles"}}
<style>
* { 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; }
.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 input[type="checkbox"] { width:auto; }
.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; }
.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; }
.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; }
.clearfix::after { content:""; display:table; clear:both; }
/* ===== 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", "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: 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: 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 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: #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>{{t .Lang "邮箱"}}</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="{{t .Lang "搜索邮件发件人 / 主题"}}" autocomplete="off">
</div>
<div class="topbar-right">
{{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">{{t .Lang "管理后台"}}</a>{{end}}
<a class="topbar-link" href="/settings">{{t .Lang "设置"}}</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="{{t .Lang "退出登录"}}">
<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>
</nav>
</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>
{{t .Lang "写信"}}
</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 $.Lang .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>
{{t .Lang "管理后台"}}
</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('{{t .Lang "确定要删除选中的 %s 封邮件吗?"}}'.replace('%s', 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}}

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