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

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

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

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

- 依赖:go-imap v1.2.1 → go-imap/v2 v2.0.0-beta.8(原生支持 UID
  EXPUNGE,且库为 race-clean,移除集成测试 !race 标签)
- Message 新增 IsDeleted 字段持久化 \Deleted 标记(AutoMigrate 自动
  加列,SQLite/MySQL 通用;migrate 工具随模型字段自动复制)
- IMAP 层重写为 v2 imapserver.Session 架构(session.go 替代
  backend.go):SELECT/STATUS/LIST/FETCH/SEARCH/STORE/COPY/MOVE/
  APPEND/EXPUNGE/Poll/Idle;EXPUNGE 按数据库 IsDeleted 删除,UID
  EXPUNGE 只删集合内已标记消息
- server.go:显式能力集(IMAP4rev1+UIDPLUS+MOVE+LITERAL++CHILDREN+
  SPECIAL-USE);自实现 mailboxHub 跨会话推送(含来源排除,避免 v2
  库 MailboxTracker 对 EXPUNGE/EXISTS 的回声重复响应);Pusher 接口
  签名不变,Web/SMTP/POP3 调用方零改动;DisconnectByAddr 改用会话
  注册表 + conn.Bye()
- 测试:集成测试改用 v2 imapclient,新增删除流程/跨会话持久化/MOVE
  回归用例;notify_test 适配 hub 推送模型并覆盖来源排除与
  allowExpunge 语义
