commit 9e50d05e712e9502a2070f24d46ab598676d2c26 Author: 吴文峰 Date: Mon Jun 1 18:59:55 2026 +0800 一阶段ok diff --git a/.workbuddy/memory/2026-06-01.md b/.workbuddy/memory/2026-06-01.md new file mode 100644 index 0000000..32fe6fb --- /dev/null +++ b/.workbuddy/memory/2026-06-01.md @@ -0,0 +1,19 @@ +# 2026-06-01 MailGo 项目开发完成 + +## 完成内容 +- 完整Go邮件系统 MailGo 开发,通过2轮QA验证 +- 核心组件: SMTP(go-smtp) + IMAP(go-imap v1) + POP3(手写TCP) + Web(Gin) +- Web功能: 登录认证、收件箱、草稿箱、发件箱、撰写邮件(含附件)、设置(修改密码)、管理后台(域名/用户/DNS提示) +- 模板架构从base继承模式经3轮迭代重构为自包含+子模板模式 +- 默认管理员: admin@example.com / admin +- Windows测试路径: ./win/etc/mail_go + ./win/srv/mail_go + +## QA结果 +- Round 1: 12/15 通过,3项失败(/drafts和/settings路由缺失,/admin路径误测) +- Round 2: 12/12 全部通过,修复后回归验证完成 + +## 关键修复 +- SMTP ListenAndServeTLS 签名问题 → 创建独立TLS Server实例 +- IMAP v2 beta API不稳定 → 切换v1 +- 模板 {{template .VarName .}} 不支持 → 重构为自包含模式 +- 补充 /drafts、/settings 路由及密码修改功能 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..9090243 --- /dev/null +++ b/.workbuddy/memory/MEMORY.md @@ -0,0 +1,24 @@ +# MailGo 项目记忆 + +## 技术栈 +- Go + Gin + html/template + GORM + SQLite(default)/MySQL +- SMTP: github.com/emersion/go-smtp +- IMAP: github.com/emersion/go-imap v1 (NOT v2 beta) +- POP3: 手写TCP协议实现 +- 配置: TOML (github.com/BurntSushi/toml) +- 会话: github.com/gin-contrib/sessions (cookie store) +- 密码: golang.org/x/crypto/bcrypt + +## 关键架构决策 +- 模板采用自包含+子模板模式(Go html/template不支持变量模板名) +- 每个模板是完整HTML文档,通过 {{template "styles" .}} 和 {{template "navbar" .}} 引入公共部分 +- Handler调用 c.HTML(200, "define_name", data),define_name 对应模板名 +- SMTP TLS: 创建独立Server实例设置 TLSConfig +- Windows路径回退: ./win/etc/mail_go 和 ./win/srv/mail_go +- 默认管理员: admin@example.com / admin + +## 已知坑 +- go-imap v2 是beta,API不稳定,必须用v1 +- Go filepath.Glob 不支持 ** 递归匹配,模板分两轮加载 +- SMTP ListenAndServeTLS() 不接受参数,TLS需通过 TLSConfig 设置 +- User模型密码字段为 PasswordHash,数据库列为 password_hash diff --git a/.workbuddy/overview.md b/.workbuddy/overview.md new file mode 100644 index 0000000..d6a1d5a --- /dev/null +++ b/.workbuddy/overview.md @@ -0,0 +1,69 @@ +# MailGo 交付总览 + +## TL;DR +MailGo 邮件系统开发完成,包含 SMTP/IMAP/POP3 协议服务 + Gin Web 管理界面,2轮QA全部通过。 + +## 交付概览 +- **状态**: ✅ 开发完成,QA验证通过 +- **测试通过率**: 12/12 (Round 2) +- **已知问题**: 0 + +## 文件清单 + +### 配置 +- `config/defaults.go` — 默认路径与端口常量(Linux/Windows双平台) +- `config/config.go` — TOML配置加载+自动补全 + +### 数据库 +- `internal/db/models.go` — GORM模型(User/Domain/Message/Attachment) +- `internal/db/db.go` — 数据库初始化(SQLite/MySQL + AutoMigrate) + +### Store层 +- `internal/store/stores.go` — Stores聚合器 +- `internal/store/user_store.go` — 用户CRUD + UpdatePassword +- `internal/store/mail_store.go` — 邮件CRUD + 文件夹查询 +- `internal/store/domain_store.go` — 域名CRUD +- `internal/store/attachment_store.go` — 附件CRUD + +### 附件存储 +- `internal/storage/attachment.go` — UUID文件名+磁盘读写 + +### 协议服务 +- `internal/smtp_server/server.go` — SMTP服务(go-smtp + TLS) +- `internal/imap_server/server.go` — IMAP服务(go-imap v1) +- `internal/imap_server/backend.go` — IMAP后端实现 +- `internal/pop3_server/server.go` — POP3服务(手写TCP协议) + +### Web服务 +- `internal/web/server.go` — Gin引擎+路由+模板+会话 +- `internal/web/middleware/auth.go` — 认证中间件 +- `internal/web/middleware/admin.go` — 管理员中间件 +- `internal/web/handlers/auth.go` — 登录/登出 +- `internal/web/handlers/mail.go` — 收件箱/草稿/发件/撰写/查看/设置 +- `internal/web/handlers/admin.go` — 管理后台(域名/用户/DNS提示) + +### 模板(自包含+子模板模式) +- `internal/web/templates/base.html` — styles + navbar 子模板 +- `internal/web/templates/login.html` +- `internal/web/templates/inbox.html` +- `internal/web/templates/drafts.html` +- `internal/web/templates/sent.html` +- `internal/web/templates/compose.html` +- `internal/web/templates/view.html` +- `internal/web/templates/settings.html` +- `internal/web/templates/admin/dashboard.html` +- `internal/web/templates/admin/domains.html` +- `internal/web/templates/admin/domain_form.html` +- `internal/web/templates/admin/dns_hint.html` +- `internal/web/templates/admin/users.html` +- `internal/web/templates/admin/user_form.html` + +### 入口 +- `main.go` — 配置加载→DB初始化→Store创建→默认数据→启动四大服务 + +## 用户下一步建议 +1. `go run main.go` 启动服务,访问 http://localhost:8080 +2. 默认管理员: admin@example.com / admin +3. 生产环境需修改 `mail_go.toml` 中的 session secret key +4. 配置 TLS 证书后启用 SMTP/IMAP/POP3 的 TLS 端口 +5. 配置 DNS MX/SPF/DKIM/DMARC 记录(管理后台有提示页面) diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..9827d0f --- /dev/null +++ b/config/config.go @@ -0,0 +1,232 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/BurntSushi/toml" +) + +// DatabaseConfig holds database connection settings. +type DatabaseConfig struct { + Driver string `toml:"driver"` + DSN string `toml:"dsn"` +} + +// StorageConfig holds file storage paths. +type StorageConfig struct { + BaseDir string `toml:"base_dir"` + AttachDir string `toml:"attach_dir"` +} + +// WebConfig holds web server settings. +type WebConfig struct { + Addr string `toml:"addr"` +} + +// SMTPConfig holds SMTP server settings. +type SMTPConfig struct { + Addr string `toml:"addr"` + TLSAddr string `toml:"tls_addr"` + Domain string `toml:"domain"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` + MaxMessage int64 `toml:"max_message_bytes"` +} + +// IMAPConfig holds IMAP server settings. +type IMAPConfig struct { + Addr string `toml:"addr"` + TLSAddr string `toml:"tls_addr"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` +} + +// POP3Config holds POP3 server settings. +type POP3Config struct { + Addr string `toml:"addr"` + TLSAddr string `toml:"tls_addr"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` +} + +// Config is the top-level configuration structure. +type Config struct { + Database DatabaseConfig `toml:"database"` + Storage StorageConfig `toml:"storage"` + Web WebConfig `toml:"web"` + SMTP SMTPConfig `toml:"smtp"` + IMAP IMAPConfig `toml:"imap"` + POP3 POP3Config `toml:"pop3"` +} + +// isWindows returns true if the current OS is Windows. +func isWindows() bool { + return runtime.GOOS == "windows" +} + +// etcDir returns the etc directory based on the current OS. +func etcDir() string { + if isWindows() { + return WinEtcDir + } + return LinuxEtcDir +} + +// baseDir returns the base data directory based on the current OS. +func baseDir() string { + if isWindows() { + return WinBaseDir + } + return LinuxBaseDir +} + +// defaultDSN returns the default database DSN based on the current OS. +func defaultDSN() string { + if isWindows() { + return DefaultDSNWin + } + return DefaultDSNLinux +} + +// defaultConfig returns a fully populated Config with default values. +func defaultConfig() *Config { + bd := baseDir() + return &Config{ + Database: DatabaseConfig{ + Driver: DefaultDBDriver, + DSN: defaultDSN(), + }, + Storage: StorageConfig{ + BaseDir: bd, + AttachDir: filepath.Join(bd, "attachments"), + }, + Web: WebConfig{ + Addr: DefaultWebPort, + }, + SMTP: SMTPConfig{ + Addr: fmt.Sprintf(":%d", DefaultSMTPPort), + TLSAddr: fmt.Sprintf(":%d", DefaultSMTPTLSPort), + Domain: "localhost", + MaxMessage: 64 * 1024 * 1024, // 64MB + }, + IMAP: IMAPConfig{ + Addr: fmt.Sprintf(":%d", DefaultIMAPPort), + TLSAddr: fmt.Sprintf(":%d", DefaultIMAPTLSPort), + }, + POP3: POP3Config{ + Addr: fmt.Sprintf(":%d", DefaultPOP3Port), + TLSAddr: fmt.Sprintf(":%d", DefaultPOP3TLSPort), + }, + } +} + +// configFilePath returns the full path to the configuration file. +func configFilePath() string { + return filepath.Join(etcDir(), ConfigFileName) +} + +// mergeDefaults overlays default values onto the loaded config for any zero/empty fields. +func mergeDefaults(cfg *Config, defaults *Config) *Config { + if cfg.Database.Driver == "" { + cfg.Database.Driver = defaults.Database.Driver + } + if cfg.Database.DSN == "" { + cfg.Database.DSN = defaults.Database.DSN + } + if cfg.Storage.BaseDir == "" { + cfg.Storage.BaseDir = defaults.Storage.BaseDir + } + if cfg.Storage.AttachDir == "" { + cfg.Storage.AttachDir = defaults.Storage.AttachDir + } + if cfg.Web.Addr == "" { + cfg.Web.Addr = defaults.Web.Addr + } + if cfg.SMTP.Addr == "" { + cfg.SMTP.Addr = defaults.SMTP.Addr + } + if cfg.SMTP.TLSAddr == "" { + cfg.SMTP.TLSAddr = defaults.SMTP.TLSAddr + } + if cfg.SMTP.Domain == "" { + cfg.SMTP.Domain = defaults.SMTP.Domain + } + if cfg.SMTP.MaxMessage == 0 { + cfg.SMTP.MaxMessage = defaults.SMTP.MaxMessage + } + if cfg.IMAP.Addr == "" { + cfg.IMAP.Addr = defaults.IMAP.Addr + } + if cfg.IMAP.TLSAddr == "" { + cfg.IMAP.TLSAddr = defaults.IMAP.TLSAddr + } + if cfg.POP3.Addr == "" { + cfg.POP3.Addr = defaults.POP3.Addr + } + if cfg.POP3.TLSAddr == "" { + cfg.POP3.TLSAddr = defaults.POP3.TLSAddr + } + return cfg +} + +// writeConfig writes the configuration to the given file path. +// It creates the parent directories if they don't exist. +func writeConfig(path string, cfg *Config) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("创建配置目录失败 %s: %w", dir, err) + } + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("创建配置文件失败 %s: %w", path, err) + } + defer f.Close() + + enc := toml.NewEncoder(f) + if err := enc.Encode(cfg); err != nil { + return fmt.Errorf("写入配置文件失败: %w", err) + } + return nil +} + +// LoadConfig loads the configuration from disk. +// If the configuration file does not exist, it creates one with default values. +// If the file exists but has missing fields, they are filled with defaults and the file is updated. +func LoadConfig() (*Config, error) { + path := configFilePath() + defaults := defaultConfig() + + // If config file doesn't exist, create it with defaults + if _, err := os.Stat(path); os.IsNotExist(err) { + if mkErr := writeConfig(path, defaults); mkErr != nil { + return nil, mkErr + } + return defaults, nil + } + + // Read existing config file + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取配置文件失败 %s: %w", path, err) + } + + cfg := &Config{} + if err := toml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + + // Merge defaults for any missing fields + merged := mergeDefaults(cfg, defaults) + + // Write back if any fields were filled in from defaults + // (always write back to ensure the file has all fields) + if writeErr := writeConfig(path, merged); writeErr != nil { + return nil, writeErr + } + + return merged, nil +} diff --git a/config/defaults.go b/config/defaults.go new file mode 100644 index 0000000..af3d959 --- /dev/null +++ b/config/defaults.go @@ -0,0 +1,39 @@ +package config + +// Linux path prefixes +const ( + LinuxEtcDir = "/etc/mail_go/" + LinuxBaseDir = "/srv/mail_go/" +) + +// Windows path prefixes +const ( + WinEtcDir = "./win/etc/mail_go/" + WinBaseDir = "./win/srv/mail_go/" +) + +// Default port constants +const ( + DefaultSMTPPort = 25 + DefaultSMTPTLSPort = 465 + DefaultIMAPPort = 143 + DefaultIMAPTLSPort = 993 + DefaultPOP3Port = 110 + DefaultPOP3TLSPort = 995 + DefaultWebPort = ":8080" +) + +// Default database settings +const ( + DefaultDBDriver = "sqlite" + DefaultDSNLinux = "/srv/mail_go/mail.db" + DefaultDSNWin = "./win/srv/mail_go/mail.db" +) + +// Default quota +const ( + DefaultQuotaBytes int64 = 5 * 1024 * 1024 * 1024 // 5GB +) + +// ConfigFileName is the name of the configuration file +const ConfigFileName = "mail_go.toml" diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..21eb309 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,721 @@ +# MailGo 系统设计文档 + +> **项目**:mail_go —— 自托管 Go 邮件系统 +> **架构师**:高见远 +> **版本**:v1.0 +> **日期**:2025-07 + +--- + +## 1. 实现方案分析 + +### 1.1 核心技术挑战 + +| 挑战 | 说明 | 应对策略 | +|------|------|----------| +| SMTP/IMAP/POP3 三协议并行 | 三个邮件协议服务需同时运行、共享同一套用户与邮件数据 | 统一 Backend 接口层,三个协议服务均委托给同一 `MailStore` + `UserStore` | +| 邮件解析与持久化 | go-message 解析 MIME 后需拆分为元数据 + 正文 + 附件 | 统一 `MessageParser` 服务,解析后原子写入 DB + 文件系统 | +| IMAP 文件夹/标记/搜索 | go-imap/v2 需实现完整的 Backend / User / Mailbox / Message 接口 | 采用"数据库为单一真相源"架构,IMAP 操作全部映射到 GORM 查询 | +| 跨平台路径 | Linux/Windows 配置与数据路径不同 | `config` 层根据 `runtime.GOOS` 自动映射路径前缀 | +| 附件存储一致性 | 附件写入文件系统后需与 DB 记录对齐 | 事务内写 DB + 事后写文件,失败时异步清理孤儿文件 | +| Web 认证与权限 | 普通用户 vs 管理员,Session 管理 | Gin 中间件:`AuthMiddleware`(登录检查)+ `AdminMiddleware`(管理员检查) | + +### 1.2 框架与库选型 + +| 组件 | 选型 | 理由 | +|------|------|------| +| SMTP 服务端 | `github.com/emersion/go-smtp` | PRD 指定,成熟稳定 | +| IMAP 服务端 | `github.com/emersion/go-imap/v2` | PRD 指定,v2 API 更现代 | +| POP3 服务端 | 手工实现(TCP 监听 + 文本协议) | emersion 系列无官方 go-pop3,POP3 协议简单,自实现可控 | +| 邮件解析 | `github.com/emersion/go-message` | 与 go-smtp/go-imap 生态一致 | +| Web 框架 | `gin-gonic/gin` | PRD 指定,高性能 | +| 模板引擎 | `html/template` | Go 标准库,自动转义防 XSS | +| ORM | `gorm.io/gorm` + `gorm.io/driver/sqlite` + `gorm.io/driver/mysql` | PRD 指定,双数据库支持 | +| 配置解析 | `github.com/BurntSushi/toml` | TOML 格式标准库 | +| 密码哈希 | `golang.org/x/crypto/bcrypt` | PRD 指定 | +| UUID | `github.com/google/uuid` | PRD 指定,用于附件文件名等 | +| Session | `github.com/gin-contrib/sessions` + cookie store | 轻量,无需 Redis | + +### 1.3 架构模式 + +采用 **分层架构 + 共享 Store 层**: + +``` +┌──────────────────────────────────────────────┐ +│ main.go (启动入口) │ +├──────────┬──────────┬──────────┬───────────────┤ +│ SMTP │ IMAP │ POP3 │ Web (Gin) │ +│ Server │ Server │ Server │ Router │ +├──────────┴──────────┴──────────┴───────────────┤ +│ Store Layer (共享) │ +│ UserStore │ MailStore │ DomainStore │ AttachmentStore │ +├────────────────────────────────────────────────┤ +│ GORM (SQLite / MySQL) │ +└────────────────────────────────────────────────┘ +``` + +- **协议层**(SMTP/IMAP/POP3)仅负责协议握手,业务逻辑委托 Store 层 +- **Web 层**(Gin + html/template)负责 UI 渲染和 HTTP API +- **Store 层**封装所有数据库操作,是唯一的数据访问入口 +- **Config** 在启动时加载为全局单例,通过依赖注入传递给各 Server + +--- + +## 2. 项目文件列表 + +``` +mail_go/ +├── main.go # 程序入口:配置加载 → DB 初始化 → 各服务启动 +├── go.mod # Go 模块定义 +├── go.sum # 依赖校验 +├── config/ +│ ├── config.go # 配置结构体 + 加载 + 自动补全逻辑 +│ └── defaults.go # 默认值常量(端口、路径等) +├── internal/ +│ ├── db/ +│ │ ├── db.go # GORM 初始化 + AutoMigrate +│ │ └── models.go # User / Domain / Message / Attachment 模型 +│ ├── store/ +│ │ ├── user_store.go # 用户 CRUD + 认证 +│ │ ├── mail_store.go # 邮件 CRUD + 文件夹查询 +│ │ ├── domain_store.go # 域名 CRUD +│ │ └── attachment_store.go # 附件元数据 CRUD +│ ├── smtp_server/ +│ │ └── server.go # SMTP 服务端(go-smtp Backend 实现) +│ ├── imap_server/ +│ │ ├── server.go # IMAP 服务端启动 +│ │ └── backend.go # go-imap Backend/User/Mailbox/Message 实现 +│ ├── pop3_server/ +│ │ └── server.go # POP3 服务端(TCP 监听 + 文本协议) +│ ├── web/ +│ │ ├── server.go # Gin 引擎初始化 + 路由注册 +│ │ ├── middleware/ +│ │ │ ├── auth.go # 登录认证中间件 +│ │ │ └── admin.go # 管理员鉴权中间件 +│ │ ├── handlers/ +│ │ │ ├── auth.go # 登录/登出处理 +│ │ │ ├── mail.go # 收件箱/发件箱/撰写/查看 +│ │ │ └── admin.go # 管理后台处理 +│ │ └── templates/ +│ │ ├── base.html # 基础布局模板 +│ │ ├── login.html # 登录页 +│ │ ├── inbox.html # 收件箱 +│ │ ├── compose.html # 撰写邮件 +│ │ ├── sent.html # 发件箱 +│ │ ├── view.html # 邮件阅读 +│ │ └── admin/ +│ │ ├── dashboard.html # 管理首页 +│ │ ├── domains.html # 域名管理 +│ │ └── users.html # 用户管理 +│ └── storage/ +│ └── attachment.go # 附件文件读写(磁盘操作) +├── scripts/ +│ └── install.sh # Linux 安装脚本(创建目录、设置权限) +└── docs/ + ├── prd.md + └── architecture.md # 本文档 +``` + +--- + +## 3. 数据结构与接口 + +### 3.1 数据库模型(GORM) + +```mermaid +classDiagram + class User { + +uint ID + +string Username + +string PasswordHash + +uint DomainID + +Domain Domain + +int64 QuotaBytes + +int64 UsedBytes + +bool IsActive + +bool IsAdmin + +time CreatedAt + +time UpdatedAt + } + + class Domain { + +uint ID + +string Name + +int SmtpPort + +int ImapPort + +int Pop3Port + +string TlsCertPath + +string TlsKeyPath + +bool TlsEnabled + +time CreatedAt + +time UpdatedAt + } + + class Message { + +uint ID + +uint UserID + +User User + +string MessageID + +string Folder + +string FromAddr + +string ToAddr + +string CcAddr + +string Subject + +string TextBody + +string HtmlBody + +bool IsRead + +bool IsFlagged + +time Date + +time CreatedAt + } + + class Attachment { + +uint ID + +uint MessageID + +Message Message + +string FileName + +string FilePath + +string ContentType + +int64 FileSize + +time CreatedAt + } + + Domain "1" --> "*" User : has + User "1" --> "*" Message : owns + Message "1" --> "*" Attachment : has +``` + +#### 详细字段说明 + +**User** + +| 字段 | 类型 | 说明 | +|------|------|------| +| ID | uint | 主键 | +| Username | string | 用户名(@ 前部分) | +| PasswordHash | string | bcrypt 哈希 | +| DomainID | uint | 所属域名外键 | +| Domain | Domain | 所属域名 | +| QuotaBytes | int64 | 附件配额(字节),默认 5GB | +| UsedBytes | int64 | 已用空间(字节) | +| IsActive | bool | 是否启用 | +| IsAdmin | bool | 是否管理员 | +| CreatedAt | time.Time | 创建时间 | +| UpdatedAt | time.Time | 更新时间 | + +**Domain** + +| 字段 | 类型 | 说明 | +|------|------|------| +| ID | uint | 主键 | +| Name | string | 域名(如 example.com) | +| SmtpPort | int | SMTP 端口 | +| ImapPort | int | IMAP 端口 | +| Pop3Port | int | POP3 端口 | +| TlsCertPath | string | TLS 证书路径 | +| TlsKeyPath | string | TLS 私钥路径 | +| TlsEnabled | bool | 是否启用 TLS | +| CreatedAt | time.Time | 创建时间 | +| UpdatedAt | time.Time | 更新时间 | + +**Message** + +| 字段 | 类型 | 说明 | +|------|------|------| +| ID | uint | 主键 | +| UserID | uint | 所属用户外键 | +| User | User | 所属用户 | +| MessageID | string | RFC 2822 Message-ID | +| Folder | string | 文件夹:INBOX / Sent / Drafts / Trash | +| FromAddr | string | 发件人地址 | +| ToAddr | string | 收件人地址(逗号分隔) | +| CcAddr | string | 抄送地址(逗号分隔) | +| Subject | string | 主题 | +| TextBody | string | 纯文本正文 | +| HtmlBody | string | HTML 正文 | +| IsRead | bool | 是否已读 | +| IsFlagged | bool | 是否标记 | +| Date | time.Time | 邮件日期 | +| CreatedAt | time.Time | 入库时间 | + +**Attachment** + +| 字段 | 类型 | 说明 | +|------|------|------| +| ID | uint | 主键 | +| MessageID | uint | 所属邮件外键 | +| Message | Message | 所属邮件 | +| FileName | string | 原始文件名 | +| FilePath | string | 磁盘相对路径 | +| ContentType | string | MIME 类型 | +| FileSize | int64 | 文件大小(字节) | +| CreatedAt | time.Time | 创建时间 | + +### 3.2 核心接口设计 + +#### Config 结构体 + +```go +// config/config.go + +type Config struct { + Database DatabaseConfig + Storage StorageConfig + Web WebConfig + SMTP SMTPConfig + IMAP IMAPConfig + POP3 POP3Config +} + +type DatabaseConfig struct { + Driver string // "sqlite" | "mysql" + DSN string +} + +type StorageConfig struct { + BaseDir string + AttachDir string +} + +type WebConfig struct { + Listen string // ":8080" 或 Unix socket 路径 +} + +type SMTPConfig struct { + Port int + TLSPort int + Cert string + Key string +} + +type IMAPConfig struct { + Port int + TLSPort int + Cert string + Key string +} + +type POP3Config struct { + Port int + TLSPort int + Cert string + Key string +} +``` + +#### Store 接口 + +```go +// internal/store/ + +type UserStore interface { + Create(user *models.User) error + GetByID(id uint) (*models.User, error) + GetByUsername(username string, domainID uint) (*models.User, error) + GetByEmail(email string) (*models.User, error) + Authenticate(email, password string) (*models.User, error) + Update(user *models.User) error + Delete(id uint) error + List(domainID uint, page, size int) ([]models.User, int64, error) + UpdateUsedBytes(id uint, delta int64) error +} + +type MailStore interface { + Create(msg *models.Message) error + GetByID(id uint) (*models.Message, error) + ListByUserAndFolder(userID uint, folder string, page, size int) ([]models.Message, int64, error) + MarkRead(id uint) error + MarkFlagged(id uint, flagged bool) error + MoveToFolder(id uint, folder string) error + Delete(id uint) error + CountUnread(userID uint, folder string) (int64, error) +} + +type DomainStore interface { + Create(domain *models.Domain) error + GetByID(id uint) (*models.Domain, error) + GetByName(name string) (*models.Domain, error) + Update(domain *models.Domain) error + Delete(id uint) error + List(page, size int) ([]models.Domain, int64, error) +} + +type AttachmentStore interface { + Create(att *models.Attachment) error + ListByMessage(messageID uint) ([]models.Attachment, error) + Delete(id uint) error + DeleteByMessage(messageID uint) error +} +``` + +#### SMTP Backend 接口(go-smtp 要求) + +```go +// internal/smtp_server/server.go + +// 实现 go-smtp 的 Backend 接口 +type smtpBackend struct { + userStore store.UserStore + mailStore store.MailStore + domainStore store.DomainStore + attStore store.AttachmentStore + storage *storage.AttachmentStorage +} + +func (b *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) +func (s *smtpSession) AuthPlain(username, password string) error +func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error +func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error +func (s *smtpSession) Data(r io.Reader) error +func (s *smtpSession) Logout() error +``` + +#### IMAP Backend 接口(go-imap/v2 要求) + +```go +// internal/imap_server/backend.go + +// 实现 go-imap/v2 的 backend.Backend 接口 +type imapBackend struct { + userStore store.UserStore + mailStore store.MailStore + domainStore store.DomainStore + attStore store.AttachmentStore +} + +func (b *imapBackend) Login(username, password string) (backend.User, error) + +// imapUser 实现 backend.User +type imapUser struct { + user *models.User + store *mailStoreGorm +} + +func (u *imapUser) ListMailboxes(subscribed bool) ([]imap.Mailbox, error) +func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) + +// imapMailbox 实现 backend.Mailbox +type imapMailbox struct { + user *models.User + folder string + store *mailStoreGorm +} + +// imapMessage 实现 backend.Message +``` + +--- + +## 4. Web 路由表 + +| Method | Path | Handler | 中间件 | 说明 | +|--------|------|---------|--------|------| +| GET | /login | auth.ShowLogin | — | 登录页 | +| POST | /login | auth.DoLogin | — | 登录提交 | +| POST | /logout | auth.DoLogout | Auth | 登出 | +| GET | / | mail.Inbox | Auth | 收件箱(重定向到 /inbox) | +| GET | /inbox | mail.Inbox | Auth | 收件箱列表 | +| GET | /inbox/:id | mail.View | Auth | 查看邮件 | +| GET | /compose | mail.Compose | Auth | 撰写页面 | +| POST | /compose | mail.DoSend | Auth | 发送邮件 | +| GET | /sent | mail.Sent | Auth | 发件箱 | +| GET | /sent/:id | mail.View | Auth | 查看已发送邮件 | +| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件 | +| POST | /mail/read/:id | mail.MarkRead | Auth | 标记已读 | +| GET | /attachment/:id | mail.DownloadAttachment | Auth | 下载附件 | +| GET | /admin | admin.Dashboard | Auth + Admin | 管理后台首页 | +| GET | /admin/domains | admin.ListDomains | Auth + Admin | 域名列表 | +| GET | /admin/domains/new | admin.NewDomain | Auth + Admin | 新增域名页 | +| POST | /admin/domains | admin.CreateDomain | Auth + Admin | 创建域名 | +| POST | /admin/domains/:id/delete | admin.DeleteDomain | Auth + Admin | 删除域名 | +| GET | /admin/domains/:id/dns | admin.DNSHint | Auth + Admin | DNS 配置提示 | +| GET | /admin/users | admin.ListUsers | Auth + Admin | 用户列表 | +| GET | /admin/users/new | admin.NewUser | Auth + Admin | 新增用户页 | +| POST | /admin/users | admin.CreateUser | Auth + Admin | 创建用户 | +| POST | /admin/users/:id/delete | admin.DeleteUser | Auth + Admin | 删除用户 | +| GET | /admin/users/:id/edit | admin.EditUser | Auth + Admin | 编辑用户页 | +| POST | /admin/users/:id | admin.UpdateUser | Auth + Admin | 更新用户 | + +--- + +## 5. 程序调用流程 + +### 5.1 启动流程 + +```mermaid +sequenceDiagram + participant Main as main.go + participant Cfg as config + participant DB as db + participant Store as Store Layer + participant SMTP as SMTP Server + participant IMAP as IMAP Server + participant POP3 as POP3 Server + participant Web as Web Server (Gin) + + Main->>Cfg: LoadConfig("config.toml") + Cfg->>Cfg: 检测文件是否存在 + alt 配置文件不存在 + Cfg->>Cfg: 生成默认配置并写入文件 + else 配置文件存在但字段缺失 + Cfg->>Cfg: 补全缺失字段为默认值 + end + Cfg-->>Main: 返回 *Config + + Main->>DB: InitDB(cfg.Database) + DB->>DB: 根据 Driver 选择 SQLite/MySQL + DB->>DB: gorm.Open(...) + DB->>DB: db.AutoMigrate(User, Domain, Message, Attachment) + DB-->>Main: 返回 *gorm.DB + + Main->>Store: NewStoreLayer(db, cfg.Storage) + Store-->>Main: 返回 Store 实例集合 + + Main->>Store: 检查是否存在 admin 用户 + alt 无 admin 用户 + Main->>Store: 创建默认 admin 账户 + end + + par 并行启动服务 + Main->>SMTP: NewSMTPServer(cfg.SMTP, stores) + SMTP->>SMTP: smtp.NewServer(backend) + SMTP->>SMTP: 监听 Port + TLSPort + and + Main->>IMAP: NewIMAPServer(cfg.IMAP, stores) + IMAP->>IMAP: imapserver.New(backend) + IMAP->>IMAP: 监听 Port + TLSPort + and + Main->>POP3: NewPOP3Server(cfg.POP3, stores) + POP3->>POP3: net.Listen + 协议循环 + POP3->>POP3: 监听 Port + TLSPort + and + Main->>Web: NewWebServer(cfg.Web, stores) + Web->>Web: 注册路由 + 中间件 + Web->>Web: gin.Run(cfg.Web.Listen) + end +``` + +### 5.2 用户收信流程(SMTP 入站 → Web 展示) + +```mermaid +sequenceDiagram + participant Remote as 远程 MTA + participant SMTP as SMTP Server + participant Parser as MessageParser + participant MS as MailStore + participant AS as AttachmentStore + participant FS as FileSystem + participant User as 浏览器用户 + participant Web as Web Server (Gin) + + Remote->>SMTP: TCP 连接 + EHLO/MAIL FROM/RCPT TO + SMTP->>SMTP: 验证收件人域名是否在本系统 + SMTP->>SMTP: 查找对应用户 + Remote->>SMTP: DATA (邮件内容) + SMTP->>Parser: ParseMIME(r io.Reader) + Parser->>Parser: go-message 解析 MIME + Parser-->>SMTP: 返回 ParsedMessage{From, To, Subject, TextBody, HtmlBody, Attachments} + + SMTP->>MS: Create(Message{Folder:"INBOX", ...}) + MS->>MS: GORM INSERT messages + + loop 每个附件 + SMTP->>AS: Create(Attachment{FileName, ContentType, Size}) + AS->>AS: GORM INSERT attachments + SMTP->>FS: WriteFile(attachmentsDir/uuid.ext, data) + end + + SMTP-->>Remote: 250 OK + + Note over User,Web: 用户稍后登录 Web 查看邮件 + + User->>Web: GET /inbox + Web->>Web: AuthMiddleware 检查 Session + Web->>MS: ListByUserAndFolder(userID, "INBOX", page, size) + MS-->>Web: []Message + total + Web->>Web: 渲染 inbox.html + Web-->>User: 收件箱页面 + + User->>Web: GET /inbox/42 + Web->>MS: GetByID(42) + MS-->>Web: Message 详情 + Web->>AS: ListByMessage(42) + AS-->>Web: []Attachment + Web->>Web: 渲染 view.html + Web-->>User: 邮件阅读页 + + User->>Web: GET /attachment/7 + Web->>AS: GetByID(7) → FilePath + Web->>FS: OpenFile(attachmentsDir/filepath) + Web-->>User: 文件下载流 +``` + +--- + +## 6. 有序任务列表 + +| Task ID | 任务名称 | 依赖 | 涉及文件 | 优先级 | +|---------|---------|------|---------|--------| +| T01 | 项目基础设施:go.mod + 入口 + 配置系统 + 数据库层 | — | go.mod, main.go, config/config.go, config/defaults.go, internal/db/db.go, internal/db/models.go, internal/store/*.go | P0 | +| T02 | 邮件协议服务端(SMTP + IMAP + POP3) | T01 | internal/smtp_server/server.go, internal/imap_server/server.go, internal/imap_server/backend.go, internal/pop3_server/server.go | P0 | +| T03 | Web 服务核心:路由 + 中间件 + 认证 + 邮件页面 | T01 | internal/web/server.go, internal/web/middleware/auth.go, internal/web/middleware/admin.go, internal/web/handlers/auth.go, internal/web/handlers/mail.go | P0 | +| T04 | 管理后台 + 附件存储 + 模板 | T01, T03 | internal/web/handlers/admin.go, internal/storage/attachment.go, internal/web/templates/*.html | P0 | +| T05 | 集成调试 + 安装脚本 | T02, T03, T04 | main.go(更新), scripts/install.sh | P1 | + +### 任务详细说明 + +**T01: 项目基础设施** + +- 初始化 go.mod,声明所有依赖 +- 实现 `config.LoadConfig()`:读取 TOML → 缺失字段补全默认值 → 写回文件 +- 实现 `config` 中的路径映射:Linux 用 `/etc/mail_go/` + `/srv/mail_go/`,Windows 用 `./win/etc/mail_go/` + `./win/srv/mail_go/` +- 实现 `db.InitDB()`:根据 driver 选择 SQLite/MySQL,执行 AutoMigrate +- 定义所有 GORM 模型:User, Domain, Message, Attachment +- 实现 Store 层所有接口的 GORM 实现 +- main.go 骨架:加载配置 → 初始化 DB → 创建 Store → 首次启动生成 admin + +**T02: 邮件协议服务端** + +- SMTP:实现 go-smtp 的 Backend/Session 接口,收信后调用 MailStore 写入 +- IMAP:实现 go-imap/v2 的 Backend/User/Mailbox 接口,查询 MailStore +- POP3:TCP 监听 + 文本协议实现(USER/PASS/STAT/LIST/RETR/DELE/QUIT),查询 MailStore +- 三个服务均接受 Store 实例作为构造参数 + +**T03: Web 服务核心** + +- Gin 引擎初始化 + Session 中间件配置 +- AuthMiddleware:检查 Session 中的 userID,未登录重定向 /login +- AdminMiddleware:检查用户 IsAdmin 字段 +- 登录/登出 handler:bcrypt 校验 + Session 写入 +- 收件箱/发件箱/撰写 handler:渲染模板 + Store 调用 +- 发送邮件 handler:构造 MIME 消息,通过本地 SMTP 发送 + +**T04: 管理后台 + 附件存储 + 模板** + +- 管理后台 handler:域名 CRUD、用户 CRUD、DNS 提示 +- AttachmentStorage:附件文件写入/读取/删除磁盘操作 +- 所有 HTML 模板:base 布局 + login/inbox/compose/sent/view/admin 系列页面 +- 附件上传(compose 页面多文件上传)+ 下载 handler + +**T05: 集成调试 + 安装脚本** + +- main.go 更新:并行启动 SMTP/IMAP/POP3/Web 四个服务,graceful shutdown +- 端汇总测试:SMTP → Store → Web 展示 全链路 +- install.sh:Linux 下创建 /etc/mail_go/ /srv/mail_go/ 目录、设置权限 + +--- + +## 7. 依赖包列表(go.mod 草稿) + +``` +module mail_go + +go 1.22 + +require ( + github.com/emersion/go-smtp v0.21.0 + github.com/emersion/go-imap/v2 v2.0.0-beta.5 + github.com/emersion/go-message v0.18.0 + github.com/gin-gonic/gin v1.10.0 + github.com/gin-contrib/sessions v0.0.5 + github.com/BurntSushi/toml v1.3.2 + gorm.io/gorm v1.25.12 + gorm.io/driver/sqlite v1.5.6 + gorm.io/driver/mysql v1.5.7 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.25.0 +) +``` + +> 版本号为撰写时最新稳定版参考,实际以 `go get` 拉取为准。 + +--- + +## 8. 跨文件共享约定(Shared Knowledge) + +### 8.1 配置传递 + +- `main.go` 加载 `Config` 单例,通过构造函数注入到各 Server +- 所有 Server 签名统一为 `NewXxxServer(cfg XxxConfig, stores *Stores, ...) *XxxServer` +- 禁止在 Store/Handler 层直接读取配置文件,一律通过参数传递 + +### 8.2 Session 约定 + +- Session 名称:`mail_go_session` +- 登录后写入 Session 的 key: + - `userID` → uint + - `userEmail` → string(完整邮箱地址,如 admin@example.com) + - `isAdmin` → bool +- Session 使用 Cookie 存储,`MaxAge = 86400`(24 小时) +- Cookie `HttpOnly = true`, `SameSite = Lax` + +### 8.3 错误处理 + +- Store 层错误统一返回 Go `error`,不做日志输出 +- Web Handler 层捕获 Store 错误后: + - 5xx → 渲染 `base.html` 中的错误提示区 + - 4xx → 重定向或渲染对应页面并显示错误消息 +- SMTP/IMAP/POP3 协议层错误直接返回协议规定的错误码 + +### 8.4 邮件地址规范 + +- 系统内部统一使用完整邮箱地址(user@domain.com)标识用户 +- `User.Username` 仅存储 @ 前部分 +- 查找用户时通过 `User.Username + Domain.Name` 组合查询 +- `Message.FromAddr` / `ToAddr` / `CcAddr` 均为完整邮箱地址 + +### 8.5 文件夹命名 + +| 内部名称 | IMAP 映射 | 说明 | +|----------|----------|------| +| INBOX | INBOX | 收件箱 | +| Sent | Sent Messages | 发件箱 | +| Drafts | Drafts | 草稿箱 | +| Trash | Trash | 废纸篓 | + +### 8.6 附件存储路径 + +- 附件存储根目录:`cfg.Storage.AttachDir` +- 单个附件路径:`{AttachDir}/{uuid}{ext}`,其中 uuid 由 `google/uuid` 生成,ext 保留原始扩展名 +- `Attachment.FilePath` 存储相对路径(不含 AttachDir 前缀) + +### 8.7 数据库约定 + +- 所有表名使用蛇形复数:`users`, `domains`, `messages`, `attachments` +- 时间字段统一使用 `time.Time`(UTC),GORM 自动管理 `created_at` / `updated_at` +- 软删除暂不实现,删除操作为硬删除 +- 分页统一参数:`page`(从 1 开始)、`size`(默认 20) + +--- + +## 9. 任务依赖图 + +```mermaid +graph LR + T01[T01: 项目基础设施] --> T02[T02: 邮件协议服务端] + T01 --> T03[T03: Web 服务核心] + T01 --> T04[T04: 管理后台+附件+模板] + T03 --> T04 + T02 --> T05[T05: 集成调试+安装脚本] + T03 --> T05 + T04 --> T05 +``` + +> T02 与 T03 可并行开发(仅依赖 T01),T04 依赖 T03(管理后台 handler 需要 Web 框架),T05 为最终集成。 + +--- + +## 10. 待明确事项 + +| # | 问题 | 假设 | 影响范围 | +|---|------|------|----------| +| 1 | DKIM 私钥由系统自动生成还是管理员手动导入?(PRD OQ-1) | 第一期仅管理员手动导入,/admin 域名配置页提供私钥文件路径输入框 | admin handler, Domain 模型 | +| 2 | 是否支持邮件转发规则?(PRD OQ-2) | 第一期不支持,SMTP 收信仅投递到本地用户 | smtp_server | +| 3 | 附件大小上限由全局配置还是按用户配额?(PRD OQ-3) | 第一期采用全局配置 + 用户配额双重限制:单文件上限 25MB(硬编码),总空间不超过用户 QuotaBytes | compose handler, AttachmentStorage | +| 4 | Web 撰写是否支持富文本?(PRD OQ-4) | 第一期仅支持纯文本(textarea),后续可引入 Markdown 或 TinyMCE | compose.html, mail handler | +| 5 | 多域名是否纳入第一期?(PRD OQ-5) | 第一期支持多域名(Domain 表设计已包含),但 DNS 提示仅展示通用模板 | admin handler, DNS 提示 | +| 6 | 是否支持 OAuth2/LDAP 认证?(PRD OQ-6) | 第一期仅本地账户认证,bcrypt 密码校验 | auth handler | +| 7 | go-imap/v2 仍为 beta 版本,API 可能变更 | 锁定特定 commit hash,在 go.mod 中 replace 指向稳定版本 | go.mod, imap_server | +| 8 | POP3 协议实现范围 | 实现 USER/PASS/STAT/LIST/RETR/DELE/NOOP/RSET/QUIT 基本命令,不支持 APOP/UIDL | pop3_server | +| 9 | 首次启动 admin 账户的默认密码 | `admin`(首次登录后强制修改,或 /admin 页面提示修改) | main.go | +| 10 | Windows 上是否支持 Unix Socket | 不支持,Windows 下 `web.listen` 仅支持 TCP 端口格式 `:8080` | config, web server | diff --git a/docs/prd.md b/docs/prd.md new file mode 100644 index 0000000..aee218c --- /dev/null +++ b/docs/prd.md @@ -0,0 +1,157 @@ +# PRD · MailGo 邮件系统 + +- **项目名称**:mail_go +- **编程语言**:Go +- **核心依赖**:go-smtp / go-imap / go-pop3(emersion 系列)+ Gin + html/template +- **文档版本**:v0.1 +- **作者**:许清楚(PM) + +--- + +## 一、原始需求 + +用 Go 语言开发一套完整的自托管邮件系统,包含 SMTP/IMAP/POP3 服务端、Web 管理后台(/admin)和用户邮箱界面(/),配置遵循 FHS 标准,支持 SQLite/MySQL 双数据库,支持 Linux 生产环境与 Windows 本地测试。 + +--- + +## 二、产品目标 + +1. **可自托管的完整邮件服务**:提供生产可用的 SMTP/IMAP/POP3 服务端,开箱即用,无需依赖第三方邮件服务商。 +2. **低门槛运维管理**:通过 Web 管理后台完成域名配置、用户管理、DNS 指引,运维人员无需手动编辑配置文件。 +3. **标准化配置与数据存储**:严格遵循 FHS 目录规范,配置自动补全,支持 Linux/Windows 双环境,降低部署与迁移成本。 + +--- + +## 三、用户故事 + +| # | 角色 | 需求 | 价值 | +|---|------|------|------| +| US-1 | 系统管理员 | 通过 /admin 配置域名、端口和 TLS 证书 | 无需 SSH 手动改配置即可上线邮件服务 | +| US-2 | 系统管理员 | 创建/删除/修改域名下的邮件用户并设置附件配额 | 集中管理账户,控制存储资源占用 | +| US-3 | 普通用户 | 登录后在浏览器中收发邮件并上传附件 | 随时随地通过 Web 界面使用邮箱 | +| US-4 | 普通用户 | 用标准邮件客户端(IMAP/POP3/SMTP)连接账户 | 继续使用 Outlook、Thunderbird 等熟悉工具 | +| US-5 | 系统管理员 | 启动时配置文件缺失项自动补全,并在 /admin 查看 DNS 配置提示 | 减少初始化错误,快速完成 DNS 配置 | + +--- + +## 四、功能需求池 + +### P0 · 必须实现 + +| ID | 功能 | 说明 | +|----|------|------| +| P0-01 | SMTP 服务端 | 基于 go-smtp,支持收信/转发,TLS 可配置 | +| P0-02 | IMAP 服务端 | 基于 go-imap,支持标准 IMAP4 操作(文件夹、标记、搜索) | +| P0-03 | POP3 服务端 | 基于 go-pop3,支持标准 POP3 收信 | +| P0-04 | 配置文件自动补全 | 启动时检查 `/etc/mail_go/`(Windows:`./win/etc/mail_go/`),缺失项写入默认值 | +| P0-05 | 数据库支持 | 默认 SQLite,可切换 MySQL;数据目录 `/srv/mail_go`(Windows:`./win/srv/mail_go`) | +| P0-06 | 邮件持久化 | 收/发邮件元数据及正文存入数据库 | +| P0-07 | 附件存储 | 附件保存至 `/srv/mail_go/attachments`(Windows:`./win/srv/mail_go/attachments`) | +| P0-08 | 默认 admin 账户 | 首次启动自动生成 admin 管理员账户 | +| P0-09 | 用户登录认证 | Web 界面登录,Session 管理,未登录重定向 | +| P0-10 | 收件箱页面 | 邮件列表 + 邮件阅读(HTML/纯文本) | +| P0-11 | 撰写/发送邮件 | 支持附件上传,调用本地 SMTP 发送 | +| P0-12 | Web 框架 | Gin + html/template,/admin 仅管理员可访问 | + +### P1 · 应当实现 + +| ID | 功能 | 说明 | +|----|------|------| +| P1-01 | 发件箱页面 | 展示已发送邮件列表 | +| P1-02 | 域名管理(/admin) | 配置域名、SMTP/IMAP 端口、TLS 证书路径 | +| P1-03 | 用户管理(/admin) | 创建/删除/修改用户,设置附件空间配额(默认 5 GB) | +| P1-04 | DNS 配置提示(/admin) | 展示 MX、SPF、DKIM、DMARC 记录的填写示例 | +| P1-05 | Web 监听配置 | 支持 TCP 端口和 Unix Socket(Windows 仅 TCP) | +| P1-06 | DKIM 签名 | 发信时自动附加 DKIM 签名 | + +### P2 · 有则更好 + +| ID | 功能 | 说明 | +|----|------|------| +| P2-01 | 邮件搜索 | Web 界面关键词搜索收件箱 | +| P2-02 | 垃圾邮件过滤 | 基础规则过滤或集成第三方库 | +| P2-03 | 多域名支持 | 单实例托管多个邮件域名 | +| P2-04 | 邮件分组/标签 | 用户自定义文件夹或标签 | +| P2-05 | 操作审计日志 | /admin 记录管理操作日志 | + +--- + +## 五、关键 UI 页面描述 + +### 5.1 登录页(`/login`) +- 居中卡片:邮箱地址 + 密码输入框 + 登录按钮 +- 登录失败提示错误信息 + +### 5.2 收件箱(`/inbox`) +- 左侧导航:收件箱 / 发件箱 / 撰写 / 退出 +- 右侧主区:邮件列表(发件人、主题、时间、未读标记) +- 点击邮件展开正文,底部显示附件下载链接 + +### 5.3 撰写邮件(`/compose`) +- 表单:收件人、抄送(可选)、主题、正文(textarea) +- 附件上传按钮(支持多文件) +- 发送 / 存草稿 按钮 + +### 5.4 发件箱(`/sent`) +- 与收件箱布局一致,展示已发送邮件列表 + +### 5.5 管理后台 - 域名配置(`/admin/domains`) +- 表格列:域名、SMTP 端口、IMAP 端口、TLS 状态、操作 +- 新增/编辑弹层:域名、端口、证书路径输入框 +- DNS 提示区:自动生成 MX / SPF / DKIM / DMARC 记录文本,可一键复制 + +### 5.6 管理后台 - 用户管理(`/admin/users`) +- 表格列:用户名、域名、配额、已用空间、状态、操作 +- 新增/编辑弹层:用户名、密码、所属域名、附件配额(GB) + +--- + +## 六、配置文件结构(参考) + +```toml +# /etc/mail_go/config.toml + +[database] +driver = "sqlite" # sqlite | mysql +dsn = "/srv/mail_go/mail.db" + +[storage] +base_dir = "/srv/mail_go" +attachments = "/srv/mail_go/attachments" + +[web] +listen = ":8080" # TCP 端口或 Unix socket 路径(仅 Linux) + +[smtp] +port = 25 +tls_port = 465 +cert = "" +key = "" + +[imap] +port = 143 +tls_port = 993 +cert = "" +key = "" + +[pop3] +port = 110 +tls_port = 995 +cert = "" +key = "" +``` + +Windows 测试时路径前缀替换为 `./win`。 + +--- + +## 七、待确认问题(Open Questions) + +| # | 问题 | 影响范围 | +|---|------|----------| +| OQ-1 | DKIM 私钥由系统自动生成还是管理员手动导入?影响 /admin 域名配置页交互设计 | P1-02、P1-06 | +| OQ-2 | 是否需要支持邮件转发规则(Forward Rule)?影响 SMTP 服务端逻辑复杂度 | P0-01 | +| OQ-3 | 附件大小上限由全局配置还是按用户配额单独限制? | P0-07、P1-03 | +| OQ-4 | Web 界面是否需要支持富文本(HTML)邮件撰写,还是纯文本即可? | P0-11 | +| OQ-5 | 多域名(P2-03)是否纳入第一期范围?影响数据库 Schema 设计 | P2-03 | +| OQ-6 | 是否需要 OAuth2 / 外部 LDAP 认证,还是仅本地账户? | P0-09 | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26ef3bc --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module mail_go + +go 1.25.0 + +require ( + github.com/BurntSushi/toml v1.4.0 + github.com/emersion/go-imap v1.2.1 + github.com/emersion/go-message v0.18.2 + github.com/emersion/go-smtp v0.24.0 + github.com/gin-contrib/sessions v1.1.0 + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.48.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/driver/sqlite v1.5.7 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2bb0d93 --- /dev/null +++ b/go.sum @@ -0,0 +1,161 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= +github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..2c0cf1b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,54 @@ +package db + +import ( + "fmt" + "os" + "path/filepath" + + "mail_go/config" + + "gorm.io/driver/mysql" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// InitDB initializes the database connection and performs auto-migration. +// It selects the appropriate driver based on cfg.Driver and resolves +// the DSN path for SQLite relative to the storage base directory. +func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.DB, error) { + var dialector gorm.Dialector + + switch cfg.Driver { + case "sqlite": + dsn := cfg.DSN + // If the DSN is the default relative path, prepend the storage base directory + if dsn == config.DefaultDSNWin || dsn == config.DefaultDSNLinux { + dsn = filepath.Join(storageCfg.BaseDir, "mail.db") + } + // Ensure the parent directory exists for SQLite + dir := filepath.Dir(dsn) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("创建数据库目录失败 %s: %w", dir, err) + } + dialector = sqlite.Open(dsn) + case "mysql": + dialector = mysql.Open(cfg.DSN) + default: + return nil, fmt.Errorf("不支持的数据库驱动: %s", cfg.Driver) + } + + db, err := gorm.Open(dialector, &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + + // Auto-migrate all models + if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}); err != nil { + return nil, fmt.Errorf("数据库迁移失败: %w", err) + } + + return db, nil +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..28b3e7c --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,85 @@ +package db + +import ( + "time" +) + +// User represents a mail user in the system. +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:64;not null" json:"username"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + DomainID uint `gorm:"index" json:"domain_id"` + Domain Domain `gorm:"foreignKey:DomainID" json:"domain"` + QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"` + UsedBytes int64 `gorm:"default:0" json:"used_bytes"` + IsActive bool `gorm:"default:true" json:"is_active"` + IsAdmin bool `gorm:"default:false" json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName specifies the table name for User. +func (User) TableName() string { + return "users" +} + +// Domain represents a mail domain in the system. +type Domain struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:255;uniqueIndex;not null" json:"name"` + SmtpPort int `gorm:"default:25" json:"smtp_port"` + ImapPort int `gorm:"default:143" json:"imap_port"` + Pop3Port int `gorm:"default:110" json:"pop3_port"` + TlsCertPath string `gorm:"size:512" json:"tls_cert_path"` + TlsKeyPath string `gorm:"size:512" json:"tls_key_path"` + TlsEnabled bool `gorm:"default:false" json:"tls_enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName specifies the table name for Domain. +func (Domain) TableName() string { + return "domains" +} + +// Message represents an email message in the system. +type Message struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + MessageID string `gorm:"size:255;index" json:"message_id"` + Folder string `gorm:"size:64;default:INBOX;index" json:"folder"` + FromAddr string `gorm:"size:512;not null" json:"from_addr"` + ToAddr string `gorm:"size:2048;not null" json:"to_addr"` + CcAddr string `gorm:"size:2048" json:"cc_addr"` + Subject string `gorm:"size:1024" json:"subject"` + TextBody string `gorm:"type:text" json:"text_body"` + HtmlBody string `gorm:"type:text" json:"html_body"` + IsRead bool `gorm:"default:false" json:"is_read"` + IsFlagged bool `gorm:"default:false" json:"is_flagged"` + Date time.Time `json:"date"` + CreatedAt time.Time `json:"created_at"` +} + +// TableName specifies the table name for Message. +func (Message) TableName() string { + return "messages" +} + +// Attachment represents a file attached to an email message. +type Attachment struct { + ID uint `gorm:"primaryKey" json:"id"` + MessageID uint `gorm:"index;not null" json:"message_id"` + Message Message `gorm:"foreignKey:MessageID" json:"message"` + FileName string `gorm:"size:255;not null" json:"file_name"` + FilePath string `gorm:"size:512;not null" json:"file_path"` + ContentType string `gorm:"size:128" json:"content_type"` + FileSize int64 `json:"file_size"` + CreatedAt time.Time `json:"created_at"` +} + +// TableName specifies the table name for Attachment. +func (Attachment) TableName() string { + return "attachments" +} diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go new file mode 100644 index 0000000..b8377c5 --- /dev/null +++ b/internal/imap_server/backend.go @@ -0,0 +1,786 @@ +package imap_server + +import ( + "bytes" + "fmt" + "io" + "log" + "net/mail" + "strings" + "time" + + "mail_go/internal/db" + "mail_go/internal/store" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + asgomail "github.com/emersion/go-message/mail" +) + +// ---------- imapBackend ---------- + +// imapBackend implements backend.Backend. +type imapBackend struct { + stores *store.Stores +} + +// Login authenticates a user by email and password. +func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) { + user, err := b.stores.Users.Authenticate(username, password) + if err != nil { + return nil, fmt.Errorf("invalid credentials: %w", err) + } + + email := user.Username + "@" + domain, err := b.stores.Domains.GetByID(user.DomainID) + if err == nil { + email = user.Username + "@" + domain.Name + } + + return &imapUser{ + stores: b.stores, + id: user.ID, + email: email, + }, nil +} + +// ---------- imapUser ---------- + +// imapUser implements backend.User. +type imapUser struct { + stores *store.Stores + id uint + email string +} + +// Username returns the user's email address. +func (u *imapUser) Username() string { + return u.email +} + +// ListMailboxes returns the standard mailbox list. +func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) { + folders := []struct { + name string + delimiter string + attributes []string + }{ + {"INBOX", "/", nil}, + {"Sent", "/", nil}, + {"Drafts", "/", []string{"\\Drafts"}}, + {"Trash", "/", []string{"\\Trash"}}, + } + + mailboxes := make([]backend.Mailbox, 0, len(folders)) + for _, f := range folders { + mailboxes = append(mailboxes, &imapMailbox{ + stores: u.stores, + user: u, + name: f.name, + delimiter: f.delimiter, + attributes: f.attributes, + }) + } + return mailboxes, nil +} + +// GetMailbox returns a mailbox by name. +func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) { + validNames := map[string]bool{ + "INBOX": true, + "Sent": true, + "Drafts": true, + "Trash": true, + } + + normalized := name + upper := strings.ToUpper(name) + if upper == "INBOX" { + normalized = "INBOX" + } else if validNames[upper] { + normalized = upper + } else { + // Try title case + normalized = strings.Title(strings.ToLower(name)) + } + + if !validNames[normalized] { + return nil, backend.ErrNoSuchMailbox + } + + return &imapMailbox{ + stores: u.stores, + user: u, + name: normalized, + delimiter: "/", + }, nil +} + +// CreateMailbox creates a new mailbox (not supported in this version). +func (u *imapUser) CreateMailbox(name string) error { + return fmt.Errorf("mailbox creation not supported") +} + +// DeleteMailbox deletes a mailbox (not supported in this version). +func (u *imapUser) DeleteMailbox(name string) error { + return fmt.Errorf("mailbox deletion not supported") +} + +// RenameMailbox renames a mailbox (not supported in this version). +func (u *imapUser) RenameMailbox(existingName, newName string) error { + return fmt.Errorf("mailbox rename not supported") +} + +// Logout is called when the user session ends. +func (u *imapUser) Logout() error { + return nil +} + +// ---------- imapMailbox ---------- + +// imapMailbox implements backend.Mailbox. +type imapMailbox struct { + stores *store.Stores + user *imapUser + name string + delimiter string + attributes []string + // deleted tracks messages marked as \Deleted in this session + deleted map[uint]bool +} + +// Name returns the mailbox name. +func (m *imapMailbox) Name() string { + return m.name +} + +// Info returns mailbox metadata. +func (m *imapMailbox) Info() (*imap.MailboxInfo, error) { + attrs := m.attributes + if attrs == nil { + attrs = []string{} + } + return &imap.MailboxInfo{ + Name: m.name, + Delimiter: m.delimiter, + Attributes: attrs, + }, nil +} + +// Status returns mailbox status information. +func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { + status := &imap.MailboxStatus{ + Name: m.name, + Flags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"}, + PermanentFlags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"}, + } + + messages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) + if err != nil { + return nil, err + } + + status.Messages = uint32(len(messages)) + + var unseenCount uint32 + for _, msg := range messages { + if !msg.IsRead { + unseenCount++ + } + } + status.Unseen = unseenCount + status.Recent = 0 + status.UidNext = uint32(len(messages) + 1) + status.UidValidity = 1 + + return status, nil +} + +// SetSubscribed sets the subscribed status (no-op for now). +func (m *imapMailbox) SetSubscribed(subscribed bool) error { + return nil +} + +// Check is a no-op checkpoint. +func (m *imapMailbox) Check() error { + return nil +} + +// ListMessages returns messages matching the sequence set and fetch items. +func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { + defer close(ch) + + // Fetch all messages in this mailbox + dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) + if err != nil { + return err + } + + if len(dbMessages) == 0 { + return nil + } + + // Build a mapping of sequence number (1-based) to db.Message + type seqEntry struct { + seqNum uint32 + msg *db.Message + } + entries := make([]seqEntry, len(dbMessages)) + for i := range dbMessages { + entries[i] = seqEntry{ + seqNum: uint32(i + 1), + msg: &dbMessages[i], + } + } + + for _, entry := range entries { + var match bool + if uid { + match = seqset.Contains(uint32(entry.msg.ID)) + } else { + match = seqset.Contains(entry.seqNum) + } + if !match { + continue + } + + imapMsg, err := m.buildIMAPMessage(entry.msg, entry.seqNum, items) + if err != nil { + log.Printf("IMAP: error building message %d: %v", entry.msg.ID, err) + continue + } + ch <- imapMsg + } + + return nil +} + +// buildIMAPMessage constructs an imap.Message from a db.Message with the requested items. +func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items []imap.FetchItem) (*imap.Message, error) { + imapMsg := &imap.Message{ + SeqNum: seqNum, + Uid: uint32(dbMsg.ID), + Flags: m.getMessageFlags(dbMsg), + InternalDate: dbMsg.Date, + Body: make(map[*imap.BodySectionName]imap.Literal), + } + + rawMsg := buildRawMessage(dbMsg) + imapMsg.Size = uint32(len(rawMsg)) + + for _, item := range items { + switch item { + case imap.FetchUid: + // UID is already set on the struct + case imap.FetchFlags: + // Flags are already set on the struct + case imap.FetchInternalDate: + // InternalDate is already set on the struct + case imap.FetchRFC822Size: + // Size is already set on the struct + case imap.FetchEnvelope: + imapMsg.Envelope = m.buildEnvelope(dbMsg) + case imap.FetchRFC822: + section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{}} + imapMsg.Body[section] = bytes.NewReader(rawMsg) + case imap.FetchRFC822Header: + headerBytes := buildRawHeader(dbMsg) + section := &imap.BodySectionName{ + BodyPartName: imap.BodyPartName{ + Specifier: imap.HeaderSpecifier, + }, + } + imapMsg.Body[section] = bytes.NewReader(headerBytes) + case imap.FetchRFC822Text: + var bodyText string + if dbMsg.TextBody != "" { + bodyText = dbMsg.TextBody + } else if dbMsg.HtmlBody != "" { + bodyText = dbMsg.HtmlBody + } + section := &imap.BodySectionName{ + BodyPartName: imap.BodyPartName{ + Specifier: imap.TextSpecifier, + }, + } + imapMsg.Body[section] = bytes.NewReader([]byte(bodyText)) + default: + // Handle BODY[] and BODY.PEEK[] sections + itemStr := string(item) + if strings.HasPrefix(itemStr, "BODY[]") || strings.HasPrefix(itemStr, "BODY.PEEK[]") { + section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{}} + imapMsg.Body[section] = bytes.NewReader(rawMsg) + } else if strings.HasPrefix(itemStr, "BODY[HEADER") || strings.HasPrefix(itemStr, "BODY.PEEK[HEADER") { + headerBytes := buildRawHeader(dbMsg) + section := &imap.BodySectionName{ + BodyPartName: imap.BodyPartName{ + Specifier: imap.HeaderSpecifier, + }, + } + imapMsg.Body[section] = bytes.NewReader(headerBytes) + } else if strings.HasPrefix(itemStr, "BODY[TEXT") || strings.HasPrefix(itemStr, "BODY.PEEK[TEXT") { + var bodyText string + if dbMsg.TextBody != "" { + bodyText = dbMsg.TextBody + } else if dbMsg.HtmlBody != "" { + bodyText = dbMsg.HtmlBody + } + section := &imap.BodySectionName{ + BodyPartName: imap.BodyPartName{ + Specifier: imap.TextSpecifier, + }, + } + imapMsg.Body[section] = bytes.NewReader([]byte(bodyText)) + } + } + } + + return imapMsg, nil +} + +// getMessageFlags returns IMAP flags for a database message. +func (m *imapMailbox) getMessageFlags(dbMsg *db.Message) []string { + flags := make([]string, 0) + if dbMsg.IsRead { + flags = append(flags, "\\Seen") + } + if dbMsg.IsFlagged { + flags = append(flags, "\\Flagged") + } + if m.deleted != nil && m.deleted[dbMsg.ID] { + flags = append(flags, "\\Deleted") + } + return flags +} + +// buildEnvelope constructs an imap.Envelope from a db.Message. +func (m *imapMailbox) buildEnvelope(dbMsg *db.Message) *imap.Envelope { + env := &imap.Envelope{ + Date: dbMsg.Date, + Subject: dbMsg.Subject, + From: parseAddressList(dbMsg.FromAddr), + Sender: parseAddressList(dbMsg.FromAddr), + ReplyTo: parseAddressList(dbMsg.FromAddr), + To: parseAddressList(dbMsg.ToAddr), + Cc: parseAddressList(dbMsg.CcAddr), + MessageId: dbMsg.MessageID, + } + return env +} + +// SearchMessages returns sequence numbers or UIDs of messages matching the criteria. +func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { + dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) + if err != nil { + return nil, err + } + + var results []uint32 + for i, dbMsg := range dbMessages { + if m.matchesCriteria(&dbMsg, criteria) { + if uid { + results = append(results, uint32(dbMsg.ID)) + } else { + results = append(results, uint32(i+1)) + } + } + } + + return results, nil +} + +// matchesCriteria checks if a message matches the given search criteria. +func (m *imapMailbox) matchesCriteria(msg *db.Message, criteria *imap.SearchCriteria) bool { + // Check WithFlags (messages must have all these flags) + for _, flag := range criteria.WithFlags { + switch flag { + case "\\Seen": + if !msg.IsRead { + return false + } + case "\\Flagged": + if !msg.IsFlagged { + return false + } + case "\\Deleted": + if m.deleted == nil || !m.deleted[msg.ID] { + return false + } + } + } + + // Check WithoutFlags (messages must NOT have any of these flags) + for _, flag := range criteria.WithoutFlags { + switch flag { + case "\\Seen": + if msg.IsRead { + return false + } + case "\\Flagged": + if msg.IsFlagged { + return false + } + case "\\Deleted": + if m.deleted != nil && m.deleted[msg.ID] { + return false + } + } + } + + // Check date range + if !criteria.Since.IsZero() && msg.Date.Before(criteria.Since) { + return false + } + if !criteria.Before.IsZero() && !msg.Date.Before(criteria.Before) { + return false + } + + // Check header fields + if criteria.Header != nil { + if subject := criteria.Header.Get("Subject"); subject != "" { + if !strings.Contains(strings.ToLower(msg.Subject), strings.ToLower(subject)) { + return false + } + } + if from := criteria.Header.Get("From"); from != "" { + if !strings.Contains(strings.ToLower(msg.FromAddr), strings.ToLower(from)) { + return false + } + } + if to := criteria.Header.Get("To"); to != "" { + if !strings.Contains(strings.ToLower(msg.ToAddr), strings.ToLower(to)) { + return false + } + } + } + + // Check body text + for _, text := range criteria.Body { + bodyText := strings.ToLower(msg.TextBody + " " + msg.HtmlBody) + if !strings.Contains(bodyText, strings.ToLower(text)) { + return false + } + } + + // Check generic text (searches headers + body) + for _, text := range criteria.Text { + allText := strings.ToLower(msg.Subject + " " + msg.FromAddr + " " + msg.ToAddr + " " + msg.TextBody + " " + msg.HtmlBody) + if !strings.Contains(allText, strings.ToLower(text)) { + return false + } + } + + // Check NOT criteria + for _, notCrit := range criteria.Not { + if m.matchesCriteria(msg, notCrit) { + return false + } + } + + // Check OR criteria (at least one must match) + for _, orPair := range criteria.Or { + if !m.matchesCriteria(msg, orPair[0]) && !m.matchesCriteria(msg, orPair[1]) { + return false + } + } + + return true +} + +// CreateMessage appends a new message to the mailbox (IMAP APPEND command). +func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { + // Read the message literal + data, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed to read message body: %w", err) + } + + // Parse as MIME message + mr, err := asgomail.CreateReader(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to parse MIME message: %w", err) + } + + header := mr.Header + fromAddr := header.Get("From") + toAddr := header.Get("To") + ccAddr := header.Get("Cc") + subject, _ := header.Subject() + messageID, _ := header.MessageID() + msgDate, _ := header.Date() + if msgDate.IsZero() { + msgDate = date + } + if msgDate.IsZero() { + msgDate = time.Now() + } + + var textBody, htmlBody string + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + break + } + + switch h := p.Header.(type) { + case *asgomail.InlineHeader: + contentType, _, _ := h.ContentType() + buf, _ := io.ReadAll(p.Body) + if strings.HasPrefix(contentType, "text/plain") { + textBody = string(buf) + } else if strings.HasPrefix(contentType, "text/html") { + htmlBody = string(buf) + } + case *asgomail.AttachmentHeader: + // Attachments from APPEND are not saved in this simple implementation + } + } + + if textBody == "" && htmlBody == "" { + textBody = string(data) + } + + // Determine initial flag state + isRead := false + isFlagged := false + for _, flag := range flags { + switch flag { + case "\\Seen": + isRead = true + case "\\Flagged": + isFlagged = true + } + } + + msg := &db.Message{ + UserID: m.user.id, + MessageID: messageID, + Folder: m.name, + FromAddr: fromAddr, + ToAddr: toAddr, + CcAddr: ccAddr, + Subject: subject, + TextBody: textBody, + HtmlBody: htmlBody, + IsRead: isRead, + IsFlagged: isFlagged, + Date: msgDate, + } + + if err := m.stores.Mails.Create(msg); err != nil { + return fmt.Errorf("failed to create message: %w", err) + } + + return nil +} + +// UpdateMessagesFlags modifies flags on messages. +func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) error { + if m.deleted == nil { + m.deleted = make(map[uint]bool) + } + + dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) + if err != nil { + return err + } + + for i, dbMsg := range dbMessages { + var match bool + if uid { + match = seqset.Contains(uint32(dbMsg.ID)) + } else { + match = seqset.Contains(uint32(i + 1)) + } + if !match { + continue + } + + for _, flag := range flags { + switch flag { + case "\\Seen": + if op == imap.AddFlags || op == imap.SetFlags { + _ = m.stores.Mails.MarkRead(dbMsg.ID) + } else if op == imap.RemoveFlags { + // Mark as unread — update directly + } + case "\\Flagged": + if op == imap.AddFlags || op == imap.SetFlags { + _ = m.stores.Mails.MarkFlagged(dbMsg.ID, true) + } else if op == imap.RemoveFlags { + _ = m.stores.Mails.MarkFlagged(dbMsg.ID, false) + } + case "\\Deleted": + if op == imap.AddFlags || op == imap.SetFlags { + m.deleted[dbMsg.ID] = true + } else if op == imap.RemoveFlags { + delete(m.deleted, dbMsg.ID) + } + } + } + } + + return nil +} + +// CopyMessages copies messages to another mailbox. +func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error { + dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) + if err != nil { + return err + } + + for i, dbMsg := range dbMessages { + var match bool + if uid { + match = seqset.Contains(uint32(dbMsg.ID)) + } else { + match = seqset.Contains(uint32(i + 1)) + } + if !match { + continue + } + + // Create a copy in the destination mailbox + copyMsg := &db.Message{ + UserID: m.user.id, + MessageID: dbMsg.MessageID, + Folder: dest, + FromAddr: dbMsg.FromAddr, + ToAddr: dbMsg.ToAddr, + CcAddr: dbMsg.CcAddr, + Subject: dbMsg.Subject, + TextBody: dbMsg.TextBody, + HtmlBody: dbMsg.HtmlBody, + IsRead: dbMsg.IsRead, + IsFlagged: dbMsg.IsFlagged, + Date: dbMsg.Date, + } + if err := m.stores.Mails.Create(copyMsg); err != nil { + log.Printf("IMAP: failed to copy message %d to %s: %v", dbMsg.ID, dest, err) + } + } + + return nil +} + +// Expunge permanently removes messages marked as \Deleted. +func (m *imapMailbox) Expunge() error { + if m.deleted == nil { + return nil + } + + for msgID := range m.deleted { + if err := m.stores.Mails.Delete(msgID); err != nil { + log.Printf("IMAP: failed to expunge message %d: %v", msgID, err) + } + } + m.deleted = make(map[uint]bool) + return nil +} + +// ---------- Helper functions ---------- + +// parseAddressList parses a comma-separated address string into imap.Address slice. +func parseAddressList(addrStr string) []*imap.Address { + if addrStr == "" { + return nil + } + + addresses, err := mail.ParseAddressList(addrStr) + if err != nil { + // Fallback: treat the whole string as a single address + return []*imap.Address{{ + MailboxName: addrStr, + HostName: "", + }} + } + + result := make([]*imap.Address, 0, len(addresses)) + for _, addr := range addresses { + parts := strings.SplitN(addr.Address, "@", 2) + mailbox := parts[0] + host := "" + if len(parts) > 1 { + host = parts[1] + } + result = append(result, &imap.Address{ + PersonalName: addr.Name, + MailboxName: mailbox, + HostName: host, + }) + } + return result +} + +// buildRawMessage reconstructs a raw RFC822 message from a db.Message. +func buildRawMessage(msg *db.Message) []byte { + var buf bytes.Buffer + + // Write headers + buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr)) + buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr)) + if msg.CcAddr != "" { + buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr)) + } + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject)) + buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z))) + if msg.MessageID != "" { + buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID)) + } + buf.WriteString("MIME-Version: 1.0\r\n") + + // Write body + if msg.HtmlBody != "" && msg.TextBody != "" { + boundary := fmt.Sprintf("mailgo_%d", msg.ID) + buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + buf.WriteString("\r\n") + buf.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") + buf.WriteString(msg.TextBody) + buf.WriteString("\r\n") + buf.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n") + buf.WriteString(msg.HtmlBody) + buf.WriteString("\r\n") + buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + } else if msg.TextBody != "" { + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") + buf.WriteString(msg.TextBody) + } else if msg.HtmlBody != "" { + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n") + buf.WriteString(msg.HtmlBody) + } else { + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") + } + + return buf.Bytes() +} + +// buildRawHeader reconstructs just the header portion of a raw RFC822 message. +func buildRawHeader(msg *db.Message) []byte { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr)) + buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr)) + if msg.CcAddr != "" { + buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr)) + } + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject)) + buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z))) + if msg.MessageID != "" { + buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID)) + } + buf.WriteString("MIME-Version: 1.0\r\n") + if msg.HtmlBody != "" && msg.TextBody != "" { + boundary := fmt.Sprintf("mailgo_%d", msg.ID) + buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + } else if msg.TextBody != "" { + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + } else if msg.HtmlBody != "" { + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n") + } + return buf.Bytes() +} diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go new file mode 100644 index 0000000..f0a8bec --- /dev/null +++ b/internal/imap_server/server.go @@ -0,0 +1,66 @@ +package imap_server + +import ( + "crypto/tls" + "fmt" + "log" + + "mail_go/config" + "mail_go/internal/store" + + "github.com/emersion/go-imap/backend" + imapserver "github.com/emersion/go-imap/server" +) + +// IMAPServer wraps a go-imap Server and provides mailbox access capability. +type IMAPServer struct { + stores *store.Stores + cfg config.IMAPConfig +} + +// NewIMAPServer creates a new IMAP server instance. +func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores) *IMAPServer { + return &IMAPServer{ + stores: stores, + cfg: cfg, + } +} + +// newServer creates a configured imapserver.Server with the given address. +func (s *IMAPServer) newServer(addr string) *imapserver.Server { + be := &imapBackend{stores: s.stores} + srv := imapserver.New(be) + srv.Addr = addr + srv.AllowInsecureAuth = true + return srv +} + +// Start starts the IMAP server on the plain-text port. +func (s *IMAPServer) Start() error { + srv := s.newServer(s.cfg.Addr) + log.Printf("IMAP server listening on %s", s.cfg.Addr) + return srv.ListenAndServe() +} + +// StartTLS starts the IMAP server on the TLS port. +func (s *IMAPServer) StartTLS() error { + if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { + return fmt.Errorf("IMAP TLS certificate or key not configured") + } + + cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey) + if err != nil { + return fmt.Errorf("failed to load IMAP TLS certificate: %w", err) + } + + srv := s.newServer(s.cfg.TLSAddr) + srv.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr) + return srv.ListenAndServeTLS() +} + +// ensure imapBackend satisfies backend.Backend at compile time +var _ backend.Backend = (*imapBackend)(nil) diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go new file mode 100644 index 0000000..12d5971 --- /dev/null +++ b/internal/pop3_server/server.go @@ -0,0 +1,465 @@ +package pop3_server + +import ( + "bufio" + "fmt" + "log" + "net" + "strconv" + "strings" + "sync" + "time" + + "mail_go/config" + "mail_go/internal/db" + "mail_go/internal/store" +) + +// POP3Server implements a simple POP3 mail server over TCP. +type POP3Server struct { + listener net.Listener + stores *store.Stores + cfg config.POP3Config + wg sync.WaitGroup +} + +// NewPOP3Server creates a new POP3 server instance. +func NewPOP3Server(cfg config.POP3Config, stores *store.Stores) *POP3Server { + return &POP3Server{ + stores: stores, + cfg: cfg, + } +} + +// Start starts the POP3 server on the configured plain-text port. +func (s *POP3Server) Start() error { + var err error + s.listener, err = net.Listen("tcp", s.cfg.Addr) + if err != nil { + return fmt.Errorf("POP3 listen failed: %w", err) + } + + log.Printf("POP3 server listening on %s", s.cfg.Addr) + + s.wg.Add(1) + go func() { + defer s.wg.Done() + for { + conn, err := s.listener.Accept() + if err != nil { + // Listener closed + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.handleConn(conn) + }() + } + }() + + return nil +} + +// StartTLS starts the POP3 server on the configured TLS port. +func (s *POP3Server) StartTLS() error { + if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { + return fmt.Errorf("POP3 TLS certificate or key not configured") + } + return fmt.Errorf("POP3 TLS not yet implemented") +} + +// handleConn handles a single POP3 client connection. +func (s *POP3Server) handleConn(conn net.Conn) { + defer conn.Close() + + // Set read/write deadlines + conn.SetDeadline(time.Now().Add(10 * time.Minute)) + + reader := bufio.NewReader(conn) + + // Session state + var user *db.User + var messages []pop3Message + var deleted map[int]bool + + // Send greeting + sendResponse(conn, "+OK MailGo POP3 server ready") + + // Main command loop + for { + line, err := reader.ReadString('\n') + if err != nil { + return // connection closed or error + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Parse command + parts := strings.SplitN(line, " ", 2) + cmd := strings.ToUpper(parts[0]) + arg := "" + if len(parts) > 1 { + arg = strings.TrimSpace(parts[1]) + } + + switch cmd { + case "USER": + user, messages, deleted = s.handleUSER(conn, arg, user) + case "PASS": + user, messages, deleted = s.handlePASS(conn, arg, user) + case "STAT": + s.handleSTAT(conn, messages, deleted) + case "LIST": + s.handleLIST(conn, arg, messages, deleted) + case "RETR": + s.handleRETR(conn, arg, messages, deleted) + case "DELE": + s.handleDELE(conn, arg, deleted) + case "NOOP": + sendResponse(conn, "+OK") + case "RSET": + deleted = make(map[int]bool) + sendResponse(conn, "+OK") + case "QUIT": + // In UPDATE state, actually delete marked messages + s.expungeDeleted(messages, deleted, user) + sendResponse(conn, "+OK MailGo POP3 server signing off") + return + case "CAPA": + sendResponse(conn, "+OK Capability list follows") + sendResponse(conn, "USER") + sendResponse(conn, ".") + case "TOP": + s.handleTOP(conn, arg, messages, deleted) + case "UIDL": + s.handleUIDL(conn, arg, messages, deleted) + default: + sendResponse(conn, "-ERR unknown command") + } + } +} + +// pop3Message holds a message and its computed size for POP3. +type pop3Message struct { + id uint + raw string + size int + message *db.Message +} + +// loadMessages loads all INBOX messages for a user. +func (s *POP3Server) loadMessages(user *db.User) []pop3Message { + if user == nil { + return nil + } + + dbMsgs, err := s.stores.Mails.ListAllByUserAndFolder(user.ID, "INBOX") + if err != nil { + return nil + } + + msgs := make([]pop3Message, 0, len(dbMsgs)) + for i := range dbMsgs { + raw := string(buildRawMessage(&dbMsgs[i])) + msgs = append(msgs, pop3Message{ + id: dbMsgs[i].ID, + raw: raw, + size: len(raw), + message: &dbMsgs[i], + }) + } + return msgs +} + +// handleUSER processes the USER command. +func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db.User) (*db.User, []pop3Message, map[int]bool) { + if username == "" { + sendResponse(conn, "-ERR missing username") + return currentUser, nil, nil + } + + // Look up the user by email + user, err := s.stores.Users.GetByEmail(username) + if err != nil { + // Don't reveal whether user exists yet (standard POP3 behavior) + sendResponse(conn, "+OK") + // Store the attempted username for PASS to use + return &db.User{Username: username}, nil, nil + } + + sendResponse(conn, "+OK") + return user, nil, nil +} + +// handlePASS processes the PASS command. +func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (*db.User, []pop3Message, map[int]bool) { + if user == nil { + sendResponse(conn, "-ERR no username given") + return nil, nil, nil + } + + // Authenticate + authUser, err := s.stores.Users.Authenticate(user.Username, password) + if err != nil { + // If username was an email, try using it directly + if strings.Contains(user.Username, "@") { + authUser, err = s.stores.Users.Authenticate(user.Username, password) + } + if err != nil { + sendResponse(conn, "-ERR authentication failed") + return nil, nil, nil + } + } + + // Load messages + messages := s.loadMessages(authUser) + deleted := make(map[int]bool) + + sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages))) + return authUser, messages, deleted +} + +// handleSTAT processes the STAT command. +func (s *POP3Server) handleSTAT(conn net.Conn, messages []pop3Message, deleted map[int]bool) { + count := 0 + totalSize := 0 + for i, msg := range messages { + if !deleted[i+1] { + count++ + totalSize += msg.size + } + } + sendResponse(conn, fmt.Sprintf("+OK %d %d", count, totalSize)) +} + +// handleLIST processes the LIST command (with optional message number). +func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { + if arg == "" { + // List all messages + sendResponse(conn, "+OK message list follows") + for i, msg := range messages { + if !deleted[i+1] { + sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.size)) + } + } + sendResponse(conn, ".") + } else { + // List specific message + num, err := strconv.Atoi(arg) + if err != nil || num < 1 || num > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + if deleted[num] { + sendResponse(conn, "-ERR message deleted") + return + } + sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].size)) + } +} + +// handleRETR processes the RETR command. +func (s *POP3Server) handleRETR(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { + num, err := strconv.Atoi(arg) + if err != nil || num < 1 || num > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + if deleted[num] { + sendResponse(conn, "-ERR message deleted") + return + } + + msg := messages[num-1] + sendResponse(conn, fmt.Sprintf("+OK %d octets", msg.size)) + + // Send the raw message, dot-stuffing any lines that start with "." + scanner := bufio.NewScanner(strings.NewReader(msg.raw)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, ".") { + conn.Write([]byte(".")) + } + conn.Write([]byte(line + "\r\n")) + } + conn.Write([]byte(".\r\n")) +} + +// handleDELE processes the DELE command. +func (s *POP3Server) handleDELE(conn net.Conn, arg string, deleted map[int]bool) { + num, err := strconv.Atoi(arg) + if err != nil || num < 1 { + sendResponse(conn, "-ERR invalid message number") + return + } + if deleted == nil { + deleted = make(map[int]bool) + } + if deleted[num] { + sendResponse(conn, "-ERR message already deleted") + return + } + deleted[num] = true + sendResponse(conn, "+OK message marked for deletion") +} + +// handleTOP processes the TOP command (headers + first N lines of body). +func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { + // Format: TOP msgNum [n] + parts := strings.Fields(arg) + if len(parts) < 1 { + sendResponse(conn, "-ERR syntax error") + return + } + + num, err := strconv.Atoi(parts[0]) + if err != nil || num < 1 || num > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + if deleted != nil && deleted[num] { + sendResponse(conn, "-ERR message deleted") + return + } + + nLines := 0 + if len(parts) > 1 { + nLines, _ = strconv.Atoi(parts[1]) + } + + msg := messages[num-1] + sendResponse(conn, "+OK top of message follows") + + // Find blank line separating headers from body + headerEnd := strings.Index(msg.raw, "\r\n\r\n") + if headerEnd == -1 { + headerEnd = strings.Index(msg.raw, "\n\n") + if headerEnd == -1 { + // No body, just send everything + sendResponse(conn, msg.raw) + sendResponse(conn, ".") + return + } + } + + // Send headers + header := msg.raw[:headerEnd] + scanner := bufio.NewScanner(strings.NewReader(header)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, ".") { + conn.Write([]byte(".")) + } + conn.Write([]byte(line + "\r\n")) + } + conn.Write([]byte("\r\n")) + + // Send up to N lines of body + if nLines > 0 { + body := msg.raw[headerEnd:] + scanner := bufio.NewScanner(strings.NewReader(body)) + lineCount := 0 + for scanner.Scan() && lineCount < nLines { + line := scanner.Text() + if strings.HasPrefix(line, ".") { + conn.Write([]byte(".")) + } + conn.Write([]byte(line + "\r\n")) + lineCount++ + } + } + + conn.Write([]byte(".\r\n")) +} + +// handleUIDL processes the UIDL command. +func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { + if arg == "" { + // List all UIDs + sendResponse(conn, "+OK UIDL list follows") + for i, msg := range messages { + if deleted == nil || !deleted[i+1] { + sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.id)) + } + } + sendResponse(conn, ".") + } else { + // Specific message UID + num, err := strconv.Atoi(arg) + if err != nil || num < 1 || num > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + if deleted != nil && deleted[num] { + sendResponse(conn, "-ERR message deleted") + return + } + sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id)) + } +} + +// expungeDeleted actually deletes messages that were marked for deletion. +func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) { + if deleted == nil || user == nil { + return + } + for seqNum, msgDeleted := range deleted { + if msgDeleted && seqNum >= 1 && seqNum <= len(messages) { + if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil { + log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err) + } + } + } +} + +// sendResponse writes a POP3 response line to the connection. +func sendResponse(conn net.Conn, line string) { + conn.Write([]byte(line + "\r\n")) +} + +// buildRawMessage reconstructs a raw RFC822 message from a db.Message. +// This is a local copy to avoid importing from the imap_server package. +func buildRawMessage(msg *db.Message) []byte { + var buf strings.Builder + + buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr)) + buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr)) + if msg.CcAddr != "" { + buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr)) + } + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject)) + buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"))) + if msg.MessageID != "" { + buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID)) + } + buf.WriteString("MIME-Version: 1.0\r\n") + + if msg.HtmlBody != "" && msg.TextBody != "" { + boundary := fmt.Sprintf("mailgo_%d", msg.ID) + buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + buf.WriteString("\r\n") + buf.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") + buf.WriteString(msg.TextBody) + buf.WriteString("\r\n") + buf.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n") + buf.WriteString(msg.HtmlBody) + buf.WriteString("\r\n") + buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + } else if msg.TextBody != "" { + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") + buf.WriteString(msg.TextBody) + } else if msg.HtmlBody != "" { + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n") + buf.WriteString(msg.HtmlBody) + } else { + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") + } + + return []byte(buf.String()) +} diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go new file mode 100644 index 0000000..ada4ee3 --- /dev/null +++ b/internal/smtp_server/server.go @@ -0,0 +1,300 @@ +package smtp_server + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "log" + "strings" + "time" + + "mail_go/config" + "mail_go/internal/db" + "mail_go/internal/store" + "mail_go/internal/storage" + + "github.com/emersion/go-message/mail" + "github.com/emersion/go-smtp" +) + +// SMTPServer wraps a go-smtp Server and provides mail receiving capability. +type SMTPServer struct { + server *smtp.Server + stores *store.Stores + storage *storage.AttachmentStorage + cfg config.SMTPConfig +} + +// NewSMTPServer creates a new SMTP server instance. +func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *SMTPServer { + s := &SMTPServer{ + stores: stores, + storage: attStorage, + cfg: cfg, + } + + be := &smtpBackend{server: s} + srv := smtp.NewServer(be) + srv.Addr = cfg.Addr + srv.Domain = cfg.Domain + srv.MaxMessageBytes = cfg.MaxMessage + srv.AllowInsecureAuth = true + srv.ReadTimeout = 60 * time.Second + srv.WriteTimeout = 60 * time.Second + + s.server = srv + return s +} + +// Start starts the SMTP server on the plain-text port. +func (s *SMTPServer) Start() error { + log.Printf("SMTP server listening on %s", s.cfg.Addr) + return s.server.ListenAndServe() +} + +// StartTLS starts the SMTP server on the TLS port. +func (s *SMTPServer) StartTLS() error { + if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { + return fmt.Errorf("SMTP TLS certificate or key not configured") + } + + cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey) + if err != nil { + return fmt.Errorf("failed to load SMTP TLS certificate: %w", err) + } + + // 创建一个新的 SMTP 服务器实例用于 TLS 端口 + be := &smtpBackend{server: s} + srv := smtp.NewServer(be) + srv.Addr = s.cfg.TLSAddr + srv.Domain = s.cfg.Domain + srv.MaxMessageBytes = s.cfg.MaxMessage + srv.AllowInsecureAuth = false + srv.ReadTimeout = 60 * time.Second + srv.WriteTimeout = 60 * time.Second + srv.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + log.Printf("SMTPS server listening on %s", s.cfg.TLSAddr) + return srv.ListenAndServeTLS() +} + +// smtpBackend implements the smtp.Backend interface. +type smtpBackend struct { + server *SMTPServer +} + +// NewSession creates a new SMTP session for the incoming connection. +func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { + return &smtpSession{ + backend: be, + rcpts: make([]string, 0), + attachments: make([]*db.Attachment, 0), + }, nil +} + +// smtpSession implements the smtp.Session interface for handling a single connection. +type smtpSession struct { + backend *smtpBackend + from string + rcpts []string + authenticated bool + username string + attachments []*db.Attachment +} + +// AuthPlain authenticates the user with plain-text credentials. +func (s *smtpSession) AuthPlain(username, password string) error { + user, err := s.backend.server.stores.Users.Authenticate(username, password) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + s.authenticated = true + s.username = user.Username + return nil +} + +// Mail records the sender address (MAIL FROM command). +func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error { + s.from = from + s.rcpts = s.rcpts[:0] + s.attachments = s.attachments[:0] + return nil +} + +// Rcpt validates and records a recipient address (RCPT TO command). +// It verifies that the recipient domain exists in the system and the user exists. +func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error { + parts := strings.SplitN(to, "@", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid recipient address: %s", to) + } + + userName := parts[0] + domainName := parts[1] + + // Check if domain is managed by this system + domain, err := s.backend.server.stores.Domains.GetByName(domainName) + if err != nil { + return fmt.Errorf("domain not found: %s", domainName) + } + + // Check if the user exists in this domain + _, err = s.backend.server.stores.Users.GetByUsername(userName, domain.ID) + if err != nil { + return fmt.Errorf("user not found: %s", to) + } + + s.rcpts = append(s.rcpts, to) + return nil +} + +// Data handles the message body (DATA command). It parses the MIME message, +// extracts fields and attachments, and stores the message for each recipient. +func (s *smtpSession) Data(r io.Reader) error { + // Read all message data + data, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("failed to read message data: %w", err) + } + + // Parse as MIME message + mr, err := mail.CreateReader(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to parse MIME message: %w", err) + } + + // Extract headers from the top-level mail header + header := mr.Header + + fromAddr := header.Get("From") + toAddr := header.Get("To") + ccAddr := header.Get("Cc") + subject, _ := header.Subject() + messageID, _ := header.MessageID() + date, _ := header.Date() + if date.IsZero() { + date = time.Now() + } + + var textBody, htmlBody string + var attachments []*db.Attachment + + // Iterate through all MIME parts + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + log.Printf("SMTP: error reading MIME part: %v", err) + break + } + + switch h := p.Header.(type) { + case *mail.InlineHeader: + contentType, _, _ := h.ContentType() + buf, readErr := io.ReadAll(p.Body) + if readErr != nil { + log.Printf("SMTP: error reading inline part: %v", readErr) + continue + } + if strings.HasPrefix(contentType, "text/plain") { + textBody = string(buf) + } else if strings.HasPrefix(contentType, "text/html") { + htmlBody = string(buf) + } + + case *mail.AttachmentHeader: + filename, _ := h.Filename() + if filename == "" { + filename = "unnamed_attachment" + } + contentType, _, _ := h.ContentType() + buf, readErr := io.ReadAll(p.Body) + if readErr != nil { + log.Printf("SMTP: error reading attachment part: %v", readErr) + continue + } + + relPath, saveErr := s.backend.server.storage.Save(filename, buf) + if saveErr != nil { + log.Printf("SMTP: failed to save attachment %s: %v", filename, saveErr) + continue + } + + attachments = append(attachments, &db.Attachment{ + FileName: filename, + FilePath: relPath, + ContentType: contentType, + FileSize: int64(len(buf)), + }) + } + } + + // Fallback: if no text body was extracted from MIME parts, use the raw data + if textBody == "" && htmlBody == "" { + textBody = string(data) + } + + // Create a Message record for each verified recipient + for _, rcpt := range s.rcpts { + user, err := s.backend.server.stores.Users.GetByEmail(rcpt) + if err != nil { + log.Printf("SMTP: recipient not found %s, skipping", rcpt) + continue + } + + msg := &db.Message{ + UserID: user.ID, + MessageID: messageID, + Folder: "INBOX", + FromAddr: fromAddr, + ToAddr: toAddr, + CcAddr: ccAddr, + Subject: subject, + TextBody: textBody, + HtmlBody: htmlBody, + IsRead: false, + IsFlagged: false, + Date: date, + } + + if createErr := s.backend.server.stores.Mails.Create(msg); createErr != nil { + log.Printf("SMTP: failed to create message for %s: %v", rcpt, createErr) + continue + } + + // Create Attachment records linked to the new message + for _, att := range attachments { + attCopy := db.Attachment{ + MessageID: msg.ID, + FileName: att.FileName, + FilePath: att.FilePath, + ContentType: att.ContentType, + FileSize: att.FileSize, + } + if attErr := s.backend.server.stores.Attachments.Create(&attCopy); attErr != nil { + log.Printf("SMTP: failed to create attachment record: %v", attErr) + } + } + + log.Printf("SMTP: message delivered to %s (ID=%d)", rcpt, msg.ID) + } + + return nil +} + +// Reset clears the session state for the next message on the same connection. +func (s *smtpSession) Reset() { + s.from = "" + s.rcpts = s.rcpts[:0] + s.attachments = s.attachments[:0] +} + +// Logout is called when the SMTP connection is closed. +func (s *smtpSession) Logout() error { + return nil +} diff --git a/internal/storage/attachment.go b/internal/storage/attachment.go new file mode 100644 index 0000000..806941f --- /dev/null +++ b/internal/storage/attachment.go @@ -0,0 +1,69 @@ +package storage + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +// AttachmentStorage handles file operations for email attachments on disk. +type AttachmentStorage struct { + baseDir string // cfg.Storage.AttachDir +} + +// NewAttachmentStorage creates a new AttachmentStorage with the given base directory. +func NewAttachmentStorage(baseDir string) *AttachmentStorage { + return &AttachmentStorage{baseDir: baseDir} +} + +// Save writes attachment data to disk and returns the relative file path. +// The filename is generated as {uuid}{ext} to avoid collisions. +func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) { + // Ensure the base directory exists + if err := os.MkdirAll(s.baseDir, 0755); err != nil { + return "", fmt.Errorf("创建附件目录失败: %w", err) + } + + // Generate a unique filename with the original extension + ext := filepath.Ext(filename) + uniqueName := uuid.New().String() + ext + + fullPath := filepath.Join(s.baseDir, uniqueName) + if err := os.WriteFile(fullPath, data, 0644); err != nil { + return "", fmt.Errorf("写入附件文件失败: %w", err) + } + + return uniqueName, nil +} + +// Read reads attachment data from disk given a relative path. +func (s *AttachmentStorage) Read(relPath string) ([]byte, error) { + fullPath := s.FullPath(relPath) + data, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("读取附件文件失败: %w", err) + } + return data, nil +} + +// Delete removes an attachment file from disk given a relative path. +func (s *AttachmentStorage) Delete(relPath string) error { + fullPath := s.FullPath(relPath) + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除附件文件失败: %w", err) + } + return nil +} + +// FullPath returns the absolute path for a given relative path. +func (s *AttachmentStorage) FullPath(relPath string) string { + // Prevent directory traversal attacks + cleanRel := filepath.Clean(relPath) + if strings.HasPrefix(cleanRel, "..") { + cleanRel = strings.TrimPrefix(cleanRel, "../") + } + return filepath.Join(s.baseDir, cleanRel) +} diff --git a/internal/store/attachment_store.go b/internal/store/attachment_store.go new file mode 100644 index 0000000..f363f4c --- /dev/null +++ b/internal/store/attachment_store.go @@ -0,0 +1,59 @@ +package store + +import ( + "mail_go/internal/db" + + "gorm.io/gorm" +) + +// AttachmentStore defines the interface for attachment data operations. +type AttachmentStore interface { + Create(att *db.Attachment) error + GetByID(id uint) (*db.Attachment, error) + ListByMessage(messageID uint) ([]db.Attachment, error) + Delete(id uint) error + DeleteByMessage(messageID uint) error +} + +// attachmentStoreGorm implements AttachmentStore using GORM. +type attachmentStoreGorm struct { + db *gorm.DB +} + +// newAttachmentStore creates a new GORM-backed AttachmentStore. +func newAttachmentStore(database *gorm.DB) AttachmentStore { + return &attachmentStoreGorm{db: database} +} + +// Create inserts a new attachment record. +func (s *attachmentStoreGorm) Create(att *db.Attachment) error { + return s.db.Create(att).Error +} + +// GetByID retrieves an attachment by primary key. +func (s *attachmentStoreGorm) GetByID(id uint) (*db.Attachment, error) { + var att db.Attachment + if err := s.db.First(&att, id).Error; err != nil { + return nil, err + } + return &att, nil +} + +// ListByMessage retrieves all attachments for a given message. +func (s *attachmentStoreGorm) ListByMessage(messageID uint) ([]db.Attachment, error) { + var attachments []db.Attachment + if err := s.db.Where("message_id = ?", messageID).Find(&attachments).Error; err != nil { + return nil, err + } + return attachments, nil +} + +// Delete removes an attachment by ID. +func (s *attachmentStoreGorm) Delete(id uint) error { + return s.db.Delete(&db.Attachment{}, id).Error +} + +// DeleteByMessage removes all attachments for a given message. +func (s *attachmentStoreGorm) DeleteByMessage(messageID uint) error { + return s.db.Where("message_id = ?", messageID).Delete(&db.Attachment{}).Error +} diff --git a/internal/store/domain_store.go b/internal/store/domain_store.go new file mode 100644 index 0000000..2aae424 --- /dev/null +++ b/internal/store/domain_store.go @@ -0,0 +1,76 @@ +package store + +import ( + "mail_go/internal/db" + + "gorm.io/gorm" +) + +// DomainStore defines the interface for domain data operations. +type DomainStore interface { + Create(domain *db.Domain) error + GetByID(id uint) (*db.Domain, error) + GetByName(name string) (*db.Domain, error) + Update(domain *db.Domain) error + Delete(id uint) error + List(page, size int) ([]db.Domain, int64, error) +} + +// domainStoreGorm implements DomainStore using GORM. +type domainStoreGorm struct { + db *gorm.DB +} + +// newDomainStore creates a new GORM-backed DomainStore. +func newDomainStore(database *gorm.DB) DomainStore { + return &domainStoreGorm{db: database} +} + +// Create inserts a new domain record. +func (s *domainStoreGorm) Create(domain *db.Domain) error { + return s.db.Create(domain).Error +} + +// GetByID retrieves a domain by primary key. +func (s *domainStoreGorm) GetByID(id uint) (*db.Domain, error) { + var domain db.Domain + if err := s.db.First(&domain, id).Error; err != nil { + return nil, err + } + return &domain, nil +} + +// GetByName retrieves a domain by its name. +func (s *domainStoreGorm) GetByName(name string) (*db.Domain, error) { + var domain db.Domain + if err := s.db.Where("name = ?", name).First(&domain).Error; err != nil { + return nil, err + } + return &domain, nil +} + +// Update saves changes to an existing domain record. +func (s *domainStoreGorm) Update(domain *db.Domain) error { + return s.db.Save(domain).Error +} + +// Delete removes a domain by ID. +func (s *domainStoreGorm) Delete(id uint) error { + return s.db.Delete(&db.Domain{}, id).Error +} + +// List retrieves a paginated list of domains. +func (s *domainStoreGorm) List(page, size int) ([]db.Domain, int64, error) { + var domains []db.Domain + var total int64 + + if err := s.db.Model(&db.Domain{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * size + if err := s.db.Offset(offset).Limit(size).Find(&domains).Error; err != nil { + return nil, 0, err + } + return domains, total, nil +} diff --git a/internal/store/mail_store.go b/internal/store/mail_store.go new file mode 100644 index 0000000..3b17599 --- /dev/null +++ b/internal/store/mail_store.go @@ -0,0 +1,125 @@ +package store + +import ( + "errors" + + "mail_go/internal/db" + + "gorm.io/gorm" +) + +// Common store errors +var ( + ErrInvalidEmail = errors.New("无效的邮箱地址格式") + ErrInvalidCredentials = errors.New("用户名或密码错误") + ErrUserInactive = errors.New("用户已被禁用") + ErrRecordNotFound = errors.New("记录不存在") +) + +// MailStore defines the interface for mail message operations. +type MailStore interface { + Create(msg *db.Message) error + GetByID(id uint) (*db.Message, error) + ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error) + ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) + CountByUserAndFolder(userID uint, folder string) (int64, error) + MarkRead(id uint) error + MarkFlagged(id uint, flagged bool) error + MoveToFolder(id uint, folder string) error + Delete(id uint) error + CountUnread(userID uint, folder string) (int64, error) +} + +// mailStoreGorm implements MailStore using GORM. +type mailStoreGorm struct { + db *gorm.DB +} + +// newMailStore creates a new GORM-backed MailStore. +func newMailStore(database *gorm.DB) MailStore { + return &mailStoreGorm{db: database} +} + +// Create inserts a new message record. +func (s *mailStoreGorm) Create(msg *db.Message) error { + return s.db.Create(msg).Error +} + +// GetByID retrieves a message by primary key. +func (s *mailStoreGorm) GetByID(id uint) (*db.Message, error) { + var msg db.Message + if err := s.db.First(&msg, id).Error; err != nil { + return nil, err + } + return &msg, nil +} + +// ListByUserAndFolder retrieves a paginated list of messages for a user and folder. +func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error) { + var messages []db.Message + var total int64 + + query := s.db.Where("user_id = ? AND folder = ?", userID, folder) + if err := query.Model(&db.Message{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * size + if err := query.Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil { + return nil, 0, err + } + return messages, total, nil +} + +// MarkRead sets the IsRead flag to true for a message. +func (s *mailStoreGorm) MarkRead(id uint) error { + return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_read", true).Error +} + +// MarkFlagged sets the IsFlagged flag for a message. +func (s *mailStoreGorm) MarkFlagged(id uint, flagged bool) error { + return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_flagged", flagged).Error +} + +// MoveToFolder changes the folder of a message. +func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error { + return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error +} + +// Delete removes a message by ID. +func (s *mailStoreGorm) Delete(id uint) error { + return s.db.Delete(&db.Message{}, id).Error +} + +// CountUnread returns the count of unread messages for a user in a folder. +func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) { + var count int64 + if err := s.db.Model(&db.Message{}). + Where("user_id = ? AND folder = ? AND is_read = ?", userID, folder, false). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination. +// Messages are ordered by ID ascending so that sequence numbers are stable. +func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) { + var messages []db.Message + if err := s.db.Where("user_id = ? AND folder = ?", userID, folder). + Order("id ASC").Find(&messages).Error; err != nil { + return nil, err + } + return messages, nil +} + +// CountByUserAndFolder returns the total count of messages for a user in a folder. +func (s *mailStoreGorm) CountByUserAndFolder(userID uint, folder string) (int64, error) { + var count int64 + if err := s.db.Model(&db.Message{}). + Where("user_id = ? AND folder = ?", userID, folder). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/internal/store/stores.go b/internal/store/stores.go new file mode 100644 index 0000000..202f721 --- /dev/null +++ b/internal/store/stores.go @@ -0,0 +1,31 @@ +package store + +import ( + "mail_go/internal/db" + + "gorm.io/gorm" +) + +// Stores aggregates all store interfaces for convenient access. +type Stores struct { + Users UserStore + Mails MailStore + Domains DomainStore + Attachments AttachmentStore +} + +// NewStores creates a new Stores instance with all GORM-backed implementations. +func NewStores(database *gorm.DB) *Stores { + return &Stores{ + Users: newUserStore(database), + Mails: newMailStore(database), + Domains: newDomainStore(database), + Attachments: newAttachmentStore(database), + } +} + +// Ensure models are referenced (prevents unused import errors). +var _ = db.User{} +var _ = db.Domain{} +var _ = db.Message{} +var _ = db.Attachment{} diff --git a/internal/store/user_store.go b/internal/store/user_store.go new file mode 100644 index 0000000..9a3b8ca --- /dev/null +++ b/internal/store/user_store.go @@ -0,0 +1,146 @@ +package store + +import ( + "strings" + + "mail_go/internal/db" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// UserStore defines the interface for user data operations. +type UserStore interface { + Create(user *db.User) error + GetByID(id uint) (*db.User, error) + GetByUsername(username string, domainID uint) (*db.User, error) + GetByEmail(email string) (*db.User, error) + Authenticate(email, password string) (*db.User, error) + Update(user *db.User) error + Delete(id uint) error + List(domainID uint, page, size int) ([]db.User, int64, error) + ListAll(page, size int) ([]db.User, int64, error) + UpdateUsedBytes(id uint, delta int64) error + UpdatePassword(userID uint, hashedPassword string) error +} + +// userStoreGorm implements UserStore using GORM. +type userStoreGorm struct { + db *gorm.DB +} + +// newUserStore creates a new GORM-backed UserStore. +func newUserStore(database *gorm.DB) UserStore { + return &userStoreGorm{db: database} +} + +// Create inserts a new user record. +func (s *userStoreGorm) Create(user *db.User) error { + return s.db.Create(user).Error +} + +// GetByID retrieves a user by primary key. +func (s *userStoreGorm) GetByID(id uint) (*db.User, error) { + var user db.User + if err := s.db.Preload("Domain").First(&user, id).Error; err != nil { + return nil, err + } + return &user, nil +} + +// GetByUsername retrieves a user by username and domain ID. +func (s *userStoreGorm) GetByUsername(username string, domainID uint) (*db.User, error) { + var user db.User + if err := s.db.Where("username = ? AND domain_id = ?", username, domainID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// GetByEmail retrieves a user by email address (user@domain format). +func (s *userStoreGorm) GetByEmail(email string) (*db.User, error) { + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 { + return nil, ErrInvalidEmail + } + username := parts[0] + domainName := parts[1] + + var user db.User + if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id"). + Where("users.username = ? AND domains.name = ?", username, domainName). + Preload("Domain"). + First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// Authenticate verifies an email/password combination and returns the user on success. +func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) { + user, err := s.GetByEmail(email) + if err != nil { + return nil, ErrInvalidCredentials + } + if !user.IsActive { + return nil, ErrUserInactive + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + return user, nil +} + +// Update saves changes to an existing user record. +func (s *userStoreGorm) Update(user *db.User) error { + return s.db.Save(user).Error +} + +// Delete removes a user by ID (soft delete if supported, hard delete otherwise). +func (s *userStoreGorm) Delete(id uint) error { + return s.db.Delete(&db.User{}, id).Error +} + +// List retrieves a paginated list of users for a given domain. +func (s *userStoreGorm) List(domainID uint, page, size int) ([]db.User, int64, error) { + var users []db.User + var total int64 + + query := s.db.Where("domain_id = ?", domainID) + if err := query.Model(&db.User{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * size + if err := s.db.Preload("Domain").Where("domain_id = ?", domainID).Offset(offset).Limit(size).Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil +} + +// UpdateUsedBytes atomically adjusts the UsedBytes field by delta. +func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error { + return s.db.Model(&db.User{}).Where("id = ?", id). + Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error +} + +// UpdatePassword updates the password hash for a user. +func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error { + return s.db.Model(&db.User{}).Where("id = ?", userID).Update("password_hash", hashedPassword).Error +} + +// ListAll retrieves a paginated list of all users across all domains. +func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) { + var users []db.User + var total int64 + + if err := s.db.Model(&db.User{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * size + if err := s.db.Preload("Domain").Offset(offset).Limit(size).Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil +} diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go new file mode 100644 index 0000000..2b213bf --- /dev/null +++ b/internal/web/handlers/admin.go @@ -0,0 +1,435 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "mail_go/internal/db" + "mail_go/internal/store" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +// AdminHandler handles admin-related routes (dashboard, domain/user management). +type AdminHandler struct { + stores *store.Stores +} + +// NewAdminHandler creates a new AdminHandler with the given stores. +func NewAdminHandler(stores *store.Stores) *AdminHandler { + return &AdminHandler{stores: stores} +} + +// Dashboard renders the admin dashboard with summary statistics. +func (h *AdminHandler) Dashboard(c *gin.Context) { + _, domainCount, _ := h.stores.Domains.List(1, 1) + _, userCount, _ := h.stores.Users.ListAll(1, 1) + + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "admin_dashboard", gin.H{ + "currentUser": currentUser, + "domainCount": domainCount, + "userCount": userCount, + "activeFolder": "admin", + }) +} + +// ListDomains renders the domain list page. +func (h *AdminHandler) ListDomains(c *gin.Context) { + page := getPageParam(c, "page", 1) + domains, total, err := h.stores.Domains.List(page, 20) + if err != nil { + c.String(http.StatusInternalServerError, "加载域名列表失败: %v", err) + return + } + + currentUser, _ := c.Get("currentUser") + + totalPages := int(total) / 20 + if int(total)%20 > 0 { + totalPages++ + } + if totalPages < 1 { + totalPages = 0 + } + + c.HTML(200, "admin_domains", gin.H{ + "currentUser": currentUser, + "domains": domains, + "total": total, + "page": page, + "pageSize": 20, + "totalPages": totalPages, + "activeFolder": "admin", + }) +} + +// NewDomain renders the new domain form page. +func (h *AdminHandler) NewDomain(c *gin.Context) { + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "admin_domain_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": "", + "isEdit": false, + "domain": &db.Domain{}, + }) +} + +// CreateDomain processes the new domain form submission. +func (h *AdminHandler) CreateDomain(c *gin.Context) { + name := c.PostForm("name") + smtpPort := formIntOrDefault(c, "smtp_port", 25) + imapPort := formIntOrDefault(c, "imap_port", 143) + pop3Port := formIntOrDefault(c, "pop3_port", 110) + tlsEnabled := c.PostForm("tls_enabled") == "on" + + if name == "" { + currentUser, _ := c.Get("currentUser") + c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": "请输入域名", + "isEdit": false, + "domain": &db.Domain{ + Name: name, + SmtpPort: smtpPort, + ImapPort: imapPort, + Pop3Port: pop3Port, + TlsEnabled: tlsEnabled, + }, + }) + return + } + + domain := &db.Domain{ + Name: name, + SmtpPort: smtpPort, + ImapPort: imapPort, + Pop3Port: pop3Port, + TlsEnabled: tlsEnabled, + } + + if err := h.stores.Domains.Create(domain); err != nil { + currentUser, _ := c.Get("currentUser") + c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": fmt.Sprintf("创建域名失败: %v", err), + "isEdit": false, + "domain": domain, + }) + return + } + + c.Redirect(http.StatusFound, "/admin/domains") +} + +// DeleteDomain removes a domain by ID. +func (h *AdminHandler) DeleteDomain(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "无效的域名ID") + return + } + + if err := h.stores.Domains.Delete(uint(id)); err != nil { + c.String(http.StatusInternalServerError, "删除域名失败: %v", err) + return + } + + c.Redirect(http.StatusFound, "/admin/domains") +} + +// DNSHint renders the DNS configuration hints for a specific domain. +func (h *AdminHandler) DNSHint(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "无效的域名ID") + return + } + + domain, err := h.stores.Domains.GetByID(uint(id)) + if err != nil { + c.String(http.StatusNotFound, "域名不存在") + return + } + + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "admin_dns_hint", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "domain": domain, + }) +} + +// ListUsers renders the user list page. +func (h *AdminHandler) ListUsers(c *gin.Context) { + page := getPageParam(c, "page", 1) + + users, total, err := h.stores.Users.ListAll(page, 20) + if err != nil { + c.String(http.StatusInternalServerError, "加载用户列表失败: %v", err) + return + } + + // Get all domains for display + domains, _, _ := h.stores.Domains.List(1, 1000) + + currentUser, _ := c.Get("currentUser") + + totalPages := int(total) / 20 + if int(total)%20 > 0 { + totalPages++ + } + if totalPages < 1 { + totalPages = 0 + } + + c.HTML(200, "admin_users", gin.H{ + "currentUser": currentUser, + "users": users, + "domains": domains, + "total": total, + "page": page, + "pageSize": 20, + "totalPages": totalPages, + "activeFolder": "admin", + }) +} + +// NewUser renders the new user form page. +func (h *AdminHandler) NewUser(c *gin.Context) { + domains, _, _ := h.stores.Domains.List(1, 1000) + + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "admin_user_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": "", + "isEdit": false, + "domains": domains, + "user": &db.User{}, + }) +} + +// CreateUser processes the new user form submission. +func (h *AdminHandler) CreateUser(c *gin.Context) { + username := c.PostForm("username") + password := c.PostForm("password") + domainID := formUintOrDefault(c, "domain_id", 0) + quotaGB := formIntOrDefault(c, "quota_gb", 5) + isAdmin := c.PostForm("is_admin") == "on" + + if username == "" || password == "" || domainID == 0 { + domains, _, _ := h.stores.Domains.List(1, 1000) + currentUser, _ := c.Get("currentUser") + c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": "请填写所有必填字段", + "isEdit": false, + "domains": domains, + "user": &db.User{ + Username: username, + DomainID: domainID, + IsAdmin: isAdmin, + }, + }) + return + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + domains, _, _ := h.stores.Domains.List(1, 1000) + currentUser, _ := c.Get("currentUser") + c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": "密码加密失败", + "isEdit": false, + "domains": domains, + "user": &db.User{ + Username: username, + DomainID: domainID, + IsAdmin: isAdmin, + }, + }) + return + } + + quotaBytes := int64(quotaGB) * 1024 * 1024 * 1024 + + user := &db.User{ + Username: username, + PasswordHash: string(hashedPassword), + DomainID: domainID, + QuotaBytes: quotaBytes, + UsedBytes: 0, + IsActive: true, + IsAdmin: isAdmin, + } + + if err := h.stores.Users.Create(user); err != nil { + domains, _, _ := h.stores.Domains.List(1, 1000) + currentUser, _ := c.Get("currentUser") + c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": fmt.Sprintf("创建用户失败: %v", err), + "isEdit": false, + "domains": domains, + "user": user, + }) + return + } + + c.Redirect(http.StatusFound, "/admin/users") +} + +// DeleteUser removes a user by ID. +func (h *AdminHandler) DeleteUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "无效的用户ID") + return + } + + currentUser, _ := c.Get("currentUser") + if currentUser.(*db.User).ID == uint(id) { + c.String(http.StatusBadRequest, "不能删除自己的账户") + return + } + + if err := h.stores.Users.Delete(uint(id)); err != nil { + c.String(http.StatusInternalServerError, "删除用户失败: %v", err) + return + } + + c.Redirect(http.StatusFound, "/admin/users") +} + +// EditUser renders the edit user form page. +func (h *AdminHandler) EditUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "无效的用户ID") + return + } + + user, err := h.stores.Users.GetByID(uint(id)) + if err != nil { + c.String(http.StatusNotFound, "用户不存在") + return + } + + domains, _, _ := h.stores.Domains.List(1, 1000) + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "admin_user_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": "", + "isEdit": true, + "domains": domains, + "user": user, + }) +} + +// UpdateUser processes the edit user form submission. +func (h *AdminHandler) UpdateUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "无效的用户ID") + return + } + + user, err := h.stores.Users.GetByID(uint(id)) + if err != nil { + c.String(http.StatusNotFound, "用户不存在") + return + } + + username := c.PostForm("username") + domainID := formUintOrDefault(c, "domain_id", user.DomainID) + quotaGB := formIntOrDefault(c, "quota_gb", int(user.QuotaBytes/(1024*1024*1024))) + isActive := c.PostForm("is_active") == "on" + isAdmin := c.PostForm("is_admin") == "on" + password := c.PostForm("password") + + if username != "" { + user.Username = username + } + user.DomainID = domainID + user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024 + user.IsActive = isActive + user.IsAdmin = isAdmin + + // Update password only if a new one is provided + if password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + domains, _, _ := h.stores.Domains.List(1, 1000) + currentUser, _ := c.Get("currentUser") + c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": "密码加密失败", + "isEdit": true, + "domains": domains, + "user": user, + }) + return + } + user.PasswordHash = string(hashedPassword) + } + + if err := h.stores.Users.Update(user); err != nil { + domains, _, _ := h.stores.Domains.List(1, 1000) + currentUser, _ := c.Get("currentUser") + c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ + "currentUser": currentUser, + "activeFolder": "admin", + "error": fmt.Sprintf("更新用户失败: %v", err), + "isEdit": true, + "domains": domains, + "user": user, + }) + return + } + + c.Redirect(http.StatusFound, "/admin/users") +} + +// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid. +func formIntOrDefault(c *gin.Context, key string, defaultVal int) int { + val := c.PostForm(key) + if val == "" { + return defaultVal + } + n, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + return n +} + +// formUintOrDefault extracts a uint from a form field, returning the default if missing/invalid. +func formUintOrDefault(c *gin.Context, key string, defaultVal uint) uint { + val := c.PostForm(key) + if val == "" { + return defaultVal + } + n, err := strconv.ParseUint(val, 10, 64) + if err != nil { + return defaultVal + } + return uint(n) +} diff --git a/internal/web/handlers/auth.go b/internal/web/handlers/auth.go new file mode 100644 index 0000000..857b000 --- /dev/null +++ b/internal/web/handlers/auth.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "mail_go/internal/store" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// AuthHandler handles authentication-related routes (login, logout). +type AuthHandler struct { + stores *store.Stores +} + +// NewAuthHandler creates a new AuthHandler with the given stores. +func NewAuthHandler(stores *store.Stores) *AuthHandler { + return &AuthHandler{stores: stores} +} + +// ShowLogin renders the login page. +func (h *AuthHandler) ShowLogin(c *gin.Context) { + // If already logged in, redirect to inbox + session := sessions.Default(c) + if session.Get("userID") != nil { + c.Redirect(302, "/inbox") + return + } + c.HTML(200, "login", gin.H{ + "error": "", + }) +} + +// DoLogin processes the login form submission. +// It authenticates the user with email and password, sets session data +// on success, or re-renders the login page with an error on failure. +func (h *AuthHandler) DoLogin(c *gin.Context) { + email := c.PostForm("email") + password := c.PostForm("password") + + if email == "" || password == "" { + c.HTML(200, "login", gin.H{ + "error": "请输入邮箱和密码", + }) + return + } + + user, err := h.stores.Users.Authenticate(email, password) + if err != nil { + c.HTML(200, "login", gin.H{ + "error": "用户名或密码错误", + }) + return + } + + // Set session values + session := sessions.Default(c) + session.Set("userID", user.ID) + session.Set("userEmail", user.Username+"@"+user.Domain.Name) + session.Set("isAdmin", user.IsAdmin) + if err := session.Save(); err != nil { + c.HTML(200, "login", gin.H{ + "error": "会话保存失败,请重试", + }) + return + } + + c.Redirect(302, "/inbox") +} + +// DoLogout clears the session and redirects to the login page. +func (h *AuthHandler) DoLogout(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + session.Save() + c.Redirect(302, "/login") +} diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go new file mode 100644 index 0000000..2c0bfb7 --- /dev/null +++ b/internal/web/handlers/mail.go @@ -0,0 +1,521 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "net/smtp" + "path/filepath" + "strconv" + "strings" + "time" + + "mail_go/internal/db" + "mail_go/internal/storage" + "mail_go/internal/store" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.). +type MailHandler struct { + stores *store.Stores + storage *storage.AttachmentStorage +} + +// NewMailHandler creates a new MailHandler with the given stores and attachment storage. +func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage) *MailHandler { + return &MailHandler{stores: stores, storage: attStorage} +} + +// Inbox renders the inbox page showing all messages in the user's INBOX folder. +func (h *MailHandler) Inbox(c *gin.Context) { + userID := c.GetUint("userID") + page := getPageParam(c, "page", 1) + + messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20) + if err != nil { + c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err) + return + } + + unreadCount, _ := h.stores.Mails.CountUnread(userID, "INBOX") + + currentUser, _ := c.Get("currentUser") + + totalPages := int(total) / 20 + if int(total)%20 > 0 { + totalPages++ + } + if totalPages < 1 { + totalPages = 0 + } + + c.HTML(200, "inbox", gin.H{ + "currentUser": currentUser, + "messages": messages, + "total": total, + "unreadCount": unreadCount, + "page": page, + "pageSize": 20, + "totalPages": totalPages, + "folder": "INBOX", + "activeFolder": "inbox", + }) +} + +// View renders the email detail page for a specific message. +func (h *MailHandler) View(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 { + c.String(http.StatusNotFound, "邮件不存在") + return + } + + // Verify the message belongs to the current user + if msg.UserID != userID { + c.String(http.StatusForbidden, "禁止访问") + return + } + + // Load attachments + attachments, _ := h.stores.Attachments.ListByMessage(uint(id)) + + // Auto mark as read + if !msg.IsRead { + _ = h.stores.Mails.MarkRead(uint(id)) + msg.IsRead = true + } + + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "view", gin.H{ + "currentUser": currentUser, + "message": msg, + "attachments": attachments, + "activeFolder": resolveActiveFolder(msg.Folder), + }) +} + +// Compose renders the email composition page. +func (h *MailHandler) Compose(c *gin.Context) { + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "compose", gin.H{ + "currentUser": currentUser, + "activeFolder": "compose", + "error": "", + "to": c.Query("to"), + "subject": c.Query("subject"), + }) +} + +// DoSend processes the email composition form, sends the email via SMTP, +// and stores the message record. +func (h *MailHandler) DoSend(c *gin.Context) { + userID := c.GetUint("userID") + currentUserVal, _ := c.Get("currentUser") + currentUser := currentUserVal.(*db.User) + + to := c.PostForm("to") + subject := c.PostForm("subject") + body := c.PostForm("body") + cc := c.PostForm("cc") + + if to == "" { + c.HTML(http.StatusBadRequest, "compose", gin.H{ + "currentUser": currentUser, + "activeFolder": "compose", + "error": "请输入收件人", + "to": to, + "subject": subject, + "cc": cc, + }) + return + } + + // Build the email content + fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name) + now := time.Now() + messageID := fmt.Sprintf("<%s@mail_go>", uuid.New().String()) + + // Construct the raw email message + var sb strings.Builder + sb.WriteString(fmt.Sprintf("From: %s\r\n", fromAddr)) + sb.WriteString(fmt.Sprintf("To: %s\r\n", to)) + if cc != "" { + sb.WriteString(fmt.Sprintf("Cc: %s\r\n", cc)) + } + sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID)) + sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z))) + sb.WriteString("MIME-Version: 1.0\r\n") + sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + sb.WriteString("\r\n") + sb.WriteString(body) + + // Send via local SMTP + err := smtp.SendMail("localhost:25", nil, fromAddr, strings.Split(to, ","), []byte(sb.String())) + if err != nil { + // Log the error but still save to sent folder — SMTP may not be running yet + fmt.Printf("SMTP发送失败(邮件仍保存到发件箱): %v\n", err) + } + + // Save to Sent folder + msg := &db.Message{ + UserID: userID, + MessageID: messageID, + Folder: "Sent", + FromAddr: fromAddr, + ToAddr: to, + CcAddr: cc, + Subject: subject, + TextBody: body, + Date: now, + IsRead: true, + } + + if createErr := h.stores.Mails.Create(msg); createErr != nil { + c.HTML(http.StatusInternalServerError, "compose", gin.H{ + "currentUser": currentUser, + "activeFolder": "compose", + "error": fmt.Sprintf("保存邮件失败: %v", createErr), + "to": to, + "subject": subject, + "cc": cc, + }) + return + } + + // Handle attachments + form, err := c.MultipartForm() + if err == nil { + files := form.File["attachments"] + for _, file := range files { + // Read file content + f, err := file.Open() + if err != nil { + continue + } + buf, err := io.ReadAll(f) + f.Close() + if err != nil { + continue + } + + // Save to disk + relPath, err := h.storage.Save(file.Filename, buf) + if err != nil { + continue + } + + // Determine content type from extension + contentType := "application/octet-stream" + ext := strings.ToLower(filepath.Ext(file.Filename)) + if ct, ok := mimeTypes[ext]; ok { + contentType = ct + } + + att := &db.Attachment{ + MessageID: msg.ID, + FileName: file.Filename, + FilePath: relPath, + ContentType: contentType, + FileSize: file.Size, + } + _ = h.stores.Attachments.Create(att) + } + } + + c.Redirect(http.StatusFound, "/sent") +} + +// mimeTypes maps common file extensions to MIME types. +var mimeTypes = map[string]string{ + ".txt": "text/plain", + ".html": "text/html", + ".htm": "text/html", + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".zip": "application/zip", + ".rar": "application/x-rar-compressed", + ".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") + + totalPages := int(total) / 20 + if int(total)%20 > 0 { + totalPages++ + } + if totalPages < 1 { + totalPages = 0 + } + + c.HTML(200, "sent", gin.H{ + "currentUser": currentUser, + "messages": messages, + "total": total, + "page": page, + "pageSize": 20, + "totalPages": totalPages, + "folder": "Sent", + "activeFolder": "sent", + }) +} + +// Delete removes a message by ID after verifying ownership. +func (h *MailHandler) Delete(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 + } + + // Delete attachments on disk and in DB + attachments, _ := h.stores.Attachments.ListByMessage(uint(id)) + for _, att := range attachments { + _ = h.storage.Delete(att.FilePath) + } + _ = h.stores.Attachments.DeleteByMessage(uint(id)) + _ = h.stores.Mails.Delete(uint(id)) + + // Redirect back based on the folder + referer := c.GetHeader("Referer") + if referer == "" { + referer = "/inbox" + } + c.Redirect(http.StatusFound, referer) +} + +// MarkRead marks a message as read. +func (h *MailHandler) MarkRead(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 + } + + _ = h.stores.Mails.MarkRead(uint(id)) + + referer := c.GetHeader("Referer") + if referer == "" { + referer = "/inbox" + } + c.Redirect(http.StatusFound, referer) +} + +// DownloadAttachment serves an attachment file for download. +func (h *MailHandler) DownloadAttachment(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 + } + + att, err := h.stores.Attachments.GetByID(uint(id)) + if err != nil { + c.String(http.StatusNotFound, "附件不存在") + return + } + + // Verify the message belongs to the current user + msg, err := h.stores.Mails.GetByID(att.MessageID) + if err != nil || msg.UserID != userID { + c.String(http.StatusForbidden, "禁止访问") + return + } + + data, err := h.storage.Read(att.FilePath) + if err != nil { + c.String(http.StatusInternalServerError, "读取附件失败") + return + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName)) + c.Data(http.StatusOK, att.ContentType, data) +} + +// getPageParam extracts and validates a page query parameter. +// Returns defaultVal if the parameter is missing or invalid. +func getPageParam(c *gin.Context, key string, defaultVal int) int { + pageStr := c.Query(key) + if pageStr == "" { + return defaultVal + } + page, err := strconv.Atoi(pageStr) + if err != nil || page < 1 { + return defaultVal + } + return page +} + +// Drafts renders the drafts folder page. +func (h *MailHandler) Drafts(c *gin.Context) { + userID := c.GetUint("userID") + page := getPageParam(c, "page", 1) + + messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20) + if err != nil { + c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err) + return + } + + currentUser, _ := c.Get("currentUser") + + totalPages := int(total) / 20 + if int(total)%20 > 0 { + totalPages++ + } + if totalPages < 1 { + totalPages = 0 + } + + c.HTML(200, "drafts", gin.H{ + "currentUser": currentUser, + "messages": messages, + "total": total, + "page": page, + "pageSize": 20, + "totalPages": totalPages, + "folder": "Drafts", + "activeFolder": "drafts", + }) +} + +// Settings renders the user settings page. +func (h *MailHandler) Settings(c *gin.Context) { + currentUser, _ := c.Get("currentUser") + c.HTML(200, "settings", gin.H{ + "currentUser": currentUser, + "activeFolder": "settings", + "error": "", + "success": "", + }) +} + +// UpdateSettings handles the password change form. +func (h *MailHandler) UpdateSettings(c *gin.Context) { + userID := c.GetUint("userID") + currentUserVal, _ := c.Get("currentUser") + currentUser := currentUserVal.(*db.User) + + oldPassword := c.PostForm("old_password") + newPassword := c.PostForm("new_password") + confirmPassword := c.PostForm("confirm_password") + + // Verify old password + if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil { + c.HTML(http.StatusBadRequest, "settings", gin.H{ + "currentUser": currentUser, + "activeFolder": "settings", + "error": "当前密码不正确", + "success": "", + }) + return + } + + if newPassword == "" { + c.HTML(http.StatusBadRequest, "settings", gin.H{ + "currentUser": currentUser, + "activeFolder": "settings", + "error": "新密码不能为空", + "success": "", + }) + return + } + + if newPassword != confirmPassword { + c.HTML(http.StatusBadRequest, "settings", gin.H{ + "currentUser": currentUser, + "activeFolder": "settings", + "error": "两次输入的密码不一致", + "success": "", + }) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + c.HTML(http.StatusInternalServerError, "settings", gin.H{ + "currentUser": currentUser, + "activeFolder": "settings", + "error": "密码加密失败", + "success": "", + }) + return + } + + if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil { + c.HTML(http.StatusInternalServerError, "settings", gin.H{ + "currentUser": currentUser, + "activeFolder": "settings", + "error": "密码更新失败", + "success": "", + }) + return + } + + c.HTML(http.StatusOK, "settings", gin.H{ + "currentUser": currentUser, + "activeFolder": "settings", + "error": "", + "success": "密码修改成功", + }) +} + +// resolveActiveFolder maps a folder name to a sidebar active state key. +func resolveActiveFolder(folder string) string { + switch folder { + case "INBOX": + return "inbox" + case "Sent": + return "sent" + case "Drafts": + return "drafts" + default: + return folder + } +} diff --git a/internal/web/middleware/admin.go b/internal/web/middleware/admin.go new file mode 100644 index 0000000..e8a6c48 --- /dev/null +++ b/internal/web/middleware/admin.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + + "mail_go/internal/db" + + "github.com/gin-gonic/gin" +) + +// AdminMiddleware checks that the current user has admin privileges. +// Must be used after AuthMiddleware so that "currentUser" is available. +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + userVal, exists := c.Get("currentUser") + if !exists { + c.String(http.StatusForbidden, "禁止访问") + c.Abort() + return + } + + user, ok := userVal.(*db.User) + if !ok || !user.IsAdmin { + c.String(http.StatusForbidden, "禁止访问:需要管理员权限") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/web/middleware/auth.go b/internal/web/middleware/auth.go new file mode 100644 index 0000000..2073096 --- /dev/null +++ b/internal/web/middleware/auth.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "mail_go/internal/store" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// AuthMiddleware checks for a valid session and loads the current user +// into the Gin context. If no valid session exists, it redirects to /login. +func AuthMiddleware(stores *store.Stores) gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + userID := session.Get("userID") + if userID == nil { + c.Redirect(302, "/login") + c.Abort() + return + } + + // userID is stored as uint in session, but sessions.Get returns interface{} + // which may be stored as int or uint depending on the underlying store. + var id uint + switch v := userID.(type) { + case uint: + id = v + case int: + id = uint(v) + case int64: + id = uint(v) + case float64: + id = uint(v) + default: + session.Clear() + session.Save() + c.Redirect(302, "/login") + c.Abort() + return + } + + user, err := stores.Users.GetByID(id) + if err != nil { + session.Clear() + session.Save() + c.Redirect(302, "/login") + c.Abort() + return + } + + c.Set("currentUser", user) + c.Set("userID", id) + c.Next() + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..432b916 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,163 @@ +package web + +import ( + "fmt" + "html/template" + "math" + + "mail_go/config" + "mail_go/internal/storage" + "mail_go/internal/store" + "mail_go/internal/web/handlers" + "mail_go/internal/web/middleware" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +// formatBytes converts a file size in bytes to a human-readable string. +func formatBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +// WebServer wraps the Gin engine and its dependencies. +type WebServer struct { + engine *gin.Engine + stores *store.Stores + storage *storage.AttachmentStorage + cfg config.WebConfig +} + +// templateFuncs returns custom template functions for rendering. +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "mul": func(a, b int) int { return a * b }, + "div": func(a, b int) int { return a / b }, + "mod": func(a, b int) int { return a % b }, + "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, + "seq": func(n int) []int { + result := make([]int, n) + for i := 0; i < n; i++ { + result[i] = i + 1 + } + return result + }, + "domainName": func(domainID uint, domains []interface{}) string { + return fmt.Sprintf("Domain #%d", domainID) + }, + "safeHTML": func(s string) template.HTML { + return template.HTML(s) + }, + "formatBytes": func(b int64) string { + return formatBytes(b) + }, + } +} + +// NewWebServer creates a new WebServer, initializes the Gin engine, +// configures sessions, middleware, and registers all routes. +func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *WebServer { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + engine.Use(gin.Logger()) + engine.Use(gin.Recovery()) + + // Session store (cookie-based) + cookieStore := cookie.NewStore([]byte("mail-go-secret-key-change-in-production")) + cookieStore.Options(sessions.Options{ + HttpOnly: true, + SameSite: 3, // SameSiteLaxMode + MaxAge: 86400, + Path: "/", + }) + engine.Use(sessions.Sessions("mail_go_session", cookieStore)) + + // Load HTML templates with custom functions + // Note: Go's filepath.Glob doesn't support **, so we load in two passes + tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseGlob("internal/web/templates/*.html")) + template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html")) + engine.SetHTMLTemplate(tmpl) + + ws := &WebServer{ + engine: engine, + stores: stores, + storage: attStorage, + cfg: cfg, + } + + ws.registerRoutes() + return ws +} + +// registerRoutes sets up all HTTP routes with their handlers and middleware. +func (ws *WebServer) registerRoutes() { + authHandler := handlers.NewAuthHandler(ws.stores) + mailHandler := handlers.NewMailHandler(ws.stores, ws.storage) + adminHandler := handlers.NewAdminHandler(ws.stores) + + // Public routes (no auth required) + ws.engine.GET("/login", authHandler.ShowLogin) + ws.engine.POST("/login", authHandler.DoLogin) + + // Auth-protected routes + auth := ws.engine.Group("") + auth.Use(middleware.AuthMiddleware(ws.stores)) + { + auth.POST("/logout", authHandler.DoLogout) + auth.GET("/", func(c *gin.Context) { + c.Redirect(302, "/inbox") + }) + + // Mail routes + auth.GET("/inbox", mailHandler.Inbox) + auth.GET("/inbox/:id", mailHandler.View) + auth.GET("/compose", mailHandler.Compose) + auth.POST("/compose", mailHandler.DoSend) + auth.GET("/drafts", mailHandler.Drafts) + auth.GET("/drafts/:id", mailHandler.View) + auth.GET("/sent", mailHandler.Sent) + auth.GET("/sent/:id", mailHandler.View) + auth.GET("/settings", mailHandler.Settings) + auth.POST("/settings", mailHandler.UpdateSettings) + auth.POST("/mail/delete/:id", mailHandler.Delete) + auth.POST("/mail/read/:id", mailHandler.MarkRead) + auth.GET("/attachment/:id", mailHandler.DownloadAttachment) + } + + // Admin routes (auth + admin required) + admin := ws.engine.Group("/admin") + admin.Use(middleware.AuthMiddleware(ws.stores)) + admin.Use(middleware.AdminMiddleware()) + { + admin.GET("", adminHandler.Dashboard) + admin.GET("/", adminHandler.Dashboard) + admin.GET("/domains", adminHandler.ListDomains) + admin.GET("/domains/new", adminHandler.NewDomain) + admin.POST("/domains", adminHandler.CreateDomain) + admin.POST("/domains/:id/delete", adminHandler.DeleteDomain) + admin.GET("/domains/:id/dns", adminHandler.DNSHint) + admin.GET("/users", adminHandler.ListUsers) + admin.GET("/users/new", adminHandler.NewUser) + admin.POST("/users", adminHandler.CreateUser) + admin.POST("/users/:id/delete", adminHandler.DeleteUser) + admin.GET("/users/:id/edit", adminHandler.EditUser) + admin.POST("/users/:id", adminHandler.UpdateUser) + } +} + +// Start launches the HTTP server on the configured address. +func (ws *WebServer) Start() error { + return ws.engine.Run(ws.cfg.Addr) +} diff --git a/internal/web/templates/admin/dashboard.html b/internal/web/templates/admin/dashboard.html new file mode 100644 index 0000000..9c6c46d --- /dev/null +++ b/internal/web/templates/admin/dashboard.html @@ -0,0 +1,44 @@ +{{define "admin_dashboard"}} + + + + + + 管理后台 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+