2026-08-20 00:44:37 +08:00
kevin f533fc8feb Merge pull request 'feat(db): 支持 MySQL 迁移(修复模型外键冲突 + 大文本类型 + 迁移工具)' (#13) from dsh/mailgo:fix/mysql-migration into main
Reviewed-on: kevin/mailgo#13
2026-08-19 11:49:06 -04:00
kevin 8be5df57c6 Merge pull request 'fix(auth+store): 协议登录成功清零失败计数/支持裸用户名 + 邮件日期统一 UTC 排序' (#12) from dsh/mailgo:fix/auth-reset-and-date-sort into main
Reviewed-on: kevin/mailgo#12
2026-08-19 11:48:59 -04:00
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
54 changed files with 5240 additions and 2091 deletions
+10
View File
@@ -13,6 +13,12 @@
# Go 工具缓存 # Go 工具缓存
vendor/ 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 *.log
@@ -40,3 +46,7 @@ win/srv/
# 临时测试文件 # 临时测试文件
login_test.html login_test.html
migrate
# 本地构建产物
bin/
+3 -3
View File
@@ -403,8 +403,8 @@ mailgo/
│ │ ├── manager.go # 外发队列、重试、限速、退信 │ │ ├── manager.go # 外发队列、重试、限速、退信
│ │ └── sign.go # DKIM 签名 │ │ └── sign.go # DKIM 签名
│ ├── imap_server/ │ ├── imap_server/
│ │ ├── server.go # IMAP 服务 │ │ ├── server.go # IMAP 服务(监听器/能力/跨会话推送)
│ │ └── backend.go # IMAP 后端 │ │ └── session.go # IMAP Session 实现(SELECT/FETCH/STORE/EXPUNGE
│ ├── pop3_server/server.go # POP3 服务 │ ├── pop3_server/server.go # POP3 服务
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控) │ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
│ ├── storage/attachment.go # 附件文件存储 │ ├── storage/attachment.go # 附件文件存储
@@ -521,7 +521,7 @@ sudo journalctl -u mailgo -f
| 数据库 | SQLite(默认)/ MySQL | | 数据库 | SQLite(默认)/ MySQL |
| 配置格式 | TOML | | 配置格式 | TOML |
| SMTP | github.com/emersion/go-smtp | | SMTP | github.com/emersion/go-smtp |
| IMAP | github.com/emersion/go-imap v1 | | IMAP | github.com/emersion/go-imap/v2 |
| POP3 | 手工实现 TCP 协议 | | POP3 | 手工实现 TCP 协议 |
| 密码哈希 | golang.org/x/crypto/bcrypt | | 密码哈希 | golang.org/x/crypto/bcrypt |
| 富文本 | Quill.js (CDN) | | 富文本 | Quill.js (CDN) |
+260
View File
@@ -0,0 +1,260 @@
// migrate 一次性工具:把 SQLite 数据迁移到 MySQLmailgo 库)。
// 用法:go run ./cmd/migrate -from /srv/mail_go/mail.db -dsn "mailgo:密码@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=UTC"
package main
import (
"flag"
"fmt"
"log"
"time"
"mail_go/config"
"mail_go/internal/db"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
fromDSN = flag.String("from", "/srv/mail_go/mail.db", "SQLite 数据库路径")
mysqlDSN = flag.String("dsn", "", "MySQL DSN(目标库,需已创建 mailgo 库与用户)")
)
func main() {
flag.Parse()
if *mysqlDSN == "" {
log.Fatal("缺少 -dsn")
}
// 目标:MySQLInitDB 内含 AutoMigrate,按当前模型建表)
mdb, err := db.InitDB(config.DatabaseConfig{Driver: "mysql", DSN: *mysqlDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
if err != nil {
log.Fatalf("连接 MySQL 失败: %v", err)
}
log.Println("MySQL 建表完成(AutoMigrate")
// 源:SQLite(只读)
sdb, err := db.InitDB(config.DatabaseConfig{Driver: "sqlite", DSN: *fromDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
if err != nil {
log.Fatalf("连接 SQLite 失败: %v", err)
}
sdb.Logger = logger.Default.LogMode(logger.Silent)
// 关闭 GORM 自动时间戳(保留原始 CreatedAt/UpdatedAt
mw := mdb.Session(&gorm.Session{SkipHooks: true})
stateWant := int64(-1) // mailbox_states 期望行数;-1 = 源库无此表不校验
// 按外键依赖顺序复制:domains → users → messages → attachments → 其余
// 所有时间统一 UTCMySQL DATETIME 无时区)。
utc := func(t time.Time) time.Time {
if t.IsZero() {
// MySQL DATETIME 最小年份 1000;零值由调用方转 NULL
return t
}
return t.UTC()
}
_ = utc
// ---- domains ----
var domains []db.Domain
if err := sdb.Order("id").Find(&domains).Error; err != nil {
log.Fatalf("读 domains: %v", err)
}
for i := range domains {
domains[i].CreatedAt = domains[i].CreatedAt.UTC()
domains[i].UpdatedAt = domains[i].UpdatedAt.UTC()
}
if err := mw.Create(&domains).Error; err != nil {
log.Fatalf("写 domains: %v", err)
}
log.Printf("domains: %d", len(domains))
// ---- users ----
var users []db.User
if err := sdb.Order("id").Find(&users).Error; err != nil {
log.Fatalf("读 users: %v", err)
}
for i := range users {
users[i].CreatedAt = users[i].CreatedAt.UTC()
users[i].UpdatedAt = users[i].UpdatedAt.UTC()
}
if err := mw.Create(&users).Error; err != nil {
log.Fatalf("写 users: %v", err)
}
log.Printf("users: %d", len(users))
// ---- messages ----
var msgs []db.Message
if err := sdb.Order("id").Find(&msgs).Error; err != nil {
log.Fatalf("读 messages: %v", err)
}
for i := range msgs {
msgs[i].Date = msgs[i].Date.UTC()
msgs[i].CreatedAt = msgs[i].CreatedAt.UTC()
}
if err := mw.Create(&msgs).Error; err != nil {
log.Fatalf("写 messages: %v", err)
}
log.Printf("messages: %d", len(msgs))
// ---- attachments ----
var atts []db.Attachment
if err := sdb.Order("id").Find(&atts).Error; err != nil {
log.Fatalf("读 attachments: %v", err)
}
for i := range atts {
atts[i].CreatedAt = atts[i].CreatedAt.UTC()
}
if err := mw.Create(&atts).Error; err != nil {
log.Fatalf("写 attachments: %v", err)
}
log.Printf("attachments: %d", len(atts))
// ---- outbound_messages(原样,含时间转 UTC----
var outs []db.OutboundMessage
if err := sdb.Order("id").Find(&outs).Error; err != nil {
log.Fatalf("读 outbound_messages: %v", err)
}
for i := range outs {
outs[i].NextAttemptAt = outs[i].NextAttemptAt.UTC()
if outs[i].CompletedAt != nil && !outs[i].CompletedAt.IsZero() {
u := outs[i].CompletedAt.UTC()
outs[i].CompletedAt = &u
}
outs[i].CreatedAt = outs[i].CreatedAt.UTC()
outs[i].UpdatedAt = outs[i].UpdatedAt.UTC()
}
if err := mw.Create(&outs).Error; err != nil {
log.Fatalf("写 outbound_messages: %v", err)
}
log.Printf("outbound_messages: %d", len(outs))
// ---- ban_entriesexpires_at 零值 → NULL----
rows, err := sdb.Raw("SELECT id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at FROM ban_entries ORDER BY id").Rows()
if err != nil {
log.Fatalf("读 ban_entries: %v", err)
}
defer rows.Close()
bans := 0
for rows.Next() {
var (
id uint
ip string
reason *string
failCount int
banCount int
expires *time.Time
created *time.Time
updated *time.Time
)
if err := rows.Scan(&id, &ip, &reason, &failCount, &banCount, &expires, &created, &updated); err != nil {
log.Fatalf("扫 ban_entries: %v", err)
}
norm := func(t *time.Time) *time.Time {
if t == nil || t.IsZero() {
return nil
}
u := t.UTC()
return &u
}
if err := mdb.Exec("INSERT INTO ban_entries (id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
id, ip, reason, failCount, banCount, norm(expires), norm(created), norm(updated)).Error; err != nil {
log.Fatalf("写 ban_entries id=%d: %v", id, err)
}
bans++
}
log.Printf("ban_entries: %d", bans)
// ---- protocol_logs ----
var logs []db.ProtocolLog
if err := sdb.Order("id").Find(&logs).Error; err != nil {
log.Fatalf("读 protocol_logs: %v", err)
}
for i := range logs {
logs[i].CreatedAt = logs[i].CreatedAt.UTC()
}
if err := mw.Create(&logs).Error; err != nil {
log.Fatalf("写 protocol_logs: %v", err)
}
log.Printf("protocol_logs: %d", len(logs))
// ---- mailbox_states(原生 SQL:该表随 UIDVALIDITY 特性存在,旧版本源库可能没有)----
var stateCount int64
sdb.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='mailbox_states'").Scan(&stateCount)
if stateCount > 0 {
// 目标库若没有该表(上游模型未含 MailboxState 时 AutoMigrate 不会建),先建表
var tcnt int64
mdb.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'mailbox_states'").Scan(&tcnt)
if tcnt == 0 {
if err := mdb.Exec(`CREATE TABLE mailbox_states (
user_id bigint unsigned NOT NULL,
folder varchar(64) NOT NULL,
uid_validity bigint unsigned NOT NULL,
created_at datetime(3) NULL,
updated_at datetime(3) NULL,
PRIMARY KEY (user_id, folder))`).Error; err != nil {
log.Fatalf("建 mailbox_states 表: %v", err)
}
log.Println("mailbox_states: 目标库已建表")
}
srows, err := sdb.Raw("SELECT user_id, folder, uid_validity, created_at, updated_at FROM mailbox_states ORDER BY user_id, folder").Rows()
if err != nil {
log.Fatalf("读 mailbox_states: %v", err)
}
defer srows.Close()
states := 0
for srows.Next() {
var (
userID uint
folder string
validity uint32
created *time.Time
updated *time.Time
)
if err := srows.Scan(&userID, &folder, &validity, &created, &updated); err != nil {
log.Fatalf("扫 mailbox_states: %v", err)
}
norm := func(t *time.Time) *time.Time {
if t == nil || t.IsZero() {
return nil
}
u := t.UTC()
return &u
}
if err := mdb.Exec("INSERT INTO mailbox_states (user_id, folder, uid_validity, created_at, updated_at) VALUES (?,?,?,?,?)",
userID, folder, validity, norm(created), norm(updated)).Error; err != nil {
log.Fatalf("写 mailbox_states: %v", err)
}
states++
}
log.Printf("mailbox_states: %d", states)
stateWant = int64(states)
} else {
log.Println("mailbox_states: 源库无此表,跳过")
}
// ---- 校验 ----
check := func(table string, want int64) {
var got int64
if err := mdb.Table(table).Count(&got).Error; err != nil {
log.Fatalf("校验 %s: %v", table, err)
}
if got != want {
log.Fatalf("校验 %s 失败: got %d want %d", table, got, want)
}
fmt.Printf("校验 %s: %d/%d ✓\n", table, got, want)
}
check("domains", int64(len(domains)))
check("users", int64(len(users)))
check("messages", int64(len(msgs)))
check("attachments", int64(len(atts)))
check("outbound_messages", int64(len(outs)))
check("ban_entries", int64(bans))
check("protocol_logs", int64(len(logs)))
if stateWant >= 0 {
check("mailbox_states", stateWant)
}
log.Println("迁移完成 ✅")
}
+8
View File
@@ -39,6 +39,10 @@ type WebConfig struct {
// ProtocolLogKeepDays SMTP/IMAP/POP3 协议调用日志保留天数, // ProtocolLogKeepDays SMTP/IMAP/POP3 协议调用日志保留天数,
// 超过该天数的记录会被后台任务自动清理。 // 超过该天数的记录会被后台任务自动清理。
ProtocolLogKeepDays int `toml:"protocol_log_keep_days"` ProtocolLogKeepDays int `toml:"protocol_log_keep_days"`
// Timezone Web 界面显示时间所用的 IANA 时区(如 "Asia/Shanghai")。
// 为空时使用服务器本地时区。邮件日期在库中统一为 UTC 存储,
// 展示时按此配置转换。
Timezone string `toml:"timezone"`
} }
// SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。 // SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。
@@ -212,6 +216,7 @@ func defaultConfig() *Config {
Addr: DefaultWebPort, Addr: DefaultWebPort,
CookieSecure: true, CookieSecure: true,
ProtocolLogKeepDays: DefaultProtocolLogKeepDays, ProtocolLogKeepDays: DefaultProtocolLogKeepDays,
Timezone: DefaultTimezone,
}, },
SMTP: SMTPConfig{ SMTP: SMTPConfig{
Addr: fmt.Sprintf(":%d", DefaultSMTPPort), Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
@@ -281,6 +286,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
if cfg.Web.ProtocolLogKeepDays == 0 { if cfg.Web.ProtocolLogKeepDays == 0 {
cfg.Web.ProtocolLogKeepDays = defaults.Web.ProtocolLogKeepDays cfg.Web.ProtocolLogKeepDays = defaults.Web.ProtocolLogKeepDays
} }
if cfg.Web.Timezone == "" {
cfg.Web.Timezone = defaults.Web.Timezone
}
if cfg.SMTP.Addr == "" { if cfg.SMTP.Addr == "" {
cfg.SMTP.Addr = defaults.SMTP.Addr cfg.SMTP.Addr = defaults.SMTP.Addr
} }
+3
View File
@@ -39,6 +39,9 @@ const (
// DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。 // DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。
const DefaultProtocolLogKeepDays = 30 const DefaultProtocolLogKeepDays = 30
// DefaultTimezone 是 Web 界面显示时间的默认 IANA 时区(北京时间)。
const DefaultTimezone = "Asia/Shanghai"
// Outbound delivery concurrency defaults. // Outbound delivery concurrency defaults.
const ( const (
// DefaultOutboundWorkers 并发投递 worker 数(0/1 为串行)。 // DefaultOutboundWorkers 并发投递 worker 数(0/1 为串行)。
+17 -15
View File
@@ -83,8 +83,8 @@ mail_go/
│ ├── smtp_server/ │ ├── smtp_server/
│ │ └── server.go # SMTP 服务端(go-smtp Backend 实现) │ │ └── server.go # SMTP 服务端(go-smtp Backend 实现)
│ ├── imap_server/ │ ├── imap_server/
│ │ ├── server.go # IMAP 服务端启动 │ │ ├── server.go # IMAP 服务端启动(监听器/能力/跨会话推送)
│ │ └── backend.go # go-imap Backend/User/Mailbox/Message 实现 │ │ └── session.go # go-imap/v2 imapserver.Session 实现
│ ├── pop3_server/ │ ├── pop3_server/
│ │ └── server.go # POP3 服务端(TCP 监听 + 文本协议) │ │ └── server.go # POP3 服务端(TCP 监听 + 文本协议)
│ ├── web/ │ ├── web/
@@ -373,11 +373,11 @@ func (s *smtpSession) Logout() error
#### IMAP Backend 接口(go-imap/v2 要求) #### IMAP Backend 接口(go-imap/v2 要求)
```go ```go
// internal/imap_server/backend.go // internal/imap_server/session.go
// 实现 go-imap/v2 的 backend.Backend 接口 // 实现 go-imap/v2 的 imapserver.Session 接口
type imapBackend struct { type imapSession struct {
userStore store.UserStore stores *store.Stores
mailStore store.MailStore mailStore store.MailStore
domainStore store.DomainStore domainStore store.DomainStore
attStore store.AttachmentStore attStore store.AttachmentStore
@@ -413,14 +413,16 @@ type imapMailbox struct {
| GET | /login | auth.ShowLogin | — | 登录页 | | GET | /login | auth.ShowLogin | — | 登录页 |
| POST | /login | auth.DoLogin | — | 登录提交 | | POST | /login | auth.DoLogin | — | 登录提交 |
| POST | /logout | auth.DoLogout | Auth | 登出 | | POST | /logout | auth.DoLogout | Auth | 登出 |
| GET | / | mail.Inbox | Auth | 收件箱(重定向到 /inbox | | GET | / | | Auth | 收件箱(重定向到 /inbox |
| GET | /inbox | mail.Inbox | Auth | 收件箱列表 | | GET | /folder/:name | mail.Folder | Auth | 通用文件夹页(目录与 IMAP LIST 同源) |
| GET | /inbox/:id | mail.View | Auth | 查看邮件 | | 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 | 撰写页面 | | GET | /compose | mail.Compose | Auth | 撰写页面 |
| POST | /compose | mail.DoSend | Auth | 发送邮件 | | POST | /compose | mail.DoSend | Auth | 发送邮件 |
| GET | /sent | mail.Sent | Auth | 发件箱 | | POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件(移入 Trash;Trash 内为彻底删除) |
| GET | /sent/:id | mail.View | Auth | 查看已发送邮件 | | POST | /mail/restore/:id | mail.Restore | Auth | 恢复邮件到收件箱 |
| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件 | | POST | /mail/purge/:id | mail.Purge | Auth | 彻底删除邮件 |
| POST | /mail/read/:id | mail.MarkRead | Auth | 标记已读 | | POST | /mail/read/:id | mail.MarkRead | Auth | 标记已读 |
| GET | /attachment/:id | mail.DownloadAttachment | Auth | 下载附件 | | GET | /attachment/:id | mail.DownloadAttachment | Auth | 下载附件 |
| GET | /admin | admin.Dashboard | Auth + Admin | 管理后台首页 | | GET | /admin | admin.Dashboard | Auth + Admin | 管理后台首页 |
@@ -557,7 +559,7 @@ sequenceDiagram
| Task ID | 任务名称 | 依赖 | 涉及文件 | 优先级 | | 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 | | 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 | | 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 | | 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 | | T05 | 集成调试 + 安装脚本 | T02, T03, T04 | main.go(更新), scripts/install.sh | P1 |
@@ -594,7 +596,7 @@ sequenceDiagram
- 管理后台 handler:域名 CRUD、用户 CRUD、DNS 提示 - 管理后台 handler:域名 CRUD、用户 CRUD、DNS 提示
- AttachmentStorage:附件文件写入/读取/删除磁盘操作 - AttachmentStorage:附件文件写入/读取/删除磁盘操作
- 所有 HTML 模板:base 布局 + login/inbox/compose/sent/view/admin 系列页面 - 所有 HTML 模板:base 布局 + login/folder/compose/view/admin 系列页面
- 附件上传(compose 页面多文件上传)+ 下载 handler - 附件上传(compose 页面多文件上传)+ 下载 handler
**T05: 集成调试 + 安装脚本** **T05: 集成调试 + 安装脚本**
@@ -614,7 +616,7 @@ go 1.22
require ( require (
github.com/emersion/go-smtp v0.21.0 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/emersion/go-message v0.18.0
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/sessions v0.0.5
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.4.0 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-message v0.18.2
github.com/emersion/go-msgauth v0.7.0 github.com/emersion/go-msgauth v0.7.0
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
+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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= 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-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 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck= 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 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 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 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= 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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= 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/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.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.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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+63 -1
View File
@@ -2,6 +2,7 @@ package db
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -55,10 +56,71 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
return nil, fmt.Errorf("连接数据库失败: %w", err) return nil, fmt.Errorf("连接数据库失败: %w", err)
} }
// AutoMigrate 前清理 ban_entries 的历史重复行(保留每 IP 最大 id):
// ip_address 将升级为唯一索引,重复行会使索引创建失败。
// 首次安装表不存在时忽略错误(AutoMigrate 会建新表)。
dedupeBanEntries(db)
// 再清理旧模型遗留的同名非唯一索引(见 dropLegacyBanIndex)。
dropLegacyBanIndex(db)
// Auto-migrate all models // Auto-migrate all models
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}); err != nil { if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}, &Mailbox{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err) return nil, fmt.Errorf("数据库迁移失败: %w", err)
} }
return db, nil 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)
}
+45 -4
View File
@@ -60,11 +60,16 @@ type Message struct {
ToAddr string `gorm:"size:2048;not null" json:"to_addr"` ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
CcAddr string `gorm:"size:2048" json:"cc_addr"` CcAddr string `gorm:"size:2048" json:"cc_addr"`
Subject string `gorm:"size:1024" json:"subject"` Subject string `gorm:"size:1024" json:"subject"`
TextBody string `gorm:"type:text" json:"text_body"` TextBody string `gorm:"type:mediumtext" json:"text_body"`
HtmlBody string `gorm:"type:text" json:"html_body"` HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
RawData string `gorm:"type:mediumtext" json:"raw_data"` RawData string `gorm:"type:mediumtext" json:"raw_data"`
IsRead bool `gorm:"default:false" json:"is_read"` IsRead bool `gorm:"default:false" json:"is_read"`
IsFlagged bool `gorm:"default:false" json:"is_flagged"` 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"` Date time.Time `json:"date"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -111,7 +116,9 @@ func (OutboundMessage) TableName() string {
// BanEntry represents an IP address that has been banned due to excessive login failures. // BanEntry represents an IP address that has been banned due to excessive login failures.
type BanEntry struct { type BanEntry struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
IPAddress string `gorm:"size:45;index;not null" json:"ip_address"` // IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用),
// 防止并发写入产生重复行导致计数与档位读写错位。
IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"`
Reason string `gorm:"size:255" json:"reason"` Reason string `gorm:"size:255" json:"reason"`
FailCount int `gorm:"default:0" json:"fail_count"` FailCount int `gorm:"default:0" json:"fail_count"`
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。 // BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
@@ -155,10 +162,11 @@ func (ProtocolLog) TableName() string {
} }
// Attachment represents a file attached to an email message. // Attachment represents a file attached to an email message.
// 注意:不声明 Message 关联(避免 GORM 外键名 MessageID 与
// Message.MessageID 字符串字段冲突,导致 AutoMigrate 生成错误外键)。
type Attachment struct { type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
MessageID uint `gorm:"index;not null" json:"message_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"` FileName string `gorm:"size:255;not null" json:"file_name"`
FilePath string `gorm:"size:512;not null" json:"file_path"` FilePath string `gorm:"size:512;not null" json:"file_path"`
ContentType string `gorm:"size:128" json:"content_type"` ContentType string `gorm:"size:128" json:"content_type"`
@@ -171,6 +179,39 @@ func (Attachment) TableName() string {
return "attachments" 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 状态。 // MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。
// UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间 // UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间
// 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存 // 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存
File diff suppressed because it is too large Load Diff
+282 -63
View File
@@ -1,10 +1,6 @@
//go:build !race // 集成测试:启动真实 IMAP 监听 + 脚本客户端(go-imap v2 imapclient)。
// v2 库为 race-clean(此前 v1.2.1 库内数据竞争导致本文件必须带
// 集成测试:启动真实 IMAP 监听 + 脚本客户端(go-imap client // !race 构建标签,升级后已移除)。推送逻辑的单元覆盖由 notify_test.go 承担
// 注意:仅在非 -race 构建下运行——go-imap v1.2.1 存在库内数据竞争
// cmd_selected.go STORE 写 *conn.silent() vs listenUpdates 读),
// 启用 backend 推送(Updates != nil)时必然触发,-race 下会误报。
// 推送逻辑的竞态覆盖由单元测试(notify_test.go)承担。
package imap_server package imap_server
import ( import (
@@ -18,8 +14,8 @@ import (
"mail_go/internal/db" "mail_go/internal/db"
"mail_go/internal/store" "mail_go/internal/store"
"github.com/emersion/go-imap" "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/client" "github.com/emersion/go-imap/v2/imapclient"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@@ -32,7 +28,7 @@ func startIntegrationServer(t *testing.T) (*store.Stores, string) {
if err != nil { if err != nil {
t.Fatalf("open sqlite: %v", err) t.Fatalf("open sqlite: %v", err)
} }
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}); err != nil { 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) t.Fatalf("migrate: %v", err)
} }
stores := store.NewStores(gdb) stores := store.NewStores(gdb)
@@ -83,23 +79,22 @@ func seedMailbox(t *testing.T, stores *store.Stores, userID uint, n int) []uint
return ids return ids
} }
func loginAndSelect(t *testing.T, addr string) *client.Client { func loginAndSelect(t *testing.T, addr string) *imapclient.Client {
t.Helper() t.Helper()
c, err := client.Dial(addr) c, err := imapclient.DialInsecure(addr, nil)
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
t.Cleanup(func() { c.Logout() }) t.Cleanup(func() { c.Logout().Wait() })
if err := c.Login("alice@example.com", "secret123"); err != nil { if err := c.Login("alice@example.com", "secret123").Wait(); err != nil {
t.Fatalf("login: %v", err) t.Fatalf("login: %v", err)
} }
if _, err := c.Select("INBOX", false); err != nil { if _, err := c.Select("INBOX", nil).Wait(); err != nil {
t.Fatalf("select: %v", err) t.Fatalf("select: %v", err)
} }
return c return c
} }
// uidOf returns the message id of the newest message by date.
func assertReadState(t *testing.T, stores *store.Stores, msgID uint, want bool) { func assertReadState(t *testing.T, stores *store.Stores, msgID uint, want bool) {
t.Helper() t.Helper()
msg, err := stores.Mails.GetByID(msgID) msg, err := stores.Mails.GetByID(msgID)
@@ -117,13 +112,13 @@ func TestUidStorePersists(t *testing.T) {
ids := seedMailbox(t, stores, 1, 3) ids := seedMailbox(t, stores, 1, 3)
c := loginAndSelect(t, addr) c := loginAndSelect(t, addr)
seqset := new(imap.SeqSet) cmd := c.Store(imap.UIDSetNum(imap.UID(ids[1])), &imap.StoreFlags{
seqset.AddNum(uint32(ids[1])) // UID = 第二条消息 Op: imap.StoreFlagsAdd,
ch := make(chan *imap.Message, 1) Flags: []imap.Flag{imap.FlagSeen},
if err := c.UidStore(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil { }, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("uid store: %v", err) t.Fatalf("uid store: %v", err)
} }
<-ch
assertReadState(t, stores, ids[1], true) assertReadState(t, stores, ids[1], true)
assertReadState(t, stores, ids[0], false) assertReadState(t, stores, ids[0], false)
@@ -139,15 +134,13 @@ func TestSeqStoreServerIssued(t *testing.T) {
c := loginAndSelect(t, addr) c := loginAndSelect(t, addr)
// 拉取全部消息,找到 ids[2](最新一封)的服务器序号 // 拉取全部消息,找到 ids[2](最新一封)的服务器序号
seqsetAll := new(imap.SeqSet) msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
seqsetAll.AddRange(1, 3) if err != nil {
messages := make(chan *imap.Message, 3)
if err := c.Fetch(seqsetAll, []imap.FetchItem{imap.FetchFlags, imap.FetchUid}, messages); err != nil {
t.Fatalf("fetch: %v", err) t.Fatalf("fetch: %v", err)
} }
var targetSeq uint32 var targetSeq uint32
for m := range messages { for _, m := range msgs {
if m.Uid == uint32(ids[2]) { if m.UID == imap.UID(ids[2]) {
targetSeq = m.SeqNum targetSeq = m.SeqNum
} }
} }
@@ -155,13 +148,13 @@ func TestSeqStoreServerIssued(t *testing.T) {
t.Fatal("target message not found in fetch") t.Fatal("target message not found in fetch")
} }
seqset := new(imap.SeqSet) cmd := c.Store(imap.SeqSetNum(targetSeq), &imap.StoreFlags{
seqset.AddNum(targetSeq) Op: imap.StoreFlagsAdd,
ch := make(chan *imap.Message, 1) Flags: []imap.Flag{imap.FlagSeen},
if err := c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil { }, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
<-ch
assertReadState(t, stores, ids[2], true) assertReadState(t, stores, ids[2], true)
} }
@@ -177,13 +170,13 @@ func TestSeqStoreClientSelfNumbered(t *testing.T) {
c := loginAndSelect(t, addr) c := loginAndSelect(t, addr)
// 客户端按日期倒序视图:最新一封 = seq 1 // 客户端按日期倒序视图:最新一封 = seq 1
seqset := new(imap.SeqSet) cmd := c.Store(imap.SeqSetNum(1), &imap.StoreFlags{
seqset.AddNum(1) Op: imap.StoreFlagsAdd,
ch := make(chan *imap.Message, 1) Flags: []imap.Flag{imap.FlagSeen},
if err := c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil { }, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
<-ch
// 客户端意图是标记最新一封(ids[2])为已读 // 客户端意图是标记最新一封(ids[2])为已读
assertReadState(t, stores, ids[2], true) assertReadState(t, stores, ids[2], true)
@@ -191,14 +184,12 @@ func TestSeqStoreClientSelfNumbered(t *testing.T) {
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的 // TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
// message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE // message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE
// 不得因 nil BodyStructure 触发服务器 panic(否则连接中断,客户端只收到 // 不得 panicv2 的 ExtractBodyStructure 对畸形 MIME 返回降级结构,
// 部分邮件或一直卡在同步)。修复前 go-imap send() 协程会 nil 指针崩溃 // WriteBodyStructure 要求 Extended 非 nil,两者都需满足)
func TestFetchBodyMalformedMIME(t *testing.T) { func TestFetchBodyMalformedMIME(t *testing.T) {
stores, addr := startIntegrationServer(t) stores, addr := startIntegrationServer(t)
// 1) base64 编码的 message/rfc822 附件(转发邮件场景) // 1) base64 编码的 message/rfc822 附件(转发邮件场景)
// backendutil.FetchBodyStructure 不解码 base64,直接把编码文本
// 当嵌套消息头解析 → "malformed MIME header line" 错误。
rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4=" rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4="
msgWithRFC822 := &db.Message{ msgWithRFC822 := &db.Message{
UserID: 1, UserID: 1,
@@ -224,7 +215,7 @@ func TestFetchBodyMalformedMIME(t *testing.T) {
rfc822Body + "\r\n" + rfc822Body + "\r\n" +
"--==fwd==--\r\n", "--==fwd==--\r\n",
} }
// 2) 截断的 multipart(缺少结束边界)BODYSTRUCTURE(extended) 解析报错 // 2) 截断的 multipart(缺少结束边界)
msgTruncated := &db.Message{ msgTruncated := &db.Message{
UserID: 1, UserID: 1,
Folder: "INBOX", Folder: "INBOX",
@@ -251,32 +242,260 @@ func TestFetchBodyMalformedMIME(t *testing.T) {
c := loginAndSelect(t, addr) c := loginAndSelect(t, addr)
seqset := new(imap.SeqSet) seqSet := imap.SeqSetNum(1, 2)
seqset.AddRange(1, 2)
// BODY:历史上 message/rfc822 消息解析失败 → nil BodyStructure → panic // BODY(非扩展)
msgs := make(chan *imap.Message, 10) msgs, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{}}).Collect()
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBody}, msgs); err != nil { if err != nil {
t.Fatalf("fetch body: %v", err) t.Fatalf("fetch body: %v", err)
} }
got := 0 if len(msgs) != 2 {
for range msgs { t.Fatalf("FETCH BODY 返回 %d/2 封", len(msgs))
got++
}
if got != 2 {
t.Fatalf("FETCH BODY 返回 %d/2 封", got)
} }
// BODYSTRUCTURE:截断 multipart 在 extended 解析时报错 → nil → panic // BODYSTRUCTURE(扩展)
msgs2 := make(chan *imap.Message, 10) msgs2, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{Extended: true}}).Collect()
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBodyStructure}, msgs2); err != nil { if err != nil {
t.Fatalf("fetch bodystructure: %v", err) t.Fatalf("fetch bodystructure: %v", err)
} }
got2 := 0 if len(msgs2) != 2 {
for range msgs2 { t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", len(msgs2))
got2++
}
if got2 != 2 {
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", got2)
} }
} }
// 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
}
+116 -208
View File
@@ -10,18 +10,24 @@ import (
"mail_go/internal/db" "mail_go/internal/db"
"mail_go/internal/store" "mail_go/internal/store"
"github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/v2"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
// TestPushNewMessage 验证本地投递成功后推送的 MessageUpdate 内容正确。 // fakeSession 构造一个挂接在推送中心上的裸会话(无网络连接),用于
func TestPushNewMessage(t *testing.T) { // 验证 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{}) gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf("open sqlite: %v", err) t.Fatalf("open sqlite: %v", err)
} }
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil { if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.MailboxState{}, &db.Mailbox{}); err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
stores := store.NewStores(gdb) stores := store.NewStores(gdb)
@@ -34,108 +40,51 @@ func TestPushNewMessage(t *testing.T) {
if err := stores.Users.Create(user); err != nil { if err := stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err) t.Fatalf("create user: %v", err)
} }
email := "alice@example.com"
// 已有一封旧邮件(日期更早);规范排序最新在前,新邮件应为 INBOX 第 1 封 srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
old := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "old", Date: time.Now().Add(-time.Hour)} 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 { if err := stores.Mails.Create(old); err != nil {
t.Fatalf("create old message: %v", err) t.Fatalf("create old message: %v", err)
} }
inboxMsg := &db.Message{ inboxMsg := &db.Message{
UserID: user.ID, UserID: 1,
Folder: "INBOX", Folder: "INBOX",
FromAddr: "sender@other.com", FromAddr: "sender@other.com",
ToAddr: email, ToAddr: "alice@example.com",
Subject: "新邮件", Subject: "新邮件",
RawData: "From: sender@other.com\r\nSubject: 新邮件\r\n\r\nhello",
MessageID: "<new-1@other.com>",
Date: time.Now(), Date: time.Now(),
IsRead: false,
} }
if err := stores.Mails.Create(inboxMsg); err != nil { if err := stores.Mails.Create(inboxMsg); err != nil {
t.Fatalf("create message: %v", err) t.Fatalf("create message: %v", err)
} }
hub := connhub.New() hub := srv.hubForOrCreate("alice@example.com", "INBOX")
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub) sess := fakeSession()
// 模拟明文 + TLS 两个监听器(生产环境由 Start/StartTLS 注册) hub.add(sess)
srv.newServer("127.0.0.1:143", nil)
srv.newServer("127.0.0.1:993", nil)
srv.PushNewMessage(email, inboxMsg)
// 两个监听器(明文/TLS)各有一个 backend 通道,都应收到同一更新 srv.PushNewMessage("alice@example.com", inboxMsg)
srv.beMu.Lock()
bes := append([]*imapBackend(nil), srv.bes...)
srv.beMu.Unlock()
if len(bes) == 0 {
t.Fatal("no backends registered")
}
for i, b := range bes { updates := sess.takeUpdates(true)
select { if len(updates) != 1 || updates[0].exists == nil {
case upd := <-b.updates: t.Fatalf("updates = %+v, want 1 条 EXISTS", updates)
mu, ok := upd.(*backend.MessageUpdate)
if !ok {
t.Fatalf("backend %d: update type = %T, want *MessageUpdate", i, upd)
}
if mu.Username() != email {
t.Fatalf("backend %d: username = %q, want %q", i, mu.Username(), email)
}
if mu.Mailbox() != "INBOX" {
t.Fatalf("backend %d: mailbox = %q, want INBOX", i, mu.Mailbox())
}
if mu.Message.Uid != uint32(inboxMsg.ID) {
t.Fatalf("backend %d: uid = %d, want %d", i, mu.Message.Uid, inboxMsg.ID)
}
if mu.Message.SeqNum != 1 {
t.Fatalf("backend %d: seq = %d, want 1", i, mu.Message.SeqNum)
}
if mu.Message.Envelope == nil || mu.Message.Envelope.Subject != "新邮件" {
t.Fatalf("backend %d: envelope missing subject", i)
}
case <-time.After(time.Second):
t.Fatalf("backend %d: no update received", i)
} }
if *updates[0].exists != 2 {
t.Fatalf("EXISTS = %d, want 2", *updates[0].exists)
} }
} }
// TestPushNewMessageChannelFull 验证通道满时推送不阻塞(非阻塞丢弃)。 // TestPushNewMessageNoSession 验证无会话选中时推送为 no-op(不 panic)。
func TestPushNewMessageChannelFull(t *testing.T) { func TestPushNewMessageNoSession(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) srv, _ := newTestServer(t)
if err != nil { srv.PushNewMessage("alice@example.com", &db.Message{ID: 1, UserID: 1, Folder: "INBOX", Date: time.Now()})
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
hub := connhub.New()
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
srv.newServer("127.0.0.1:143", nil)
srv.newServer("127.0.0.1:993", nil)
msg := &db.Message{ID: 1, Folder: "INBOX", Date: time.Now()}
done := make(chan struct{})
go func() {
// 灌满所有 backend 通道(容量 256),再调用必须立即返回
srv.beMu.Lock()
bes := append([]*imapBackend(nil), srv.bes...)
srv.beMu.Unlock()
for _, b := range bes {
for i := 0; i < cap(b.updates); i++ {
b.updates <- backend.NewUpdate("a@b", "INBOX")
}
}
srv.PushNewMessage("a@b", msg)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("PushNewMessage blocked on full channel")
}
} }
// TestPushNewMessageNilSafe 验证空参数/空指针安全。 // TestPushNewMessageNilSafe 验证空参数/空指针安全。
@@ -145,155 +94,114 @@ func TestPushNewMessageNilSafe(t *testing.T) {
srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil) srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil)
srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱 srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱
srv.PushNewMessage("a@b", nil) // 空消息 srv.PushNewMessage("a@b", nil) // 空消息
srv.PushFlagsChanged("", "", nil)
srv.PushExpunged("", "", nil)
} }
// TestPushFlagsChanged 验证标志变化(已读/星标)推送内容正确。 // TestPushFlagsChanged 验证标志变化(已读/星标)推送内容正确。
func TestPushFlagsChanged(t *testing.T) { func TestPushFlagsChanged(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) srv, stores := newTestServer(t)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
domain := &db.Domain{Name: "example.com"} msg := &db.Message{UserID: 1, Folder: "INBOX", FromAddr: "x@y", Subject: "s", Date: time.Now()}
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)
}
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "s", Date: time.Now()}
if err := stores.Mails.Create(msg); err != nil { if err := stores.Mails.Create(msg); err != nil {
t.Fatalf("create message: %v", err) t.Fatalf("create message: %v", err)
} }
msg.IsRead = true msg.IsRead = true
msg.IsFlagged = true msg.IsFlagged = true
hub := connhub.New() hub := srv.hubForOrCreate("alice@example.com", "INBOX")
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub) sess := fakeSession()
srv.newServer("127.0.0.1:143", nil) hub.add(sess)
srv.PushFlagsChanged("alice@example.com", "INBOX", msg) srv.PushFlagsChanged("alice@example.com", "INBOX", msg)
srv.beMu.Lock() updates := sess.takeUpdates(true)
b := srv.bes[0] if len(updates) != 1 || updates[0].fetch == nil {
srv.beMu.Unlock() t.Fatalf("updates = %+v, want 1 条 FETCH", updates)
select {
case upd := <-b.updates:
mu, ok := upd.(*backend.MessageUpdate)
if !ok {
t.Fatalf("update type = %T, want *MessageUpdate", upd)
} }
if mu.Username() != "alice@example.com" || mu.Mailbox() != "INBOX" { f := updates[0].fetch
t.Fatalf("update targeting = %s/%s", mu.Username(), mu.Mailbox()) if f.uid != imap.UID(msg.ID) {
t.Fatalf("uid = %d, want %d", f.uid, msg.ID)
} }
if mu.Message.Uid != uint32(msg.ID) { got := make(map[imap.Flag]bool)
t.Fatalf("uid = %d, want %d", mu.Message.Uid, msg.ID) for _, fl := range f.flags {
got[fl] = true
} }
got := make(map[string]bool) if !got[imap.FlagSeen] || !got[imap.FlagFlagged] {
for _, f := range mu.Message.Flags { t.Fatalf("flags = %v, want \\Seen and \\Flagged", f.flags)
got[f] = true
}
if !got["\\Seen"] || !got["\\Flagged"] {
t.Fatalf("flags = %v, want \\Seen and \\Flagged", mu.Message.Flags)
}
case <-time.After(time.Second):
t.Fatal("no flags update received")
} }
} }
// TestPushExpunged 验证删除推送:每条序号一个 ExpungeUpdate // TestPushExpunged 验证删除推送:每条序号一个 EXPUNGE 更新
func TestPushExpunged(t *testing.T) { func TestPushExpunged(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) srv, _ := newTestServer(t)
if err != nil {
t.Fatalf("open sqlite: %v", err) hub := srv.hubForOrCreate("alice@example.com", "INBOX")
} sess := fakeSession()
if err := gdb.AutoMigrate(&db.Message{}); err != nil { hub.add(sess)
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
hub := connhub.New()
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
srv.newServer("127.0.0.1:143", nil)
srv.PushExpunged("alice@example.com", "INBOX", []uint32{2, 5}) srv.PushExpunged("alice@example.com", "INBOX", []uint32{2, 5})
srv.beMu.Lock() updates := sess.takeUpdates(true)
b := srv.bes[0]
srv.beMu.Unlock()
var seqs []uint32
for i := 0; i < 2; i++ {
select {
case upd := <-b.updates:
eu, ok := upd.(*backend.ExpungeUpdate)
if !ok {
t.Fatalf("update type = %T, want *ExpungeUpdate", upd)
}
if eu.Username() != "alice@example.com" || eu.Mailbox() != "INBOX" {
t.Fatalf("update targeting = %s/%s", eu.Username(), eu.Mailbox())
}
seqs = append(seqs, eu.SeqNum)
case <-time.After(time.Second):
t.Fatal("no expunge update received")
}
}
if seqs[0] != 2 || seqs[1] != 5 {
t.Fatalf("seqs = %v, want [2 5]", seqs)
}
}
// TestBroadcastUpdateIsolatedPerListener 回归测试:同一更新广播到多个监听器
// 时,每个监听器必须持有独立的 Update 对象(独立 Done channel),否则
// 多个 listenUpdates 会对同一 channel 二次 close 导致
// panic: close of closed channel。
func TestBroadcastUpdateIsolatedPerListener(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.Message{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
hub := connhub.New()
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
srv.newServer("127.0.0.1:143", nil)
srv.newServer("127.0.0.1:993", nil)
srv.PushExpunged("alice@example.com", "INBOX", []uint32{1})
srv.beMu.Lock()
bes := append([]*imapBackend(nil), srv.bes...)
srv.beMu.Unlock()
// 每个监听器各收到一条更新
var updates []backend.Update
for i, b := range bes {
select {
case upd := <-b.updates:
updates = append(updates, upd)
case <-time.After(time.Second):
t.Fatalf("backend %d: no update received", i)
}
}
if len(updates) != 2 { if len(updates) != 2 {
t.Fatalf("updates = %d, want 2", len(updates)) t.Fatalf("updates = %d, want 2", len(updates))
} }
if updates[0].expunge == nil || updates[1].expunge == nil {
// 关键断言:两条更新必须拥有独立的 Done channel t.Fatalf("updates = %+v, want expunge updates", updates)
if updates[0].Done() == updates[1].Done() { }
t.Fatal("listeners share the same Done channel: double close would panic") if *updates[0].expunge != 2 || *updates[1].expunge != 5 {
t.Fatalf("seqs = %d,%d, want 2,5", *updates[0].expunge, *updates[1].expunge)
}
} }
// 模拟两个 listenUpdates 各自执行 close(update.Done()):修复前必 panic // TestHubExcludesSource 验证来源会话不会收到自己动作的回声推送:
for _, upd := range updates { // 会话 A 的 STORE/EXPUNGE 只分发给同邮箱的其他会话(本会话的响应已由
close(upd.Done()) // 命令本身写回)。回归: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])
} }
} }
+129 -113
View File
@@ -14,22 +14,22 @@ import (
"mail_go/internal/store" "mail_go/internal/store"
"mail_go/internal/tlsutil" "mail_go/internal/tlsutil"
"github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/v2"
imapserver "github.com/emersion/go-imap/server" imapserver "github.com/emersion/go-imap/v2/imapserver"
) )
// Pusher 是 IMAP 实时推送接口:SMTP/POP3/Web 在邮件状态变化后调用, // Pusher 是 IMAP 实时推送接口:SMTP/POP3/Web 在邮件状态变化后调用,
// 由 go-imap 广播给相关客户端(按用户名+邮箱过滤,IDLE 时即时送达)。 // 更新经 mailboxHub 分发给已选中对应邮箱的其他会话(IDLE 时即时送达)。
type Pusher interface { type Pusher interface {
// PushNewMessage 推送新邮件(本地投递成功)。 // PushNewMessage 推送新邮件(本地投递成功)。
PushNewMessage(userEmail string, msg *db.Message) PushNewMessage(userEmail string, msg *db.Message)
// PushFlagsChanged 推送已读/星标等标志变化MessageUpdate // PushFlagsChanged 推送已读/星标等标志变化。
PushFlagsChanged(userEmail, mailbox string, msg *db.Message) PushFlagsChanged(userEmail, mailbox string, msg *db.Message)
// PushExpunged 推送邮件被删除(ExpungeUpdateseqNums 为删除前序号)。 // PushExpunged 推送邮件被删除(seqNums 为删除前序号)。
PushExpunged(userEmail, mailbox string, seqNums []uint32) PushExpunged(userEmail, mailbox string, seqNums []uint32)
} }
// IMAPServer wraps a go-imap Server and provides mailbox access capability. // IMAPServer 管理 IMAP/IMAPS 监听器、会话注册与跨会话推送。
type IMAPServer struct { type IMAPServer struct {
stores *store.Stores stores *store.Stores
cfg config.IMAPConfig cfg config.IMAPConfig
@@ -37,9 +37,14 @@ type IMAPServer struct {
tlsLoader *tlsutil.Loader tlsLoader *tlsutil.Loader
hub *connhub.Hub hub *connhub.Hub
beMu sync.Mutex // svc 邮箱服务层(文件夹目录/消息操作),IMAP 会话与 Web 共用。
bes []*imapBackend // 各监听器(明文/TLS)的 backend,用于新邮件推送 svc *MailboxService
srvs []*imapserver.Server // 各监听器实例,用于强制断开连接
// 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 // NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
@@ -51,118 +56,115 @@ func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsut
banCfg: banCfg, banCfg: banCfg,
tlsLoader: tlsLoader, tlsLoader: tlsLoader,
hub: hub, hub: hub,
svc: NewMailboxService(stores),
hubs: make(map[string]*mailboxHub),
sessions: make(map[*imapSession]struct{}),
} }
} }
// NotifyNewMessage 向所有 IMAP 监听器推送新邮件通知(go-imap 广播时按 // MailboxService 返回邮箱服务层(Web handler 共用)。
// 用户名+邮箱过滤,只送达已选中 INBOX 的客户端,IDLE 挂起时实时收到 func (s *IMAPServer) MailboxService() *MailboxService {
// FETCH 响应)。由 SMTP/Web 本地投递成功时调用;channel 满时非阻塞丢弃。 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) { func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) {
if s == nil || userEmail == "" || msg == nil { if s == nil || userEmail == "" || msg == nil || msg.Folder == "" {
return return
} }
update := buildNewMessageUpdate(s.stores, userEmail, "INBOX", msg) hub := s.hubFor(userEmail, msg.Folder)
if update == nil { if hub == nil {
return return
} }
s.broadcastUpdate(update, userEmail, msg.ID) if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, msg.Folder); err == nil {
hub.enqueue(sessionUpdate{exists: ptrU32(uint32(count))}, nil)
}
} }
// PushFlagsChanged 推送邮件标志(已读/星标)变化给同用户其他客户端 // PushFlagsChanged 推送邮件标志(已读/星标/删除标记)变化给同用户其他会话
func (s *IMAPServer) PushFlagsChanged(userEmail, mailbox string, msg *db.Message) { func (s *IMAPServer) PushFlagsChanged(userEmail, mailbox string, msg *db.Message) {
if s == nil || userEmail == "" || mailbox == "" || msg == nil { if s == nil || userEmail == "" || mailbox == "" || msg == nil {
return return
} }
update := buildFlagsUpdate(s.stores, userEmail, mailbox, msg, false) hub := s.hubFor(userEmail, mailbox)
if update == nil { if hub == nil {
return return
} }
s.broadcastUpdate(update, userEmail, msg.ID) 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 推送邮件被删除(每条序号一个 ExpungeUpdate)。 // PushExpunged 推送邮件被删除(每条序号一个 EXPUNGE 更新)。
func (s *IMAPServer) PushExpunged(userEmail, mailbox string, seqNums []uint32) { func (s *IMAPServer) PushExpunged(userEmail, mailbox string, seqNums []uint32) {
if s == nil || userEmail == "" || mailbox == "" || len(seqNums) == 0 { if s == nil || userEmail == "" || mailbox == "" || len(seqNums) == 0 {
return return
} }
hub := s.hubFor(userEmail, mailbox)
if hub == nil {
return
}
for _, seq := range seqNums { for _, seq := range seqNums {
update := &backend.ExpungeUpdate{ hub.enqueue(sessionUpdate{expunge: ptrU32(seq)}, nil)
Update: backend.NewUpdate(userEmail, mailbox),
SeqNum: seq,
} }
s.broadcastUpdate(update, userEmail, 0)
}
}
// broadcastUpdate 把一条更新非阻塞地投递到所有监听器的推送通道。
// 每个监听器必须收到独立的 Update 对象(各自独立的 Done channel):
// 每个监听器的 listenUpdates 都会对 update.Done() 执行 close,共享
// 同一对象会导致对同一 channel 二次 close 而 panic。
func (s *IMAPServer) broadcastUpdate(update backend.Update, userEmail string, msgID uint) {
s.beMu.Lock()
bes := append([]*imapBackend(nil), s.bes...)
s.beMu.Unlock()
for _, b := range bes {
select {
case b.updates <- cloneUpdate(update):
default:
log.Printf("IMAP: 推送通道已满,丢弃 %s 的更新 (msg=%d)", userEmail, msgID)
}
}
}
// cloneUpdate 按类型复制一条 backend.Update:载荷(消息/序号)共享,
// 但 Username/Mailbox/Done channel 重置为独立实例。
func cloneUpdate(u backend.Update) backend.Update {
switch u := u.(type) {
case *backend.MessageUpdate:
return &backend.MessageUpdate{
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
Message: u.Message,
}
case *backend.ExpungeUpdate:
return &backend.ExpungeUpdate{
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
SeqNum: u.SeqNum,
}
default:
// 防御:未知类型原样传递(当前不存在此类更新)
return u
}
}
// registerBackend 记录新建的 backend(用于新邮件推送)。
func (s *IMAPServer) registerBackend(be *imapBackend) {
s.beMu.Lock()
s.bes = append(s.bes, be)
s.beMu.Unlock()
}
// registerServer 记录监听器实例(用于强制断开连接)。
func (s *IMAPServer) registerServer(srv *imapserver.Server) {
s.beMu.Lock()
s.srvs = append(s.srvs, srv)
s.beMu.Unlock()
} }
// DisconnectByAddr 强制断开指定远端地址的连接(管理后台「断开并封禁」)。 // DisconnectByAddr 强制断开指定远端地址的连接(管理后台「断开并封禁」)。
// 关闭连接触发 go-imap 的收尾流程(user.Logout、协议日志回填、hub 注销)。 // 发送 BYE 并关闭底层连接触发的收尾流程(session.Close、协议日志回填)。
func (s *IMAPServer) DisconnectByAddr(remoteAddr string) { func (s *IMAPServer) DisconnectByAddr(remoteAddr string) {
if s == nil || remoteAddr == "" { if s == nil || remoteAddr == "" {
return return
} }
s.beMu.Lock() s.mu.Lock()
srvs := append([]*imapserver.Server(nil), s.srvs...) var targets []*imapSession
s.beMu.Unlock() for sess := range s.sessions {
if sess.remoteAddr == remoteAddr {
for _, srv := range srvs { targets = append(targets, sess)
srv.ForEachConn(func(conn imapserver.Conn) {
info := conn.Info()
if info != nil && info.RemoteAddr != nil && info.RemoteAddr.String() == remoteAddr {
_ = conn.Close()
} }
}) }
s.mu.Unlock()
for _, sess := range targets {
_ = sess.conn.Bye("Connection closed by administrator")
} }
} }
@@ -174,25 +176,42 @@ func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
} }
// newServer creates a configured imapserver.Server with the given address. // imapCaps 是服务器支持的能力集:UIDPLUSRFC 4315)提供 UID EXPUNGE、
func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server { // COPYUID/APPENDUID 支持;MOVE 由 SessionMove 实现;IDLE/UNSELECT 在
be := &imapBackend{ // IMAP4rev1 认证后由库自动广告。
stores: s.stores, var imapCaps = imap.CapSet{
banCfg: s.banCfg, imap.CapIMAP4rev1: {},
port: portOf(addr), imap.CapUIDPlus: {},
hub: s.hub, imap.CapMove: {},
updates: make(chan backend.Update, 256), imap.CapLiteralPlus: {},
disconnectAddr: s.DisconnectByAddr, imap.CapChildren: {},
imap.CapSpecialUse: {},
} }
s.registerBackend(be)
srv := imapserver.New(be) // newServer 创建指定监听地址的 IMAP 服务(会话工厂捕获监听端口)。
srv.Addr = addr func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server {
srv.TLSConfig = tlsConfig port := portOf(addr)
srv.AllowInsecureAuth = tlsConfig == nil srv := imapserver.New(&imapserver.Options{
s.registerServer(srv) Caps: imapCaps,
NewSession: s.newSessionFactory(port),
TLSConfig: tlsConfig,
InsecureAuth: tlsConfig == nil,
})
return srv return srv
} }
// 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。 // portOf 从监听地址解析端口号,失败返回 0。
func portOf(addr string) int { func portOf(addr string) int {
_, portStr, err := net.SplitHostPort(addr) _, portStr, err := net.SplitHostPort(addr)
@@ -206,29 +225,26 @@ func portOf(addr string) int {
return port return port
} }
// Start starts the IMAP server on the plain-text port. // Start starts the IMAP server on the plain-text port (STARTTLS enabled
// when a certificate is configured).
func (s *IMAPServer) Start() error { func (s *IMAPServer) Start() error {
tlsConfig, err := s.tlsConfig() tlsConfig, err := s.tlsConfig()
if err != nil { if err != nil {
log.Printf("IMAP STARTTLS 未启用: %v", err) log.Printf("IMAP STARTTLS 未启用: %v", err)
tlsConfig = nil
} }
srv := s.newServer(s.cfg.Addr, tlsConfig) srv := s.newServer(s.cfg.Addr, tlsConfig)
log.Printf("IMAP server listening on %s", s.cfg.Addr) 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 { func (s *IMAPServer) StartTLS() error {
tlsConfig, err := s.tlsConfig() tlsConfig, err := s.tlsConfig()
if err != nil { if err != nil {
return err return err
} }
srv := s.newServer(s.cfg.TLSAddr, tlsConfig) srv := s.newServer(s.cfg.TLSAddr, tlsConfig)
log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr) 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
+3 -2
View File
@@ -397,8 +397,9 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password) authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password)
if err != nil { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多") // 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多", s.stores.Users.LoginExists(user.Username))
sendResponse(conn, "-ERR authentication failed") sendResponse(conn, "-ERR authentication failed")
return nil, nil, nil return nil, nil, nil
} }
+3 -1
View File
@@ -221,12 +221,14 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password) user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password)
if err != nil { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
s.backend.server.stores.RecordAuthFailure( s.backend.server.stores.RecordAuthFailure(
s.clientIP, s.clientIP,
s.backend.server.banCfg.MaxFailAttempts, s.backend.server.banCfg.MaxFailAttempts,
s.backend.server.banCfg.BanDurationMin, s.backend.server.banCfg.BanDurationMin,
"邮件协议认证失败次数过多", "邮件协议认证失败次数过多",
s.backend.server.stores.Users.LoginExists(username),
) )
s.recordFail("用户名或密码错误") s.recordFail("用户名或密码错误")
return smtp.ErrAuthFailed return smtp.ErrAuthFailed
+19 -6
View File
@@ -23,12 +23,15 @@ func ClientIPFromAddr(addr net.Addr) string {
// 协议层统一入口): // 协议层统一入口):
// - 失败计数累加(每 IP 一条记录,upsert); // - 失败计数累加(每 IP 一条记录,upsert);
// - 达到 maxFail 阈值时触发次数 BanCount+1 // - 达到 maxFail 阈值时触发次数 BanCount+1
// 前 freeTriggers3)次只计数不封禁; // knownUser(用户名存在、疑似真实用户输错)前 freeTriggers3)次
// 从第 4 次起封禁,时长按档位递增(stageDuration),上限半年 // 只计数不封禁(防误封)
// !knownUser(用户名不存在,枚举型爆破)跳过宽限,首次触发即按
// 第 1 档封禁;
// 封禁时长按档位递增(stageDuration),上限半年;
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。 // - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
// //
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。 // 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string) (banned bool, failCount int) { func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string, knownUser bool) (banned bool, failCount int) {
if ip == "" || maxFail <= 0 { if ip == "" || maxFail <= 0 {
return false, 0 return false, 0
} }
@@ -50,16 +53,26 @@ func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reas
entry.BanCount = banCount entry.BanCount = banCount
entry.FailCount = failCount entry.FailCount = failCount
// 前 3 次只计数,不封禁(保留零到期时间与空原因) // 未知用户名(枚举型爆破)跳过宽限档:等效于已用完 3 次宽限
if banCount <= freeTriggers { effectiveCount := banCount
if !knownUser {
effectiveCount += freeTriggers
}
// 宽限期内只计数,不封禁(保留零到期时间与空原因)
if effectiveCount <= freeTriggers {
if err := s.Bans.Update(entry); err != nil { if err := s.Bans.Update(entry); err != nil {
return false, failCount return false, failCount
} }
return false, failCount return false, failCount
} }
banNum := banCount - freeTriggers banNum := effectiveCount - freeTriggers
if knownUser {
entry.Reason = fmt.Sprintf("第%d次封禁:%s(第%d次触发,失败%d次)", banNum, reason, banCount, failCount) 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)) entry.ExpiresAt = time.Now().Add(stageDuration(banNum, firstBanMin))
if err := s.Bans.Update(entry); err != nil { if err := s.Bans.Update(entry); err != nil {
return false, failCount return false, failCount
+201 -3
View File
@@ -1,6 +1,7 @@
package store package store
import ( import (
"sync"
"net" "net"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -33,7 +34,7 @@ func TestRecordAuthFailureFreeTriggers(t *testing.T) {
const maxFail = 2 const maxFail = 2
failOnce := func() bool { failOnce := func() bool {
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多") banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
return banned return banned
} }
@@ -94,7 +95,7 @@ func TestStagedBanEscalation(t *testing.T) {
const maxFail = 2 const maxFail = 2
failOnce := func() bool { failOnce := func() bool {
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多") banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
return banned return banned
} }
@@ -228,7 +229,7 @@ func TestBanListOnlyBannedOrExpired(t *testing.T) {
// TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。 // TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。
func TestRecordAuthFailureEmptyIPSafe(t *testing.T) { func TestRecordAuthFailureEmptyIPSafe(t *testing.T) {
s := newTestStores(t) s := newTestStores(t)
banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多") banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多", true)
if banned || count != 0 { if banned || count != 0 {
t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count) t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count)
} }
@@ -350,3 +351,200 @@ func TestTryReserveQuotaNonPositiveDelta(t *testing.T) {
t.Fatalf("used_bytes = %d, want 0", got.UsedBytes) 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)
}
}
+67 -18
View File
@@ -1,11 +1,14 @@
package store package store
import ( import (
"fmt"
"strings"
"time" "time"
"mail_go/internal/db" "mail_go/internal/db"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// 阶段封禁档位(分钟/天),从第 4 次触发阈值开始封禁: // 阶段封禁档位(分钟/天),从第 4 次触发阈值开始封禁:
@@ -41,6 +44,10 @@ func stageDuration(banCount int, firstBanMin int) time.Duration {
// BanStore defines the interface for IP ban operations. // BanStore defines the interface for IP ban operations.
type BanStore interface { type BanStore interface {
Create(entry *db.BanEntry) error Create(entry *db.BanEntry) error
// BanIP 手动/直接封禁某 IP 指定时长:该 IP 只保留一条记录
// (既有观察记录一并清理,计数清零),避免产生重复行导致
// IncrementFail 与 GetByIP 读写错位。
BanIP(ip, reason string, duration time.Duration) error
GetByIP(ip string) (*db.BanEntry, error) GetByIP(ip string) (*db.BanEntry, error)
Update(entry *db.BanEntry) error Update(entry *db.BanEntry) error
Delete(id uint) error Delete(id uint) error
@@ -67,6 +74,30 @@ func (s *banStoreGorm) Create(entry *db.BanEntry) error {
return s.db.Create(entry).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. // GetByIP retrieves the most recent ban entry for a given IP address.
func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) { func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
var entry db.BanEntry var entry db.BanEntry
@@ -118,33 +149,51 @@ func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) {
return true, &entry return true, &entry
} }
// IncrementFail increments the fail count for an IP address. // IncrementFail increments the fail count for an IP address atomically
// If no record exists, it creates one with fail_count=1, ban_count=0 and a // (SQL-side increment, avoiding read-modify-write races). If no record
// zero expires_at (not yet banned). Existing BanCount is preserved. // exists it creates one with fail_count=1, ban_count=0 and a zero
// Returns the updated fail count. // 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) { func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
var entry db.BanEntry res := s.db.Model(&db.BanEntry{}).
err := s.db.Where("ip_address = ?", ip).First(&entry).Error Where("ip_address = ?", ip).
if err != nil { Update("fail_count", gorm.Expr("fail_count + 1"))
// No record exists, create a new one if res.Error != nil {
entry = db.BanEntry{ 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, IPAddress: ip,
FailCount: 1, FailCount: 1,
BanCount: 0, BanCount: 0,
ExpiresAt: time.Time{}, // Zero time, not yet banned 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 var count int64
if updateErr := s.db.Model(&entry).Update("fail_count", newCount).Error; updateErr != nil { if err := s.db.Model(&db.BanEntry{}).Where("ip_address = ?", ip).
return 0, updateErr 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. // ResetFail resets the fail count for an IP address by deleting its record.
+55
View File
@@ -28,7 +28,18 @@ type MailStore interface {
MarkRead(id uint) error MarkRead(id uint) error
MarkReadState(id uint, read bool) error MarkReadState(id uint, read bool) error
MarkFlagged(id uint, flagged 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 MoveToFolder(id uint, folder string) error
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
SetDeletedStates(ids []uint, deleted bool) error
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
// (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
DeleteMany(ids []uint) error
Delete(id uint) error Delete(id uint) error
CountUnread(userID uint, folder string) (int64, error) CountUnread(userID uint, folder string) (int64, error)
CountByFolder(folder string) (int64, error) CountByFolder(folder string) (int64, error)
@@ -103,11 +114,55 @@ func (s *mailStoreGorm) MarkFlagged(id uint, flagged bool) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_flagged", flagged).Error 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. // MoveToFolder changes the folder of a message.
func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error { func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error
} }
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记。
func (s *mailStoreGorm) SetDeletedStates(ids []uint, deleted bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
}
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件。
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
var messages []db.Message
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
return nil, err
}
return messages, nil
}
// DeleteMany 批量硬删除多封邮件。
func (s *mailStoreGorm) DeleteMany(ids []uint) error {
if len(ids) == 0 {
return nil
}
return s.db.Where("id IN ?", ids).Delete(&db.Message{}).Error
}
// Delete removes a message by ID. // Delete removes a message by ID.
func (s *mailStoreGorm) Delete(id uint) error { func (s *mailStoreGorm) Delete(id uint) error {
return s.db.Delete(&db.Message{}, id).Error return s.db.Delete(&db.Message{}, id).Error
+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")
}
}
+3
View File
@@ -16,6 +16,7 @@ type Stores struct {
Outbound OutboundStore Outbound OutboundStore
ProtocolLogs ProtocolLogStore ProtocolLogs ProtocolLogStore
MailboxState MailboxStateStore MailboxState MailboxStateStore
Mailboxes MailboxStore
} }
// NewStores creates a new Stores instance with all GORM-backed implementations. // NewStores creates a new Stores instance with all GORM-backed implementations.
@@ -29,6 +30,7 @@ func NewStores(database *gorm.DB) *Stores {
Outbound: newOutboundStore(database), Outbound: newOutboundStore(database),
ProtocolLogs: newProtocolLogStore(database), ProtocolLogs: newProtocolLogStore(database),
MailboxState: newMailboxStateStore(database), MailboxState: newMailboxStateStore(database),
Mailboxes: newMailboxStore(database),
} }
} }
@@ -40,3 +42,4 @@ var _ = db.Attachment{}
var _ = db.BanEntry{} var _ = db.BanEntry{}
var _ = db.ProtocolLog{} var _ = db.ProtocolLog{}
var _ = db.MailboxState{} var _ = db.MailboxState{}
var _ = db.Mailbox{}
+23
View File
@@ -20,6 +20,10 @@ type UserStore interface {
// 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下 // 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下
// 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。 // 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。
AuthenticateLogin(login, password string) (*db.User, error) AuthenticateLogin(login, password string) (*db.User, error)
// LoginExists 判断登录名(完整邮箱或裸用户名)是否对应系统中的用户,
// 供封禁逻辑区分“真实用户输错密码”(保留宽限)与“枚举型爆破”
// (跳过宽限,见 RecordAuthFailure 的 knownUser 参数)。
LoginExists(login string) bool
Update(user *db.User) error Update(user *db.User) error
Delete(id uint) error Delete(id uint) error
List(domainID uint, page, size int) ([]db.User, int64, error) List(domainID uint, page, size int) ([]db.User, int64, error)
@@ -127,6 +131,25 @@ func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, err
return &user, nil 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. // Update saves changes to an existing user record.
func (s *userStoreGorm) Update(user *db.User) error { func (s *userStoreGorm) Update(user *db.User) error {
return s.db.Save(user).Error return s.db.Save(user).Error
+34 -19
View File
@@ -35,12 +35,30 @@ type AdminHandler struct {
protocolLogKeepDays int protocolLogKeepDays int
// hub 当前协议连接注册中心(「当前连接」页) // hub 当前协议连接注册中心(「当前连接」页)
hub *connhub.Hub hub *connhub.Hub
// tz Web 展示时区(「今日」统计边界、日志日期筛选用),nil 回退本地时区
tz *time.Location
} }
// NewAdminHandler creates a new AdminHandler with the given stores, attachment // NewAdminHandler creates a new AdminHandler with the given stores, attachment
// storage, TLS directory, Caddy data directory and outbound delivery manager. // storage, TLS directory, Caddy data directory and outbound delivery manager.
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub) *AdminHandler { func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub, tz *time.Location) *AdminHandler {
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub} return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub, tz: tz}
}
// displayTZ 返回 Web 展示时区(未配置时回退本地时区)。
func (h *AdminHandler) displayTZ() *time.Location {
if h.tz != nil {
return h.tz
}
return time.Local
}
// dayStartIn 返回展示时区的「今日零点」,并转换为服务器本地时区:
// 库中 CreatedAt 按写入时服务器本地时区序列化(RFC3339 文本),
// 边界需同偏移才能保证 SQLite 文本比较正确。
func (h *AdminHandler) dayStartIn() time.Time {
now := time.Now().In(h.displayTZ())
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, h.displayTZ()).In(time.Local)
} }
// manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。 // manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。
@@ -61,14 +79,10 @@ func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
return return
} }
// 加入黑名单:180 天封禁(管理员可随时解封) // 加入黑名单:180 天封禁(管理员可随时解封)
if err := h.stores.Bans.Create(&db.BanEntry{ // BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
IPAddress: conn.IP, // 避免与阶段性封禁的计数/档位记录错位。
Reason: "管理员手动封禁(连接断开)", if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
FailCount: 0,
BanCount: 0,
ExpiresAt: time.Now().Add(manualBanDuration),
}); err != nil {
c.String(http.StatusInternalServerError, "封禁失败: %v", err) c.String(http.StatusInternalServerError, "封禁失败: %v", err)
return return
} }
@@ -119,8 +133,8 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX") inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX")
sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent") sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent")
// Today and weekly statistics // Today and weekly statistics(「今日」按 Web 展示时区切日)
todayStart := time.Now().Truncate(24 * time.Hour) todayStart := h.dayStartIn()
weekStart := time.Now().AddDate(0, 0, -7) weekStart := time.Now().AddDate(0, 0, -7)
todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart) todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart)
@@ -860,8 +874,8 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
success = &v success = &v
} }
from := parseDateQuery(c.Query("from")) from := h.parseDateQuery(c.Query("from"))
to := parseDateQuery(c.Query("to")) to := h.parseDateQuery(c.Query("to"))
// 日期选择到天,含当天 // 日期选择到天,含当天
if !to.IsZero() { if !to.IsZero() {
to = to.AddDate(0, 0, 1) to = to.AddDate(0, 0, 1)
@@ -883,7 +897,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
} }
// 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用 // 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用
dayStart := time.Now().Truncate(24 * time.Hour) dayStart := h.dayStartIn()
todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart) todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart)
allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{}) allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{})
normStats := func(m map[string]map[string]int64) map[string]map[string]int { normStats := func(m map[string]map[string]int64) map[string]map[string]int {
@@ -935,16 +949,17 @@ func (h *AdminHandler) CleanupProtocolLogs(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/protocol-logs") c.Redirect(http.StatusFound, "/admin/protocol-logs")
} }
// parseDateQuery 解析 YYYY-MM-DD 日期,失败返回零值。 // parseDateQuery 按 Web 展示时区解析 YYYY-MM-DD 日期,失败返回零值。
func parseDateQuery(s string) time.Time { // 解析结果转换为服务器本地时区,保证与库中时间序列化偏移一致。
func (h *AdminHandler) parseDateQuery(s string) time.Time {
if s == "" { if s == "" {
return time.Time{} return time.Time{}
} }
t, err := time.ParseInLocation("2006-01-02", s, time.Local) t, err := time.ParseInLocation("2006-01-02", s, h.displayTZ())
if err != nil { if err != nil {
return time.Time{} return time.Time{}
} }
return t return t.In(time.Local)
} }
// ListMails renders the admin mail list page showing all messages across all users. // ListMails renders the admin mail list page showing all messages across all users.
+6 -2
View File
@@ -73,7 +73,10 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
user, err := h.stores.Users.Authenticate(email, password) user, err := h.stores.Users.Authenticate(email, password)
if err != nil { if err != nil {
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多") // 区分失败性质:用户名存在(真实用户输错,保留宽限)vs
// 用户名不存在(枚举型爆破,跳过宽限首次触发即封)
knownUser := h.stores.Users.LoginExists(email)
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
if banned { if banned {
entry, _ := h.stores.Bans.GetByIP(ip) entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
@@ -146,7 +149,8 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
if err != nil { if err != nil {
log.Printf("LDAP 认证失败: %v", err) log.Printf("LDAP 认证失败: %v", err)
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多") // LDAP 侧用户存在性无法判定,保守按已知用户处理(保留宽限防误封)
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
if banned { if banned {
entry, _ := h.stores.Bans.GetByIP(ip) entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
+234 -274
View File
@@ -51,39 +51,89 @@ type MailHandler struct {
stores *store.Stores stores *store.Stores
storage *storage.AttachmentStorage storage *storage.AttachmentStorage
outbound *outbound.Manager outbound *outbound.Manager
// svc 邮箱服务层(IMAP 层共用):文件夹目录与消息操作同源。
svc *imap_server.MailboxService
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空 // pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
pusher imap_server.Pusher pusher imap_server.Pusher
} }
// NewMailHandler creates a new MailHandler with the given stores, attachment // NewMailHandler creates a new MailHandler with the given stores, attachment
// storage and outbound delivery manager. // storage, mailbox service and outbound delivery manager.
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, pusher imap_server.Pusher) *MailHandler { func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, svc *imap_server.MailboxService, pusher imap_server.Pusher) *MailHandler {
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, pusher: pusher} return &MailHandler{stores: stores, storage: attStorage, outbound: ob, svc: svc, pusher: pusher}
} }
// folderCounts returns sidebar badge counts for the current user. // foldersFor 返回当前用户的侧边栏文件夹列表(与 IMAP LIST 同源:
func (h *MailHandler) folderCounts(userID uint) (inboxUnread, draftsTotal, sentTotal int64) { // IMAP 返回什么文件夹,Web 就显示什么)。
inboxUnread, _ = h.stores.Mails.CountUnread(userID, "INBOX") func (h *MailHandler) foldersFor(userID uint) []imap_server.FolderInfo {
draftsTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Drafts") infos, err := h.svc.List(userID)
sentTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Sent") if err != nil {
log.Printf("web: 加载文件夹列表失败 user=%d: %v", userID, err)
return nil
}
return infos
}
// userEmailOf 从 context 取当前用户完整邮箱(推送用),失败返回空串。
func userEmailOf(c *gin.Context) string {
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
return u.Username + "@" + u.Domain.Name
}
}
return ""
}
// seqOfFolder 返回消息在文件夹中的序号(1 基,与 IMAP 序号排序一致)。
func (h *MailHandler) seqOfFolder(userID uint, folder string, msgID uint) uint32 {
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, folder)
if err != nil {
return 0
}
for i := range msgs {
if msgs[i].ID == msgID {
return uint32(i + 1)
}
}
return 0
}
// purgeMessages 永久删除邮件(含附件文件与配额回退)。
func (h *MailHandler) purgeMessages(userID uint, msgs []db.Message) {
ids := make([]uint, 0, len(msgs))
for i := range msgs {
attachments, _ := h.stores.Attachments.ListByMessage(msgs[i].ID)
for _, att := range attachments {
_ = h.storage.Delete(att.FilePath)
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
}
if err := h.stores.Attachments.DeleteByMessage(msgs[i].ID); err != nil {
log.Printf("web: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err)
}
ids = append(ids, msgs[i].ID)
}
if err := h.stores.Mails.DeleteMany(ids); err != nil {
log.Printf("web: 删除邮件失败: %v", err)
}
}
// Folder renders the generic mailbox page for any folder the IMAP layer
// exposes (INBOX / Sent / Drafts / Trash / custom mailboxes).
func (h *MailHandler) Folder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
return return
} }
// Inbox renders the inbox page showing all messages in the user's INBOX folder.
func (h *MailHandler) Inbox(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1) page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20) messages, total, err := h.svc.Messages(userID, name, page, 20)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err) c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
return return
} }
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / 20 totalPages := int(total) / 20
if int(total)%20 > 0 { if int(total)%20 > 0 {
totalPages++ totalPages++
@@ -92,18 +142,18 @@ func (h *MailHandler) Inbox(c *gin.Context) {
totalPages = 0 totalPages = 0
} }
c.HTML(200, "inbox", gin.H{ currentUser, _ := c.Get("currentUser")
c.HTML(200, "folder", gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"messages": messages, "messages": messages,
"total": total, "total": total,
"page": page, "page": page,
"pageSize": 20, "pageSize": 20,
"totalPages": totalPages, "totalPages": totalPages,
"folder": "INBOX", "folder": name,
"activeFolder": "inbox", "activeFolder": name,
"inboxUnread": inboxUnread, "isTrash": name == "Trash",
"draftsTotal": draftsTotal, "folders": h.foldersFor(userID),
"sentTotal": sentTotal,
}) })
} }
@@ -140,16 +190,14 @@ func (h *MailHandler) View(c *gin.Context) {
} }
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
c.HTML(200, "view", gin.H{ c.HTML(200, "view", gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"message": msg, "message": msg,
"attachments": attachments, "attachments": attachments,
"activeFolder": resolveActiveFolder(msg.Folder), "activeFolder": msg.Folder,
"inboxUnread": inboxUnread, "inTrash": msg.Folder == "Trash",
"draftsTotal": draftsTotal, "folders": h.foldersFor(userID),
"sentTotal": sentTotal,
}) })
} }
@@ -167,8 +215,6 @@ func (h *MailHandler) Compose(c *gin.Context) {
quotaBytes = user.QuotaBytes quotaBytes = user.QuotaBytes
} }
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
c.HTML(200, "compose", gin.H{ c.HTML(200, "compose", gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "compose", "activeFolder": "compose",
@@ -178,12 +224,26 @@ func (h *MailHandler) Compose(c *gin.Context) {
"bodyContent": "", "bodyContent": "",
"usedBytes": usedBytes, "usedBytes": usedBytes,
"quotaBytes": quotaBytes, "quotaBytes": quotaBytes,
"inboxUnread": inboxUnread, "folders": h.foldersFor(userID),
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
}) })
} }
// composeData builds the shared template context for the compose page.
func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
return gin.H{
"currentUser": user,
"activeFolder": "compose",
"error": errMsg,
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": body,
"usedBytes": user.UsedBytes,
"quotaBytes": user.QuotaBytes,
"folders": h.foldersFor(userID),
}
}
// DoSend processes the email composition form, sends the email via SMTP, // DoSend processes the email composition form, sends the email via SMTP,
// and stores the message record. // and stores the message record.
func (h *MailHandler) DoSend(c *gin.Context) { func (h *MailHandler) DoSend(c *gin.Context) {
@@ -198,17 +258,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
cc := c.PostForm("cc") cc := c.PostForm("cc")
if to == "" { if to == "" {
c.HTML(http.StatusBadRequest, "compose", gin.H{ c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody))
"currentUser": currentUser,
"activeFolder": "compose",
"error": "请输入收件人",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return return
} }
@@ -226,17 +276,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
} }
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize) reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{ c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody))
"currentUser": currentUser,
"activeFolder": "compose",
"error": "配额检查失败,请稍后重试",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return return
} }
if !reserved { if !reserved {
@@ -255,6 +295,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
"bodyContent": htmlBody, "bodyContent": htmlBody,
"usedBytes": usedBytes, "usedBytes": usedBytes,
"quotaBytes": quotaBytes, "quotaBytes": quotaBytes,
"folders": h.foldersFor(userID),
}) })
return return
} }
@@ -314,46 +355,16 @@ func (h *MailHandler) DoSend(c *gin.Context) {
if len(externalRecipients) > 0 { if len(externalRecipients) > 0 {
ob := h.outbound ob := h.outbound
if ob == nil || !ob.Enabled() { if ob == nil || !ob.Enabled() {
c.HTML(http.StatusBadRequest, "compose", gin.H{ c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody))
"currentUser": currentUser,
"activeFolder": "compose",
"error": "外部投递未启用",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return return
} }
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt { if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
c.HTML(http.StatusBadRequest, "compose", gin.H{ c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return return
} }
for _, rcpt := range externalRecipients { for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil { if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
c.HTML(http.StatusBadRequest, "compose", gin.H{ c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return return
} }
} }
@@ -375,17 +386,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
IsRead: false, IsRead: false,
} }
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil { if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{ c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("投递邮件失败: %v", createErr),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return return
} }
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知) // 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
@@ -411,17 +412,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
} }
if createErr := h.stores.Mails.Create(msg); createErr != nil { if createErr := h.stores.Mails.Create(msg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{ c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("保存邮件失败: %v", createErr),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return return
} }
@@ -572,43 +563,6 @@ var mimeTypes = map[string]string{
".csv": "text/csv", ".csv": "text/csv",
} }
// Sent renders the sent mail folder page.
func (h *MailHandler) Sent(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Sent", page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载发件箱失败: %v", err)
return
}
currentUser, _ := c.Get("currentUser")
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "sent", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": "Sent",
"activeFolder": "sent",
"inboxUnread": inboxUnread,
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
})
}
// safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //), // safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //),
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串, // 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
// 调用方应回退到默认路径。 // 调用方应回退到默认路径。
@@ -619,7 +573,8 @@ func safeRedirectPath(referer string) string {
return referer return referer
} }
// Delete removes a message by ID after verifying ownership. // Delete 删除邮件(IMAP 语义):非 Trash 文件夹 → 移入 Trash
// 已在 Trash → 彻底删除。
func (h *MailHandler) Delete(c *gin.Context) { func (h *MailHandler) Delete(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -633,50 +588,128 @@ func (h *MailHandler) Delete(c *gin.Context) {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, "禁止访问")
return return
} }
userEmail := userEmailOf(c)
// Delete attachments on disk and in DB, and decrease UsedBytes if msg.Folder == "Trash" {
attachments, _ := h.stores.Attachments.ListByMessage(uint(id)) // 垃圾箱中删除 = 彻底删除
for _, att := range attachments { seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
_ = h.storage.Delete(att.FilePath) h.purgeMessages(userID, []db.Message{*msg})
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize) if h.pusher != nil && userEmail != "" {
}
if err := h.stores.Attachments.DeleteByMessage(uint(id)); err != nil {
log.Printf("web: 删除附件记录失败 msg=%d: %v", id, err)
}
// 删除前计算消息在所属文件夹中的序号(用于 Expunge 推送)
var seq uint32
if msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, msg.Folder); err == nil {
for i := range msgs {
if msgs[i].ID == uint(id) {
seq = uint32(i + 1)
break
}
}
}
if err := h.stores.Mails.Delete(uint(id)); err != nil {
log.Printf("web: 删除邮件失败 msg=%d: %v", id, err)
}
// 删除 → 推送给该用户的其他 IMAP 客户端
if h.pusher != nil && seq > 0 {
userEmail := ""
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
userEmail = u.Username + "@" + u.Domain.Name
}
}
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq}) h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
} }
} else {
// 其余文件夹删除 = 移入垃圾箱(与 IMAP MOVE 同源语义)
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil {
log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "删除失败")
return
}
if h.pusher != nil && userEmail != "" {
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
h.pusher.PushNewMessage(userEmail, &db.Message{UserID: userID, Folder: "Trash"})
}
}
// Redirect back based on the folder(仅同站相对路径,防开放重定向) // Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer")) referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" { if referer == "" {
referer = "/inbox" referer = "/folder/" + msg.Folder
} }
c.Redirect(http.StatusFound, referer) c.Redirect(http.StatusFound, referer)
} }
// Restore 把垃圾箱中的邮件恢复到收件箱。
func (h *MailHandler) Restore(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
if msg.Folder != "Trash" {
c.Redirect(http.StatusFound, "/folder/"+msg.Folder)
return
}
seq := h.seqOfFolder(userID, "Trash", msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil {
log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "恢复失败")
return
}
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, "Trash", []uint32{seq})
h.pusher.PushNewMessage(email, &db.Message{UserID: userID, Folder: "INBOX"})
}
}
c.Redirect(http.StatusFound, "/folder/Trash")
}
// Purge 彻底删除一封邮件(任意文件夹)。
func (h *MailHandler) Purge(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
h.purgeMessages(userID, []db.Message{*msg})
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, msg.Folder, []uint32{seq})
}
}
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/folder/" + msg.Folder
}
c.Redirect(http.StatusFound, referer)
}
// EmptyFolder 清空文件夹(永久删除其中全部邮件)。
func (h *MailHandler) EmptyFolder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
return
}
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil {
c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err)
return
}
seqs := make([]uint32, 0, len(msgs))
for i := range msgs {
seqs = append(seqs, uint32(i+1))
}
h.purgeMessages(userID, msgs)
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, name, seqs)
}
}
c.Redirect(http.StatusFound, "/folder/"+name)
}
// MarkRead marks a message as read. // MarkRead marks a message as read.
func (h *MailHandler) MarkRead(c *gin.Context) { func (h *MailHandler) MarkRead(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
@@ -709,7 +742,7 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
// Redirect back based on the folder(仅同站相对路径,防开放重定向) // Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer")) referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" { if referer == "" {
referer = "/inbox" referer = "/folder/INBOX"
} }
c.Redirect(http.StatusFound, referer) c.Redirect(http.StatusFound, referer)
} }
@@ -760,60 +793,31 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int {
return page return page
} }
// Drafts renders the drafts folder page.
func (h *MailHandler) Drafts(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err)
return
}
currentUser, _ := c.Get("currentUser")
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "drafts", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": "Drafts",
"activeFolder": "drafts",
"inboxUnread": inboxUnread,
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
})
}
// Settings renders the user settings page. // Settings renders the user settings page.
func (h *MailHandler) Settings(c *gin.Context) { func (h *MailHandler) Settings(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
userID := c.GetUint("userID") userID := c.GetUint("userID")
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
c.HTML(200, "settings", gin.H{ c.HTML(200, "settings", gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "settings", "activeFolder": "settings",
"error": "", "error": "",
"success": "", "success": "",
"mustChange": c.Query("force") == "1", "mustChange": c.Query("force") == "1",
"inboxUnread": inboxUnread, "folders": h.foldersFor(userID),
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
}) })
} }
// settingsData builds the shared template context for the settings page.
func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H {
return gin.H{
"currentUser": user,
"activeFolder": "settings",
"error": errMsg,
"success": success,
"folders": h.foldersFor(userID),
}
}
// UpdateSettings handles the password change form. // UpdateSettings handles the password change form.
func (h *MailHandler) UpdateSettings(c *gin.Context) { func (h *MailHandler) UpdateSettings(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
@@ -826,76 +830,32 @@ func (h *MailHandler) UpdateSettings(c *gin.Context) {
// Verify old password // Verify old password
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
c.HTML(http.StatusBadRequest, "settings", gin.H{ c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", ""))
"currentUser": currentUser,
"activeFolder": "settings",
"error": "当前密码不正确",
"success": "",
})
return return
} }
if newPassword == "" { if newPassword == "" {
c.HTML(http.StatusBadRequest, "settings", gin.H{ c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", ""))
"currentUser": currentUser,
"activeFolder": "settings",
"error": "新密码不能为空",
"success": "",
})
return return
} }
if newPassword != confirmPassword { if newPassword != confirmPassword {
c.HTML(http.StatusBadRequest, "settings", gin.H{ c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", ""))
"currentUser": currentUser,
"activeFolder": "settings",
"error": "两次输入的密码不一致",
"success": "",
})
return return
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "settings", gin.H{ c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", ""))
"currentUser": currentUser,
"activeFolder": "settings",
"error": "密码加密失败",
"success": "",
})
return return
} }
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil { if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
c.HTML(http.StatusInternalServerError, "settings", gin.H{ c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", ""))
"currentUser": currentUser,
"activeFolder": "settings",
"error": "密码更新失败",
"success": "",
})
return return
} }
c.HTML(http.StatusOK, "settings", gin.H{ c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功"))
"currentUser": currentUser,
"activeFolder": "settings",
"error": "",
"success": "密码修改成功",
})
}
// resolveActiveFolder maps a folder name to a sidebar active state key.
func resolveActiveFolder(folder string) string {
switch folder {
case "INBOX":
return "inbox"
case "Sent":
return "sent"
case "Drafts":
return "drafts"
default:
return folder
}
} }
// formatBytes converts a file size in bytes to a human-readable string. // formatBytes converts a file size in bytes to a human-readable string.
+188
View File
@@ -0,0 +1,188 @@
package handlers
// Web 删除语义回归测试:删除=移入垃圾箱(IMAP 语义)、垃圾箱中删除=彻底
// 删除、恢复=回到收件箱、清空=永久删除。文件夹操作全部经由 MailboxService
// (与 IMAP 会话共用同一份实现)。
import (
"html/template"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newMailTestHandler(t *testing.T) (*MailHandler, *store.Stores) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
t.Fatal(err)
}
stores := store.NewStores(gdb)
if err := stores.Users.Create(&db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}, DomainID: 1}); err != nil {
t.Fatal(err)
}
return NewMailHandler(stores, nil, nil, imap_server.NewMailboxService(stores), nil), stores
}
// newMailTestRouter 注册删除/恢复/清空路由并注入认证上下文。
func newMailTestRouter(h *MailHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// Folder 页渲染需要模板(与 oauth2 测试共用同一份测试函数表)
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
r.SetHTMLTemplate(tmpl)
r.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Set("currentUser", &db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}})
c.Next()
})
r.POST("/mail/delete/:id", h.Delete)
r.POST("/mail/restore/:id", h.Restore)
r.POST("/mail/purge/:id", h.Purge)
r.POST("/folder/:name/empty", h.EmptyFolder)
r.GET("/folder/:name", h.Folder)
return r
}
func seedWebMsg(t *testing.T, stores *store.Stores, folder string) *db.Message {
t.Helper()
msg := &db.Message{
UserID: 1,
Folder: folder,
FromAddr: "sender@other.com",
ToAddr: "alice@example.com",
Subject: "测试邮件",
Date: time.Now(),
}
if err := stores.Mails.Create(msg); err != nil {
t.Fatal(err)
}
return msg
}
func msgFolder(t *testing.T, stores *store.Stores, id uint) (string, bool) {
t.Helper()
msg, err := stores.Mails.GetByID(id)
if err != nil {
return "", false
}
return msg.Folder, true
}
func TestWebDeleteMovesToTrash(t *testing.T) {
h, stores := newMailTestHandler(t)
msg := seedWebMsg(t, stores, "INBOX")
r := newMailTestRouter(h)
// 收件箱删除 → 移入垃圾箱(非物理删除)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
req.Header.Set("Referer", "/folder/INBOX")
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("delete status = %d, want 302", w.Code)
}
folder, ok := msgFolder(t, stores, msg.ID)
if !ok || folder != "Trash" {
t.Fatalf("deleted msg folder = %q, want Trash", folder)
}
// 垃圾箱中再删除 → 彻底删除
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
req2.Header.Set("Referer", "/folder/Trash")
r.ServeHTTP(w2, req2)
if _, ok := msgFolder(t, stores, msg.ID); ok {
t.Fatal("trash delete should purge the message")
}
}
func TestWebRestoreToInbox(t *testing.T) {
h, stores := newMailTestHandler(t)
msg := seedWebMsg(t, stores, "Trash")
r := newMailTestRouter(h)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mail/restore/"+itoa(msg.ID), nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("restore status = %d, want 302", w.Code)
}
folder, ok := msgFolder(t, stores, msg.ID)
if !ok || folder != "INBOX" {
t.Fatalf("restored msg folder = %q, want INBOX", folder)
}
}
func TestWebEmptyFolderPurgesAll(t *testing.T) {
h, stores := newMailTestHandler(t)
seedWebMsg(t, stores, "Trash")
seedWebMsg(t, stores, "Trash")
r := newMailTestRouter(h)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/folder/Trash/empty", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("empty status = %d, want 302", w.Code)
}
count, err := stores.Mails.CountByUserAndFolder(1, "Trash")
if err != nil || count != 0 {
t.Fatalf("Trash count after empty = %d, want 0", count)
}
}
func TestWebFolderPageListsDynamicFolders(t *testing.T) {
h, stores := newMailTestHandler(t)
seedWebMsg(t, stores, "INBOX")
r := newMailTestRouter(h)
// IMAP CREATE 语义创建的自定义文件夹(经同一 MailboxService
svc := imap_server.NewMailboxService(stores)
if err := svc.Create(1, "工作"); err != nil {
t.Fatalf("create custom mailbox: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/folder/工作", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("custom folder page status = %d, want 200", w.Code)
}
// 不存在的文件夹 → 404
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/folder/不存在", nil)
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusNotFound {
t.Fatalf("missing folder status = %d, want 404", w2.Code)
}
}
func itoa(n uint) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
@@ -46,7 +46,12 @@ func testTemplateFuncs() template.FuncMap {
"initial": func(s string) string { return "?" }, "initial": func(s string) string { return "?" },
"truncate": func(s string, n int) string { return s }, "truncate": func(s string, n int) string { return s },
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") }, "shortDate": func(t time.Time) string { return t.Format("2006-01-02") },
"localTime": func(t time.Time) time.Time { return t },
"time12": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") },
"time12m": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
"avatarStyle": func(s string) string { return "background:#eee;color:#333" }, "avatarStyle": func(s string) string { return "background:#eee;color:#333" },
"urlPath": func(s string) string { return url.PathEscape(s) },
"folderLabel": func(s string) string { return s },
} }
} }
+15 -7
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
"mail_go/internal/db" "mail_go/internal/db"
"mail_go/internal/imap_server"
) )
func TestRenderAllPages(t *testing.T) { func TestRenderAllPages(t *testing.T) {
@@ -46,27 +47,34 @@ func TestRenderAllPages(t *testing.T) {
{ID: 2, FileName: "logo.png", FileSize: 128 * 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 { cases := []struct {
name string name string
data ginH data ginH
}{ }{
{"login", ginH{"error": ""}}, {"login", ginH{"error": ""}},
{"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}}, {"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
{"inbox", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "activeFolder": "inbox", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, {"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
{"drafts", ginH{"currentUser": user, "messages": messages, "total": 1, "page": 1, "totalPages": 1, "activeFolder": "drafts", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, {"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
{"sent", ginH{"currentUser": user, "messages": messages, "total": 3, "page": 1, "totalPages": 1, "activeFolder": "sent", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}},
{"view", ginH{ {"view", ginH{
"currentUser": user, "activeFolder": "inbox", "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}, "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, "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3), "attachments": attachments, "inTrash": false, "folders": folders,
}}, }},
{"compose", ginH{ {"compose", ginH{
"currentUser": user, "activeFolder": "compose", "error": "", "currentUser": user, "activeFolder": "compose", "error": "",
"to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "", "to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "",
"usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024), "usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024),
"inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3), "folders": folders,
}}, }},
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, {"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
{"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}}, {"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}},
{"admin_bans", ginH{ {"admin_bans", ginH{
"currentUser": user, "activeFolder": "bans", "currentUser": user, "activeFolder": "bans",
+151 -16
View File
@@ -4,11 +4,14 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"io/fs"
"math" "math"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -54,6 +57,8 @@ type WebServer struct {
caddyDataDir string caddyDataDir string
outbound *outbound.Manager outbound *outbound.Manager
hub *connhub.Hub hub *connhub.Hub
// staticFS 内嵌的静态资源(/static 路由),二进制自包含
staticFS http.FileSystem
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空 // pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
pusher imap_server.Pusher pusher imap_server.Pusher
} }
@@ -103,10 +108,36 @@ func templateFuncs() template.FuncMap {
"initial": initial, "initial": initial,
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。 // truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
"truncate": truncate, "truncate": truncate,
// shortDate 按 QQ 邮箱习惯格式化:今天显示 HH:mm,今年显示 MM-DD,更早显示 YYYY-MM-DD // shortDate 邮件列表时间:统一 12 小时制(上午/下午)
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
// 更早显示「2026-08-20 下午 2:35」。
"shortDate": shortDate, "shortDate": shortDate,
// time12 完整时间(含秒),先转换为 Web 时区:
// 「2026-08-20 下午 2:35:05」。
"time12": time12,
// time12m 同上但不含秒。
"time12m": time12m,
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
"localTime": localTime,
// avatarStyle 根据字符串哈希生成头像背景/前景色。 // avatarStyle 根据字符串哈希生成头像背景/前景色。
"avatarStyle": avatarStyle, "avatarStyle": avatarStyle,
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
"urlPath": url.PathEscape,
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。
"folderLabel": func(name string) string {
switch name {
case "INBOX":
return "收件箱"
case "Sent":
return "已发送"
case "Drafts":
return "草稿箱"
case "Trash":
return "已删除"
default:
return name
}
},
} }
} }
@@ -155,17 +186,91 @@ func truncate(s string, n int) string {
return string(r[:n]) + "…" return string(r[:n]) + "…"
} }
// shortDate formats a time like QQ Mail does: today -> HH:mm, // periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。
// this year -> MM-DD, otherwise -> YYYY-MM-DD. func periodOf(t time.Time) (string, int) {
period := "上午"
if t.Hour() >= 12 {
period = "下午"
}
h := t.Hour() % 12
if h == 0 {
h = 12
}
return period, h
}
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
// 更早 → 「2026-08-20 下午 2:35」。
func shortDate(t time.Time) string { func shortDate(t time.Time) string {
now := time.Now() t = inWebTZ(t)
now := time.Now().In(t.Location())
period, h := periodOf(t)
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
if t.Year() == now.Year() && t.YearDay() == now.YearDay() { if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04") return clock
} }
if t.Year() == now.Year() { if t.Year() == now.Year() {
return t.Format("01-02") return fmt.Sprintf("%s %s", t.Format("01-02"), clock)
} }
return t.Format("2006-01-02") return fmt.Sprintf("%s %s", t.Format("2006-01-02"), clock)
}
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
// 「2026-08-20 下午 2:35:05」。
func time12(t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
}
// time12m 完整时间(12 小时制,不含秒)。
func time12m(t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
}
// webTZ 是 Web 界面显示时间使用的时区(默认 Asia/Shanghai)。
var webTZ = time.Local
// fixedTimezone 解析 "+08:00"/"UTC+8" 形式的固定偏移时区;解析失败返回 nil。
func fixedTimezone(s string) *time.Location {
s = strings.TrimSpace(s)
sign := 1
rest := s
if strings.HasPrefix(rest, "+") {
rest = rest[1:]
} else if strings.HasPrefix(rest, "-") {
sign = -1
rest = rest[1:]
}
if strings.HasPrefix(strings.ToUpper(rest), "UTC") {
rest = strings.TrimSpace(rest[3:])
}
parts := strings.SplitN(rest, ":", 2)
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || h < 0 || h > 23 {
return nil
}
m := 0
if len(parts) == 2 {
if m, err = strconv.Atoi(strings.TrimSpace(parts[1])); err != nil || m < 0 || m > 59 {
return nil
}
}
offset := sign * (h*3600 + m*60)
return time.FixedZone("UTC"+strconv.Itoa(offset/3600), offset)
}
// inWebTZ 把时间转换到 Web 展示时区。
func inWebTZ(t time.Time) time.Time {
return t.In(webTZ)
}
// localTime 是 localTime 模板函数的实现(转换到 Web 展示时区)。
func localTime(t time.Time) time.Time {
return t.In(webTZ)
} }
// avatarStyle returns inline CSS colors derived from a string hash. // avatarStyle returns inline CSS colors derived from a string hash.
@@ -184,6 +289,17 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
return nil, err 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) gin.SetMode(gin.ReleaseMode)
engine := gin.New() engine := gin.New()
engine.Use(gin.Logger()) engine.Use(gin.Logger())
@@ -215,6 +331,12 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html")) template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html"))
engine.SetHTMLTemplate(tmpl) engine.SetHTMLTemplate(tmpl)
// 内嵌静态资源根目录(/static 路由数据源)
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, fmt.Errorf("加载内嵌静态资源失败: %w", err)
}
ws := &WebServer{ ws := &WebServer{
engine: engine, engine: engine,
stores: stores, stores: stores,
@@ -226,6 +348,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
caddyDataDir: caddyCfg.DataDir, caddyDataDir: caddyCfg.DataDir,
outbound: ob, outbound: ob,
hub: hub, hub: hub,
staticFS: http.FS(staticSub),
pusher: pusher, pusher: pusher,
} }
@@ -236,14 +359,19 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
// registerRoutes sets up all HTTP routes with their handlers and middleware. // registerRoutes sets up all HTTP routes with their handlers and middleware.
func (ws *WebServer) registerRoutes() { func (ws *WebServer) registerRoutes() {
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg) authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, ws.pusher) 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) 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 // Apply BanMiddleware globally before public routes
ws.engine.Use(middleware.BanMiddleware(ws.stores)) ws.engine.Use(middleware.BanMiddleware(ws.stores))
// Security headers on every response // Security headers on every response
ws.engine.Use(middleware.SecurityHeaders()) 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) // Public routes (no auth required)
ws.engine.GET("/login", authHandler.ShowLogin) ws.engine.GET("/login", authHandler.ShowLogin)
ws.engine.POST("/login", authHandler.DoLogin) ws.engine.POST("/login", authHandler.DoLogin)
@@ -260,20 +388,27 @@ func (ws *WebServer) registerRoutes() {
c.Redirect(302, "/inbox") c.Redirect(302, "/inbox")
}) })
// Mail routes // Mail routes:通用文件夹页(文件夹目录与 IMAP LIST 同源)
auth.GET("/inbox", mailHandler.Inbox) auth.GET("/folder/:name", mailHandler.Folder)
auth.GET("/inbox/:id", mailHandler.View) auth.GET("/folder/:name/:id", mailHandler.View)
auth.POST("/folder/:name/empty", mailHandler.EmptyFolder)
auth.GET("/compose", mailHandler.Compose) auth.GET("/compose", mailHandler.Compose)
auth.POST("/compose", mailHandler.DoSend) 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.GET("/settings", mailHandler.Settings)
auth.POST("/settings", mailHandler.UpdateSettings) auth.POST("/settings", mailHandler.UpdateSettings)
auth.POST("/mail/delete/:id", mailHandler.Delete) 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.POST("/mail/read/:id", mailHandler.MarkRead)
auth.GET("/attachment/:id", mailHandler.DownloadAttachment) 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) // Admin routes (auth + admin required)
+5 -5
View File
@@ -41,7 +41,7 @@ func newTestStores(t *testing.T) *store.Stores {
if err != nil { if err != nil {
t.Fatalf("open sqlite: %v", err) 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.Mailbox{}); err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
return store.NewStores(gdb) return store.NewStores(gdb)
@@ -122,7 +122,7 @@ func TestSessionSignedWithConfiguredSecretKey(t *testing.T) {
} }
// 合法会话可以访问收件箱 // 合法会话可以访问收件箱
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: sessionCookie}) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: sessionCookie})
resp2, err := client.Do(req) resp2, err := client.Do(req)
if err != nil { if err != nil {
@@ -151,7 +151,7 @@ func TestLegacyHardcodedKeyCannotForgeSession(t *testing.T) {
t.Fatalf("forge cookie: %v", err) t.Fatalf("forge cookie: %v", err)
} }
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: forged}) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: forged})
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
@@ -229,7 +229,7 @@ func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) {
expired := time.Now().Add(-8 * 24 * time.Hour).Unix() expired := time.Now().Add(-8 * 24 * time.Hour).Unix()
cookie := encodeSessionCookie(t, key, authCookieValues(1, expired)) cookie := encodeSessionCookie(t, key, authCookieValues(1, expired))
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie}) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
@@ -254,7 +254,7 @@ func TestSessionWithinExpiryWorks(t *testing.T) {
cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix())) cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix()))
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie}) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
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)
}
}
}
+1 -1
View File
@@ -55,7 +55,7 @@
</td> </td>
<td>{{.FailCount}}</td> <td>{{.FailCount}}</td>
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td> <td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td>
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{if not .Active}}(已过期){{end}}</td> <td>{{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}</td>
<td> <td>
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;" <form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');"> onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
@@ -77,9 +77,9 @@
<td>{{.Port}}</td> <td>{{.Port}}</td>
<td>{{if .User}}{{.User}}{{else}}—{{end}}</td> <td>{{if .User}}{{.User}}{{else}}—{{end}}</td>
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td> <td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td>
<td>{{.Connected.Format "2006-01-02 15:04:05"}}</td> <td>{{time12 .Connected}}</td>
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td> <td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
<td>{{.LastActive.Format "2006-01-02 15:04:05"}}</td> <td>{{time12 .LastActive}}</td>
<td> <td>
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;" <form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');"> onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');">
+1 -1
View File
@@ -43,7 +43,7 @@
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}} {{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> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
<p><strong>文件夹:</strong> {{.message.Folder}}</p> <p><strong>文件夹:</strong> {{.message.Folder}}</p>
<p><strong>时间:</strong> {{.message.Date.Format "2006-01-02 15:04:05"}}</p> <p><strong>时间:</strong> {{time12 .message.Date}}</p>
</div> </div>
<div class="mail-body"> <div class="mail-body">
{{if .message.HtmlBody}} {{if .message.HtmlBody}}
+1 -1
View File
@@ -54,7 +54,7 @@
<td>{{.Subject}}</td> <td>{{.Subject}}</td>
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td> <td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
<td>{{.Folder}}</td> <td>{{.Folder}}</td>
<td>{{.Date.Format "2006-01-02 15:04"}}</td> <td>{{time12m .Date}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
+2 -2
View File
@@ -82,9 +82,9 @@
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}} {{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
</td> </td>
<td>{{.Attempts}}</td> <td>{{.Attempts}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}</td> <td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m .NextAttemptAt}}{{else}}—{{end}}</td>
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td> <td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td> <td>{{time12m .CreatedAt}}</td>
<td> <td>
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}} {{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;"> <form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
@@ -92,7 +92,7 @@
<tbody> <tbody>
{{range .logs}} {{range .logs}}
<tr> <tr>
<td style="white-space:nowrap;">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td> <td style="white-space:nowrap;">{{time12 .CreatedAt}}</td>
<td> <td>
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span> {{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 if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
+1 -1
View File
@@ -65,7 +65,7 @@
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">解封时间</span> <span class="detail-label">解封时间</span>
<span class="detail-value">{{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}}</span> <span class="detail-value">{{time12 .entry.ExpiresAt}}</span>
</div> </div>
</div> </div>
{{end}} {{end}}
+23 -18
View File
@@ -165,7 +165,7 @@
font-size: 12.5px; color: var(--text-3); font-size: 12.5px; color: var(--text-3);
} }
.cell-date { .cell-date {
width: 78px; flex-shrink: 0; text-align: right; flex: 0 0 auto; white-space: nowrap; text-align: right;
font-size: 12.5px; color: var(--text-3); font-size: 12.5px; color: var(--text-3);
} }
.mail-row.unread .cell-date { color: var(--text-2); } .mail-row.unread .cell-date { color: var(--text-2); }
@@ -353,11 +353,12 @@
.folder { .folder {
flex: 1; flex-direction: column; justify-content: center; gap: 2px; flex: 1; flex-direction: column; justify-content: center; gap: 2px;
height: 46px; padding: 0; border-radius: 8px; font-size: 10.5px; height: 46px; padding: 0; border-radius: 8px; font-size: 10.5px;
position: relative; position: relative; order: 7; /* 自定义文件夹排在最后 */
} }
.folder-nav .folder:nth-child(1) { order: 1; } .folder-nav .folder:nth-child(1) { order: 1; }
.folder-nav .folder:nth-child(2) { order: 2; } .folder-nav .folder:nth-child(2) { order: 2; }
.folder-nav .folder:nth-child(3) { order: 4; } .folder-nav .folder:nth-child(3) { order: 4; }
.folder-nav .folder:nth-child(4) { order: 6; }
.folder svg { width: 19px; height: 19px; } .folder svg { width: 19px; height: 19px; }
.folder.active::before { display: none; } .folder.active::before { display: none; }
.folder .badge { .folder .badge {
@@ -376,7 +377,7 @@
.compose-btn svg { width: 20px; height: 20px; } .compose-btn svg { width: 20px; height: 20px; }
.sidebar-footer { .sidebar-footer {
margin: 0; border: none; padding: 0; margin: 0; border: none; padding: 0;
flex-direction: row; gap: 2px; order: 5; flex-direction: row; gap: 2px; order: 8;
} }
.mail-main { padding-bottom: calc(58px + env(safe-area-inset-bottom)); } .mail-main { padding-bottom: calc(58px + env(safe-area-inset-bottom)); }
@@ -387,11 +388,11 @@
.page-info { font-size: 12px; } .page-info { font-size: 12px; }
.mail-row { padding: 0 10px; height: 60px; gap: 8px; } .mail-row { padding: 0 10px; height: 60px; gap: 8px; }
.avatar { width: 32px; height: 32px; font-size: 13px; } .avatar { width: 32px; height: 32px; font-size: 13px; }
.cell-from { width: 88px; font-size: 13px; } .cell-from { width: 76px; font-size: 13px; }
.cell-subject-wrap { flex: 1; min-width: 0; } .cell-subject-wrap { flex: 1; min-width: 0; }
.cell-subject { font-size: 13px; } .cell-subject { font-size: 13px; }
.cell-snippet { display: none; } .cell-snippet { display: none; }
.cell-date { width: 56px; font-size: 11.5px; } .cell-date { width: auto; font-size: 10.5px; }
.row-del { opacity: 1; } .row-del { opacity: 1; }
.list-footer { padding: 8px 10px; } .list-footer { padding: 8px 10px; }
.pager { margin-left: auto; gap: 4px; } .pager { margin-left: auto; gap: 4px; }
@@ -463,21 +464,25 @@
写信 写信
</a> </a>
<nav class="folder-nav"> <nav class="folder-nav">
<a class="folder {{if eq .activeFolder `inbox`}}active{{end}}" href="/inbox"> {{range .folders}}
<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> <a class="folder {{if eq $.activeFolder .Name}}active{{end}}" href="/folder/{{urlPath .Name}}">
收件箱 {{if eq .SpecialUse "Sent"}}
{{if .inboxUnread}}<span class="badge">{{.inboxUnread}}</span>{{end}}
</a>
<a class="folder {{if eq .activeFolder `drafts`}}active{{end}}" href="/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>
草稿箱
{{if .draftsTotal}}<span class="count">{{.draftsTotal}}</span>{{end}}
</a>
<a class="folder {{if eq .activeFolder `sent`}}active{{end}}" href="/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> <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"}}
{{if .sentTotal}}<span class="count">{{.sentTotal}}</span>{{end}} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
{{else if eq .SpecialUse "Trash"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{{else if eq .Name "INBOX"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
{{else}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{{end}}
{{folderLabel .Name}}
{{if eq .Name "INBOX"}}
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
</a> </a>
{{end}}
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
{{if .currentUser.IsAdmin}} {{if .currentUser.IsAdmin}}
+2 -2
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>写信 - MailGo</title> <title>写信 - MailGo</title>
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet"> <link href="/static/vendor/quill/quill.snow.css" rel="stylesheet">
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body class="page-compose"> <body class="page-compose">
@@ -60,7 +60,7 @@
</form> </form>
</main> </main>
</div> </div>
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script> <script src="/static/vendor/quill/quill.min.js"></script>
<script> <script>
var quill = new Quill('#editor', { var quill = new Quill('#editor', {
theme: 'snow', theme: 'snow',
@@ -1,10 +1,10 @@
{{define "drafts"}} {{define "folder"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>草稿箱 - MailGo</title> <title>{{folderLabel .folder}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body class="page-list"> <body class="page-list">
@@ -23,29 +23,46 @@
</button> </button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled> <button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除 {{if .isTrash}}彻底删除{{else}}删除{{end}}
</button> </button>
{{if .isTrash}}
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
onsubmit="return confirm('确定要清空「已删除」文件夹吗?此操作不可恢复。');">
<button type="submit" class="tb-btn danger">清空文件夹</button>
</form>
{{end}}
<div class="toolbar-spacer"></div> <div class="toolbar-spacer"></div>
<span class="page-info">共 {{.total}} 封</span> <span class="page-info">共 {{.total}} 封</span>
</div> </div>
{{if not .messages}} {{if not .messages}}
<div class="mail-list"> <div class="mail-list">
<div class="empty-tip"><span class="empty-icon">📝</span>草稿箱暂无邮件</div> <div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel .folder}}暂无邮件</div>
</div> </div>
{{else}} {{else}}
<ul class="mail-list"> <ul class="mail-list">
{{range .messages}} {{range .messages}}
<li class="mail-row" data-id="{{.ID}}"> <li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
<label class="cell-check" onclick="event.stopPropagation()"> <label class="cell-check" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-id="{{.ID}}"> <input type="checkbox" class="row-check" data-id="{{.ID}}">
</label> </label>
<span class="cell-avatar"> <span class="cell-avatar">
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span> <span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
{{else}}
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
{{end}}
</span>
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="收件人:{{.ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
{{if eq $.folder "Drafts"}}致:{{end}}{{mailName .ToAddr}}
{{else}}
{{mailName (decodeHeader .FromAddr)}}
{{end}}
</span> </span>
<span class="cell-from" title="收件人:{{.ToAddr}}">致:{{mailName .ToAddr}}</span>
<span class="cell-subject-wrap"> <span class="cell-subject-wrap">
<a class="cell-subject" href="/drafts/{{.ID}}"> {{if not .IsRead}}<span class="unread-dot"></span>{{end}}
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}} {{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a> </a>
</span> </span>
@@ -53,12 +70,21 @@
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}} {{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
</span> </span>
<span class="cell-date">{{shortDate .Date}}</span> <span class="cell-date">{{shortDate .Date}}</span>
{{if $.isTrash}}
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要恢复这封邮件吗?');">
<button type="submit" class="icon-btn" title="恢复到收件箱">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del" <form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
onsubmit="return confirm('确定要删除这封草稿吗?');"> onsubmit="event.stopPropagation(); return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="icon-btn" title="删除"> <button type="submit" class="icon-btn" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button> </button>
</form> </form>
{{end}}
</li> </li>
{{end}} {{end}}
</ul> </ul>
@@ -68,7 +94,7 @@
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span> <span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<div class="pager"> <div class="pager">
{{if gt .page 1}} {{if gt .page 1}}
<a class="page-btn" href="/drafts?page={{sub .page 1}}"> <a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页 上一页
</a> </a>
@@ -76,7 +102,7 @@
<span class="page-btn disabled">上一页</span> <span class="page-btn disabled">上一页</span>
{{end}} {{end}}
{{if lt .page .totalPages}} {{if lt .page .totalPages}}
<a class="page-btn" href="/drafts?page={{add .page 1}}"> <a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}">
下一页 下一页
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
-88
View File
@@ -1,88 +0,0 @@
{{define "inbox"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>收件箱 - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-list">
{{template "navbar" .}}
<div class="app-body">
{{template "sidebar" .}}
<main class="mail-main">
<div class="list-toolbar">
<label class="check-all" title="全选/取消全选">
<input type="checkbox" id="select-all">
全选
</label>
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
<div class="toolbar-spacer"></div>
<span class="page-info">共 {{.total}} 封</span>
</div>
{{if not .messages}}
<div class="mail-list">
<div class="empty-tip"><span class="empty-icon">📭</span>收件箱暂无邮件</div>
</div>
{{else}}
<ul class="mail-list">
{{range .messages}}
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
<label class="cell-check" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-id="{{.ID}}">
</label>
<span class="cell-avatar">
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
</span>
<span class="cell-from" title="{{decodeHeader .FromAddr}}">{{mailName (decodeHeader .FromAddr)}}</span>
<span class="cell-subject-wrap">
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
<a class="cell-subject" href="/inbox/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</span>
<span class="cell-snippet">
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
</span>
<span class="cell-date">{{shortDate .Date}}</span>
</li>
{{end}}
</ul>
{{end}}
<div class="list-footer">
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<div class="pager">
{{if gt .page 1}}
<a class="page-btn" href="/inbox?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页
</a>
{{else}}
<span class="page-btn disabled">上一页</span>
{{end}}
{{if lt .page .totalPages}}
<a class="page-btn" href="/inbox?page={{add .page 1}}">
下一页
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
{{else}}
<span class="page-btn disabled">下一页</span>
{{end}}
</div>
</div>
</main>
</div>
{{template "listjs" .}}
</body>
</html>
{{end}}
-93
View File
@@ -1,93 +0,0 @@
{{define "sent"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>已发送 - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-list">
{{template "navbar" .}}
<div class="app-body">
{{template "sidebar" .}}
<main class="mail-main">
<div class="list-toolbar">
<label class="check-all" title="全选/取消全选">
<input type="checkbox" id="select-all">
全选
</label>
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
<div class="toolbar-spacer"></div>
<span class="page-info">共 {{.total}} 封</span>
</div>
{{if not .messages}}
<div class="mail-list">
<div class="empty-tip"><span class="empty-icon">📤</span>已发送暂无邮件</div>
</div>
{{else}}
<ul class="mail-list">
{{range .messages}}
<li class="mail-row" data-id="{{.ID}}">
<label class="cell-check" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-id="{{.ID}}">
</label>
<span class="cell-avatar">
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
</span>
<span class="cell-from" title="收件人:{{.ToAddr}}">{{mailName .ToAddr}}</span>
<span class="cell-subject-wrap">
<a class="cell-subject" href="/sent/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</span>
<span class="cell-snippet">
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
</span>
<span class="cell-date">{{shortDate .Date}}</span>
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="icon-btn" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
</li>
{{end}}
</ul>
{{end}}
<div class="list-footer">
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<div class="pager">
{{if gt .page 1}}
<a class="page-btn" href="/sent?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页
</a>
{{else}}
<span class="page-btn disabled">上一页</span>
{{end}}
{{if lt .page .totalPages}}
<a class="page-btn" href="/sent?page={{add .page 1}}">
下一页
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
{{else}}
<span class="page-btn disabled">下一页</span>
{{end}}
</div>
</div>
</main>
</div>
{{template "listjs" .}}
</body>
</html>
{{end}}
+33 -1
View File
@@ -21,6 +21,21 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复 回复
</a> </a>
{{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复
</button>
</form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
<button type="submit" class="tb-btn danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;" <form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');"> onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="tb-btn danger"> <button type="submit" class="tb-btn danger">
@@ -28,6 +43,7 @@
删除 删除
</button> </button>
</form> </form>
{{end}}
</div> </div>
<div class="mail-head"> <div class="mail-head">
@@ -38,7 +54,7 @@
<div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div> <div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div>
<div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div> <div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div>
</div> </div>
<span class="mail-date">{{.message.Date.Format "2006-01-02 15:04:05"}}</span> <span class="mail-date">{{time12 .message.Date}}</span>
</div> </div>
{{if .message.CcAddr}} {{if .message.CcAddr}}
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;"> <div class="mail-from-addr" style="margin:-8px 0 16px 48px;">
@@ -74,6 +90,21 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复 回复
</a> </a>
{{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复到收件箱
</button>
</form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
<button type="submit" class="btn btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;" <form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');"> onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
@@ -81,6 +112,7 @@
删除邮件 删除邮件
</button> </button>
</form> </form>
{{end}}
</div> </div>
</main> </main>
</div> </div>
+74
View File
@@ -0,0 +1,74 @@
package web
// 12 小时制时间格式化(上午/下午)回归测试:
// time12 / time12m / shortDate 需先按 Web 时区转换,再输出中文习惯的
// 12 小时制(「2026-08-20 下午 2:35:05」),后台页面此前直接 Format
// 输出库内 UTC 时间(比北京时间慢 8 小时)。
import (
"fmt"
"testing"
"time"
)
// setWebTZForTest 临时把展示时区固定为 UTC+8,测试结束恢复。
func setWebTZForTest(t *testing.T, loc *time.Location) {
t.Helper()
old := webTZ
webTZ = loc
t.Cleanup(func() { webTZ = old })
}
func TestTime12Format(t *testing.T) {
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
cases := []struct {
name string
in time.Time
want string
}{
{"上午", time.Date(2026, 8, 20, 1, 5, 7, 0, time.UTC), "2026-08-20 上午 9:05:07"},
{"正午", time.Date(2026, 8, 20, 4, 0, 0, 0, time.UTC), "2026-08-20 下午 12:00:00"},
{"下午", time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC), "2026-08-20 下午 6:30:45"},
{"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"},
}
for _, tc := range cases {
if got := time12(tc.in); got != tc.want {
t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestTime12mNoSeconds(t *testing.T) {
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
in := time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC)
if got, want := time12m(in), "2026-08-20 下午 6:30"; got != want {
t.Fatalf("time12m = %q, want %q", got, want)
}
}
func TestShortDate12Hour(t *testing.T) {
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
now := time.Now().In(webTZ)
// 今天 → 「下午 2:35」(无日期)
today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC()
if got, want := shortDate(today), "下午 2:35"; got != want {
t.Fatalf("shortDate(today) = %q, want %q", got, want)
}
// 今年非今天 → 「06-15 上午 9:05」(避开今天,防止午夜跨日抖动)
day := time.Date(now.Year(), 6, 15, 9, 5, 0, 0, webTZ)
if day.YearDay() == now.YearDay() {
day = day.AddDate(0, 0, 1)
}
if got, want := shortDate(day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want {
t.Fatalf("shortDate(thisYear) = %q, want %q", got, want)
}
// 往年 → 「YYYY-MM-DD 上午 9:05」
older := time.Date(now.Year()-1, 12, 1, 9, 5, 0, 0, webTZ).UTC()
if got, want := shortDate(older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want {
t.Fatalf("shortDate(older) = %q, want %q", got, want)
}
}
+39 -3
View File
@@ -185,7 +185,43 @@
2. ~~#2、#3、#4P1~~ 已完成 2026-08-19 2. ~~#2、#3、#4P1~~ 已完成 2026-08-19
3. ~~#5-#11P2~~ 已完成 2026-08-19 3. ~~#5-#11P2~~ 已完成 2026-08-19
4. ~~#12-#16P3~~ 已完成 2026-08-19 4. ~~#12-#16P3~~ 已完成 2026-08-19
5. ~~#17P4)、#18P5,方案 A~~ 已完成 2026-08-20
**全部安全审计项已修复完成。** 剩余建议(非代码项): ## P4 低危:第二轮审计发现(2026-08-208ea4a62..37b4816
- 部署侧:Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达、GitHub 仓库中 3 个 50MB+ 的 exe 文件建议改用 LFS 或删除
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响) ### 17. 手动封禁 Create 非 upsert,与阶段性封禁体系数据错位
- [x] 位置:`internal/web/handlers/admin.go``DisconnectConnection`)、`internal/store/ban_store.go``internal/db/models.go``internal/db/db.go`
- 现状:`f2493da` 阶段性封禁已改为"每 IP 一条记录 upsert"`RecordAuthFailure` 内部 GetByIP + Update),但管理员"断开并封禁"仍直接 `Create``ip_address` 无唯一索引,当目标 IP 已有失败计数记录时会插入**第二条**记录,造成:
- `IncrementFail``First`(默认主键升序)更新**旧行**`GetByIP``Order("id DESC")` 返回**新行** -> 自动封禁的档位判定(BanCount)与失败计数(FailCount)读写错行;
- `UnbanIP` 按 ID 删除一行后另一行仍在,可能出现"解封后仍被旧记录挡住/计数异常"。
- 修复方案:
- [x] BanStore 新增 `BanIP(ip, reason, duration)`:事务内删除该 IP 全部既有记录(兼容历史脏数据)后插入单条封禁记录,计数清零(与"管理员解封清零"语义一致);`DisconnectConnection` 改用该方法。
- [x] `BanEntry.IPAddress` 升级为 `uniqueIndex``InitDB` 在 AutoMigrate 前调用 `dedupeBanEntries` 清理历史重复行(保留每 IP 最大 id,SQLite/MySQL 兼容的派生表写法),表不存在时静默。
- [x] `IncrementFail` 原子化:SQL 侧 `fail_count + 1`miss 时 `OnConflict DoNothing` 插入兜底并发竞态,回读计数。
- 验证:
- [x] 单测:已有观察记录的 IP 手动封禁后仅一条、计数清零、封禁生效(`TestBanIPUpsertSingleRow`)。
- [x] 单测:同 IP 第二条记录被唯一约束拒绝(`TestBanEntryUniqueIndex`)。
- [x] 并发单测(`-race`):16 协程并发 IncrementFail 计数精确无重复行(`TestIncrementFailConcurrent`)。
- [x] db 包单测:旧表重复行清理保留最大 id、表不存在静默(`dedupe_test.go`)。
## P5 备注:产品权衡项(需决策后实施)
### 18. 阶段性封禁"前 3 次触发不封禁"降低爆破门槛
- [x] 位置:`internal/store/auth_guard.go``RecordAuthFailure`)、`internal/store/user_store.go``LoginExists`)、Web/LDAP/SMTP/IMAP/POP3 五处调用点
- 现状:为防误封手机客户端(配置向导探测、裸用户名重试等),达到失败阈值记为一次触发,前 3 次**只计数不封禁**。副作用:攻击者每次触发前可"免费"尝试 `max_fail_attempts`(默认 5)次,即约 **15 次失败尝试零封禁**;第 4 次起才进入 30min -> 3h -> 3 个月 -> 半年的递增档位。长期防护足够,但自动化爆破的起步门槛降低。
- 已实施(方案 A2026-08-20):
- [x] `RecordAuthFailure` 新增 `knownUser bool` 参数:用户名存在(真实用户输错)保留前 3 次宽限;用户名不存在(枚举型爆破)跳过宽限、首次触发即按第 1 档封禁,封禁原因注明"未知用户名,跳过宽限"。
- [x] 新增 `UserStore.LoginExists(login)`(完整邮箱或裸用户名),五个失败调用点按场景传入:Web 登录查邮箱存在性;SMTP/IMAP/POP3 用登录名查;LDAP 侧存在性无法判定,保守按已知用户处理(防误封)。
- 验证:
- [x] A:未知用户名第 1 次触发即封(30 分钟,reason 含"未知用户名")(`TestRecordAuthFailureUnknownUserSkipsGrace`)。
- [x] 已知用户名前 3 次触发不封、第 4 次封第 1 档(回归)(`TestRecordAuthFailureKnownUserKeepsGrace`)。
- [x] `LoginExists` 邮箱/裸用户名/不存在/空输入矩阵(`TestLoginExists`)。
- 决策记录:**方案 A**(按失败性质区分宽限:真实用户防误封,枚举爆破即时封禁)——在不改变正常用户体验的前提下,让针对不存在账号的字典爆破首次达到阈值即被封,兼顾误封防护与爆破门槛。
## 部署侧建议(非代码项)
- Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达。
- GitHub 仓库中 3 个 50MB+ 的 exe 文件(mailgo.exe / mail_go.exe / mailgo_qa.exe)建议改用 Git LFS 或从历史中删除。
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)。