管理后台

+
+
+

{{.domainCount}}

+

域名数

+
+
+

{{.userCount}}

+

用户数

+
+
+
+

快捷操作

+

+ 新增域名 + 新增用户 +

+
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/dns_hint.html b/internal/web/templates/admin/dns_hint.html new file mode 100644 index 0000000..16e18b8 --- /dev/null +++ b/internal/web/templates/admin/dns_hint.html @@ -0,0 +1,46 @@ +{{define "admin_dns_hint"}} + + + + + + DNS配置 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

DNS 配置提示 — {{.domain.Name}}

+

请在您的 DNS 服务商处添加以下记录:

+ +

MX 记录

+
@ IN MX 10 mail.{{.domain.Name}}.
+ +

SPF 记录 (TXT)

+
@ IN TXT "v=spf1 mx -all"
+ +

DKIM 记录

+
⚠️ 请在域名配置页配置 DKIM 私钥路径后,将自动生成 DKIM 公钥记录。
+ +

DMARC 记录

+
_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"
+ + +
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/domain_form.html b/internal/web/templates/admin/domain_form.html new file mode 100644 index 0000000..7994300 --- /dev/null +++ b/internal/web/templates/admin/domain_form.html @@ -0,0 +1,56 @@ +{{define "admin_domain_form"}} + + + + + + {{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

{{if .isEdit}}编辑域名{{else}}新增域名{{end}}

+ {{if .error}}
{{.error}}
{{end}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + 取消 +
+
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/domains.html b/internal/web/templates/admin/domains.html new file mode 100644 index 0000000..2e981f8 --- /dev/null +++ b/internal/web/templates/admin/domains.html @@ -0,0 +1,79 @@ +{{define "admin_domains"}} + + + + + + 域名管理 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+
+

域名列表

+ 新增域名 +
+ {{if not .domains}} +

暂无域名

+ {{else}} + + + + + + + + + + + + + + {{range .domains}} + + + + + + + + + + {{end}} + +
ID域名SMTPIMAPPOP3TLS操作
{{.ID}}{{.Name}}{{.SmtpPort}}{{.ImapPort}}{{.Pop3Port}}{{if .TlsEnabled}}✅{{else}}❌{{end}} + DNS +
+ +
+
+ {{end}} +
+ {{if .totalPages}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/user_form.html b/internal/web/templates/admin/user_form.html new file mode 100644 index 0000000..cdda3ea --- /dev/null +++ b/internal/web/templates/admin/user_form.html @@ -0,0 +1,69 @@ +{{define "admin_user_form"}} + + + + + + {{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

{{if .isEdit}}编辑用户{{else}}新增用户{{end}}

+ {{if .error}}
{{.error}}
{{end}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {{if .isEdit}} +
+ +
+ {{end}} + + 取消 +
+
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/users.html b/internal/web/templates/admin/users.html new file mode 100644 index 0000000..7c83236 --- /dev/null +++ b/internal/web/templates/admin/users.html @@ -0,0 +1,81 @@ +{{define "admin_users"}} + + + + + + 用户管理 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+
+

用户列表

+ 新增用户 +
+ {{if not .users}} +

暂无用户

+ {{else}} + + + + + + + + + + + + + + + {{range .users}} + + + + + + + + + + + {{end}} + +
ID用户名域名ID配额已用状态管理员操作
{{.ID}}{{.Username}}{{.DomainID}}{{formatBytes .QuotaBytes}}{{formatBytes .UsedBytes}}{{if .IsActive}}✅{{else}}❌{{end}}{{if .IsAdmin}}✅{{else}}—{{end}} + 编辑 +
+ +
+
+ {{end}} +
+ {{if .totalPages}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html new file mode 100644 index 0000000..5e892f4 --- /dev/null +++ b/internal/web/templates/base.html @@ -0,0 +1,66 @@ +{{define "styles"}} + +{{end}} + +{{define "navbar"}} +{{if .currentUser}} + +{{end}} +{{end}} diff --git a/internal/web/templates/compose.html b/internal/web/templates/compose.html new file mode 100644 index 0000000..9378d7c --- /dev/null +++ b/internal/web/templates/compose.html @@ -0,0 +1,55 @@ +{{define "compose"}} + + + + + + 撰写邮件 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

撰写邮件

+ {{if .error}}
{{.error}}
{{end}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + 取消 +
+
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/drafts.html b/internal/web/templates/drafts.html new file mode 100644 index 0000000..4737a47 --- /dev/null +++ b/internal/web/templates/drafts.html @@ -0,0 +1,74 @@ +{{define "drafts"}} + + + + + + 草稿箱 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

草稿箱

+ {{if not .messages}} +

暂无草稿邮件

+ {{else}} + + + + + + + + + + + {{range .messages}} + + + + + + + {{end}} + +
发件人/收件人主题时间操作
{{.ToAddr}} + + {{if .Subject}}{{.Subject}}{{else}}(无主题){{end}} + + {{.Date.Format "2006-01-02 15:04"}} +
+ +
+
+ {{end}} +
+ {{if .totalPages}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/web/templates/inbox.html b/internal/web/templates/inbox.html new file mode 100644 index 0000000..f337acb --- /dev/null +++ b/internal/web/templates/inbox.html @@ -0,0 +1,75 @@ +{{define "inbox"}} + + + + + + 收件箱 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

收件箱

+ {{if not .messages}} +

暂无邮件

+ {{else}} + + + + + + + + + + + {{range .messages}} + + + + + + + {{end}} + +
发件人主题时间操作
{{.FromAddr}} + + {{if .Subject}}{{.Subject}}{{else}}(无主题){{end}} + + {{.Date.Format "2006-01-02 15:04"}} + {{if not .IsRead}} +
+ +
+ {{end}} +
+ {{end}} +
+ {{if .totalPages}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..a27c2fa --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,33 @@ +{{define "login"}} + + + + + + 登录 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+
+

MailGo 登录

+ {{if .error}}
{{.error}}
{{end}} +
+
+ + +
+
+ + +
+ +
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/sent.html b/internal/web/templates/sent.html new file mode 100644 index 0000000..dfff69d --- /dev/null +++ b/internal/web/templates/sent.html @@ -0,0 +1,74 @@ +{{define "sent"}} + + + + + + 发件箱 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

发件箱

+ {{if not .messages}} +

暂无已发送邮件

+ {{else}} + + + + + + + + + + + {{range .messages}} + + + + + + + {{end}} + +
收件人主题时间操作
{{.ToAddr}} + + {{if .Subject}}{{.Subject}}{{else}}(无主题){{end}} + + {{.Date.Format "2006-01-02 15:04"}} +
+ +
+
+ {{end}} +
+ {{if .totalPages}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html new file mode 100644 index 0000000..4562115 --- /dev/null +++ b/internal/web/templates/settings.html @@ -0,0 +1,48 @@ +{{define "settings"}} + + + + + + 设置 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

设置

+ {{if .error}}
{{.error}}
{{end}} + {{if .success}}
{{.success}}
{{end}} +

修改密码

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/view.html b/internal/web/templates/view.html new file mode 100644 index 0000000..7290ab5 --- /dev/null +++ b/internal/web/templates/view.html @@ -0,0 +1,64 @@ +{{define "view"}} + + + + + + 查看邮件 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+
+ 返回 +
+

{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}

+
+

发件人: {{.message.FromAddr}}

+

收件人: {{.message.ToAddr}}

+ {{if .message.CcAddr}}

抄送: {{.message.CcAddr}}

{{end}} +

时间: {{.message.Date.Format "2006-01-02 15:04:05"}}

+
+
+ {{if .message.HtmlBody}} + {{.message.HtmlBody | safeHTML}} + {{else}} +
{{.message.TextBody}}
+ {{end}} +
+ {{if .attachments}} +
+

附件

+ {{range .attachments}} +
+ 📎 {{.FileName}} + ({{formatBytes .FileSize}}) +
+ {{end}} +
+ {{end}} +
+
+ +
+ 回复 +
+
+
+
+
+ + +{{end}} diff --git a/login_test.html b/login_test.html new file mode 100644 index 0000000..714b6c9 --- /dev/null +++ b/login_test.html @@ -0,0 +1,84 @@ + + + + + + + 登录 - MailGo + + + + + + + + +
+
+
+

MailGo 登录

+ +
+
+ + +
+
+ + +
+ +
+
+
+
+ + diff --git a/mail_go.exe b/mail_go.exe new file mode 100644 index 0000000..646b4a0 Binary files /dev/null and b/mail_go.exe differ diff --git a/mailgo.exe b/mailgo.exe new file mode 100644 index 0000000..7639993 Binary files /dev/null and b/mailgo.exe differ diff --git a/mailgo_qa.exe b/mailgo_qa.exe new file mode 100644 index 0000000..4b504bf Binary files /dev/null and b/mailgo_qa.exe differ diff --git a/mailgo_test.exe b/mailgo_test.exe new file mode 100644 index 0000000..4b504bf Binary files /dev/null and b/mailgo_test.exe differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..9d0fb91 --- /dev/null +++ b/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "fmt" + "log" + + "mail_go/config" + "mail_go/internal/db" + "mail_go/internal/imap_server" + "mail_go/internal/pop3_server" + "mail_go/internal/smtp_server" + "mail_go/internal/storage" + "mail_go/internal/store" + "mail_go/internal/web" + + "golang.org/x/crypto/bcrypt" +) + +func main() { + // 1. Load configuration + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + fmt.Println("配置加载成功") + + // 2. Initialize database + database, err := db.InitDB(cfg.Database, cfg.Storage) + if err != nil { + log.Fatalf("数据库初始化失败: %v", err) + } + fmt.Println("数据库初始化成功") + + // 3. Create Store layer + stores := store.NewStores(database) + + // 4. Ensure default admin user exists + ensureAdminUser(stores, cfg) + + // 5. Initialize attachment storage + attStorage := storage.NewAttachmentStorage(cfg.Storage.AttachDir) + + // 6. Start SMTP server + smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage) + go func() { + if err := smtpSrv.Start(); err != nil { + log.Printf("SMTP 服务启动失败: %v", err) + } + }() + // Start SMTPS if TLS is configured + if cfg.SMTP.TLSCert != "" && cfg.SMTP.TLSKey != "" { + go func() { + if err := smtpSrv.StartTLS(); err != nil { + log.Printf("SMTPS 服务启动失败: %v", err) + } + }() + } + + // 7. Start IMAP server + imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores) + go func() { + if err := imapSrv.Start(); err != nil { + log.Printf("IMAP 服务启动失败: %v", err) + } + }() + // Start IMAPS if TLS is configured + if cfg.IMAP.TLSCert != "" && cfg.IMAP.TLSKey != "" { + go func() { + if err := imapSrv.StartTLS(); err != nil { + log.Printf("IMAPS 服务启动失败: %v", err) + } + }() + } + + // 8. Start POP3 server + pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores) + go func() { + if err := pop3Srv.Start(); err != nil { + log.Printf("POP3 服务启动失败: %v", err) + } + }() + // Start POP3S if TLS is configured + if cfg.POP3.TLSCert != "" && cfg.POP3.TLSKey != "" { + go func() { + if err := pop3Srv.StartTLS(); err != nil { + log.Printf("POP3S 服务启动失败: %v", err) + } + }() + } + + // 9. Start Web server + webServer := web.NewWebServer(cfg.Web, stores, attStorage) + fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr) + go func() { + if err := webServer.Start(); err != nil { + log.Fatalf("Web 服务启动失败: %v", err) + } + }() + + fmt.Println("MailGo 邮件系统启动完成") + select {} // Block main goroutine +} + +// ensureAdminUser checks if an admin user exists and creates one if not. +// It also ensures the default domain "example.com" exists. +func ensureAdminUser(stores *store.Stores, cfg *config.Config) { + // Check if admin user exists by trying to authenticate + _, err := stores.Users.GetByEmail("admin@example.com") + if err == nil { + fmt.Println("管理员账户已存在,跳过创建") + return + } + + // Ensure the default domain exists + domain, err := stores.Domains.GetByName("example.com") + if err != nil { + // Domain doesn't exist, create it + domain = &db.Domain{ + Name: "example.com", + SmtpPort: 25, + ImapPort: 143, + Pop3Port: 110, + TlsEnabled: false, + } + if createErr := stores.Domains.Create(domain); createErr != nil { + log.Printf("创建默认域名失败: %v", createErr) + return + } + fmt.Println("默认域名 example.com 创建成功") + } + + // Hash the default admin password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) + if err != nil { + log.Printf("密码哈希失败: %v", err) + return + } + + // Create the admin user + adminUser := &db.User{ + Username: "admin", + PasswordHash: string(hashedPassword), + DomainID: domain.ID, + QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB + UsedBytes: 0, + IsActive: true, + IsAdmin: true, + } + + if createErr := stores.Users.Create(adminUser); createErr != nil { + log.Printf("创建管理员账户失败: %v", createErr) + return + } + + fmt.Println("管理员账户 admin@example.com 创建成功(密码: admin)") +} diff --git a/startup.log b/startup.log new file mode 100644 index 0000000..e69de29 diff --git a/startup_err.log b/startup_err.log new file mode 100644 index 0000000..e69de29 diff --git a/startup_out.log b/startup_out.log new file mode 100644 index 0000000..e69de29 diff --git a/win/etc/mail_go/mail_go.toml b/win/etc/mail_go/mail_go.toml new file mode 100644 index 0000000..3e3e720 --- /dev/null +++ b/win/etc/mail_go/mail_go.toml @@ -0,0 +1,30 @@ +[database] + driver = "sqlite" + dsn = "./win/srv/mail_go/mail.db" + +[storage] + base_dir = "./win/srv/mail_go/" + attach_dir = "win\\srv\\mail_go\\attachments" + +[web] + addr = ":8080" + +[smtp] + addr = ":25" + tls_addr = ":465" + domain = "localhost" + tls_cert = "" + tls_key = "" + max_message_bytes = 67108864 + +[imap] + addr = ":143" + tls_addr = ":993" + tls_cert = "" + tls_key = "" + +[pop3] + addr = ":110" + tls_addr = ":995" + tls_cert = "" + tls_key = "" diff --git a/win/srv/mail_go/mail.db b/win/srv/mail_go/mail.db new file mode 100644 index 0000000..ace7cc8 Binary files /dev/null and b/win/srv/mail_go/mail.db differ