一阶段ok
This commit is contained in:
@@ -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 路由及密码修改功能
|
||||||
@@ -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
|
||||||
@@ -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 记录(管理后台有提示页面)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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 |
|
||||||
+157
@@ -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 |
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{{define "admin_dashboard"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>管理后台 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin" class="active">控制面板</a>
|
||||||
|
<a href="/admin/domains">域名管理</a>
|
||||||
|
<a href="/admin/users">用户管理</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2 style="margin-bottom:24px;">管理后台</h2>
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.domainCount}}</h3>
|
||||||
|
<p>域名数</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.userCount}}</h3>
|
||||||
|
<p>用户数</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>快捷操作</h3>
|
||||||
|
<p style="margin-top:12px;">
|
||||||
|
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
|
||||||
|
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">新增用户</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{{define "admin_dns_hint"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DNS配置 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin">控制面板</a>
|
||||||
|
<a href="/admin/domains" class="active">域名管理</a>
|
||||||
|
<a href="/admin/users">用户管理</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">DNS 配置提示 — {{.domain.Name}}</h2>
|
||||||
|
<p style="margin-bottom:16px;color:#7f8c8d;">请在您的 DNS 服务商处添加以下记录:</p>
|
||||||
|
|
||||||
|
<h4 style="margin-bottom:8px;">MX 记录</h4>
|
||||||
|
<div class="dns-record">@ IN MX 10 mail.{{.domain.Name}}.</div>
|
||||||
|
|
||||||
|
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4>
|
||||||
|
<div class="dns-record">@ IN TXT "v=spf1 mx -all"</div>
|
||||||
|
|
||||||
|
<h4 style="margin-bottom:8px;">DKIM 记录</h4>
|
||||||
|
<div class="dns-record" style="color:#e67e22;">⚠️ 请在域名配置页配置 DKIM 私钥路径后,将自动生成 DKIM 公钥记录。</div>
|
||||||
|
|
||||||
|
<h4 style="margin-bottom:8px;">DMARC 记录</h4>
|
||||||
|
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;">
|
||||||
|
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">返回域名列表</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{{define "admin_domain_form"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin">控制面板</a>
|
||||||
|
<a href="/admin/domains" class="active">域名管理</a>
|
||||||
|
<a href="/admin/users">用户管理</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2>
|
||||||
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
|
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>域名</label>
|
||||||
|
<input type="text" name="name" required value="{{.domain.Name}}" placeholder="example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SMTP 端口</label>
|
||||||
|
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>IMAP 端口</label>
|
||||||
|
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>POP3 端口</label>
|
||||||
|
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}}>
|
||||||
|
启用 TLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
|
||||||
|
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{{define "admin_domains"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>域名管理 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin">控制面板</a>
|
||||||
|
<a href="/admin/domains" class="active">域名管理</a>
|
||||||
|
<a href="/admin/users">用户管理</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
<h2>域名列表</h2>
|
||||||
|
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
|
||||||
|
</div>
|
||||||
|
{{if not .domains}}
|
||||||
|
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无域名</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>域名</th>
|
||||||
|
<th>SMTP</th>
|
||||||
|
<th>IMAP</th>
|
||||||
|
<th>POP3</th>
|
||||||
|
<th>TLS</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .domains}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td>{{.SmtpPort}}</td>
|
||||||
|
<td>{{.ImapPort}}</td>
|
||||||
|
<td>{{.Pop3Port}}</td>
|
||||||
|
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/domains/{{.ID}}/dns" class="btn btn-primary btn-sm">DNS</a>
|
||||||
|
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
|
||||||
|
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .totalPages}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .page 1}}
|
||||||
|
<a href="/admin/domains?page={{sub .page 1}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||||
|
{{if lt .page .totalPages}}
|
||||||
|
<a href="/admin/domains?page={{add .page 1}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{{define "admin_user_form"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin">控制面板</a>
|
||||||
|
<a href="/admin/domains">域名管理</a>
|
||||||
|
<a href="/admin/users" class="active">用户管理</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑用户{{else}}新增用户{{end}}</h2>
|
||||||
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
|
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{if .isEdit}}新密码(留空则不修改){{else}}密码{{end}}</label>
|
||||||
|
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="请输入密码">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>域名</label>
|
||||||
|
<select name="domain_id" required>
|
||||||
|
<option value="">请选择域名</option>
|
||||||
|
{{range .domains}}
|
||||||
|
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>配额 (GB)</label>
|
||||||
|
<input type="number" name="quota_gb" min="1" value="5" placeholder="5">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
|
||||||
|
管理员
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{if .isEdit}}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
|
||||||
|
启用账户
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建用户{{end}}</button>
|
||||||
|
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{{define "admin_users"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>用户管理 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin">控制面板</a>
|
||||||
|
<a href="/admin/domains">域名管理</a>
|
||||||
|
<a href="/admin/users" class="active">用户管理</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
<h2>用户列表</h2>
|
||||||
|
<a href="/admin/users/new" class="btn btn-primary">新增用户</a>
|
||||||
|
</div>
|
||||||
|
{{if not .users}}
|
||||||
|
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无用户</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>域名ID</th>
|
||||||
|
<th>配额</th>
|
||||||
|
<th>已用</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>管理员</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .users}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{.DomainID}}</td>
|
||||||
|
<td>{{formatBytes .QuotaBytes}}</td>
|
||||||
|
<td>{{formatBytes .UsedBytes}}</td>
|
||||||
|
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
|
||||||
|
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
|
||||||
|
<form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;"
|
||||||
|
onsubmit="return confirm('确定要删除用户 {{.Username}} 吗?');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .totalPages}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .page 1}}
|
||||||
|
<a href="/admin/users?page={{sub .page 1}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||||
|
{{if lt .page .totalPages}}
|
||||||
|
<a href="/admin/users?page={{add .page 1}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{{define "styles"}}
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f5f5; color:#333; }
|
||||||
|
.navbar { background:#2c3e50; padding:0 20px; height:50px; display:flex; align-items:center; }
|
||||||
|
.navbar a { color:#ecf0f1; text-decoration:none; margin-right:20px; font-size:14px; }
|
||||||
|
.navbar a:hover { color:#3498db; }
|
||||||
|
.navbar .right { margin-left:auto; }
|
||||||
|
.container { max-width:1200px; margin:20px auto; padding:0 20px; }
|
||||||
|
.card { background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); padding:20px; margin-bottom:20px; }
|
||||||
|
table { width:100%; border-collapse:collapse; }
|
||||||
|
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #eee; }
|
||||||
|
th { background:#f8f9fa; font-weight:600; }
|
||||||
|
.btn { display:inline-block; padding:8px 16px; border-radius:4px; text-decoration:none; font-size:14px; cursor:pointer; border:none; }
|
||||||
|
.btn-primary { background:#3498db; color:#fff; }
|
||||||
|
.btn-primary:hover { background:#2980b9; }
|
||||||
|
.btn-danger { background:#e74c3c; color:#fff; }
|
||||||
|
.btn-danger:hover { background:#c0392b; }
|
||||||
|
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||||
|
.alert { padding:12px 16px; border-radius:4px; margin-bottom:16px; }
|
||||||
|
.alert-error { background:#fde8e8; color:#c0392b; }
|
||||||
|
.alert-success { background:#e8fde8; color:#27ae60; }
|
||||||
|
.form-group { margin-bottom:16px; }
|
||||||
|
.form-group label { display:block; margin-bottom:6px; font-weight:600; }
|
||||||
|
.form-group input, .form-group textarea, .form-group select { width:100%; padding:8px 12px; border:1px solid #ddd; border-radius:4px; font-size:14px; }
|
||||||
|
.form-group input[type="checkbox"] { width:auto; }
|
||||||
|
.unread { font-weight:bold; }
|
||||||
|
.message-subject { color:#2c3e50; text-decoration:none; }
|
||||||
|
.message-subject:hover { color:#3498db; }
|
||||||
|
.sidebar { width:200px; float:left; }
|
||||||
|
.sidebar a { display:block; padding:10px 15px; color:#2c3e50; text-decoration:none; border-radius:4px; margin-bottom:2px; }
|
||||||
|
.sidebar a:hover, .sidebar a.active { background:#3498db; color:#fff; }
|
||||||
|
.content { margin-left:220px; }
|
||||||
|
.pagination { margin-top:16px; text-align:center; }
|
||||||
|
.pagination a, .pagination span { display:inline-block; padding:6px 12px; margin:0 2px; border:1px solid #ddd; border-radius:4px; text-decoration:none; color:#333; }
|
||||||
|
.pagination .current { background:#3498db; color:#fff; border-color:#3498db; }
|
||||||
|
.mail-meta { color:#7f8c8d; font-size:13px; margin-bottom:8px; }
|
||||||
|
.mail-body { line-height:1.6; margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||||
|
.attachment-list { margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||||
|
.attachment-item { display:inline-block; margin-right:12px; margin-bottom:8px; padding:6px 12px; background:#ecf0f1; border-radius:4px; font-size:13px; }
|
||||||
|
.attachment-item a { color:#2c3e50; text-decoration:none; }
|
||||||
|
.attachment-item a:hover { color:#3498db; }
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; }
|
||||||
|
.badge-unread { background:#e74c3c; color:#fff; }
|
||||||
|
.stat-card { display:inline-block; width:200px; padding:20px; margin-right:20px; background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); text-align:center; }
|
||||||
|
.stat-card h3 { font-size:32px; color:#2c3e50; margin-bottom:4px; }
|
||||||
|
.stat-card p { color:#7f8c8d; font-size:14px; }
|
||||||
|
.dns-record { background:#f8f9fa; padding:12px 16px; border-radius:4px; margin-bottom:12px; font-family:monospace; font-size:13px; white-space:pre-wrap; }
|
||||||
|
.clearfix::after { content:""; display:table; clear:both; }
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "navbar"}}
|
||||||
|
{{if .currentUser}}
|
||||||
|
<nav class="navbar">
|
||||||
|
<a href="/inbox">MailGo</a>
|
||||||
|
{{if .currentUser.IsAdmin}}<a href="/admin">管理后台</a>{{end}}
|
||||||
|
<div class="right">
|
||||||
|
<span style="color:#ecf0f1;font-size:13px;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
|
||||||
|
<form method="POST" action="/logout" style="display:inline;">
|
||||||
|
<a href="#" onclick="this.parentElement.submit(); return false;">退出</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{{define "compose"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>撰写邮件 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||||
|
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||||
|
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||||
|
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||||
|
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">撰写邮件</h2>
|
||||||
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/compose" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>收件人</label>
|
||||||
|
<input type="email" name="to" required value="{{.to}}" placeholder="user@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>抄送(可选)</label>
|
||||||
|
<input type="text" name="cc" value="{{.cc}}" placeholder="cc@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>主题</label>
|
||||||
|
<input type="text" name="subject" value="{{.subject}}" placeholder="邮件主题">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>正文</label>
|
||||||
|
<textarea name="body" rows="12" placeholder="请输入邮件内容..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>附件</label>
|
||||||
|
<input type="file" name="attachments" multiple>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">发送邮件</button>
|
||||||
|
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{{define "drafts"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>草稿箱 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||||
|
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||||
|
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||||
|
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||||
|
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">草稿箱</h2>
|
||||||
|
{{if not .messages}}
|
||||||
|
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无草稿邮件</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:25%;">发件人/收件人</th>
|
||||||
|
<th style="width:45%;">主题</th>
|
||||||
|
<th style="width:20%;">时间</th>
|
||||||
|
<th style="width:10%;">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .messages}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ToAddr}}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/drafts/{{.ID}}" class="message-subject">
|
||||||
|
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
|
||||||
|
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .totalPages}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .page 1}}
|
||||||
|
<a href="/drafts?page={{sub .page 1}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||||
|
{{if lt .page .totalPages}}
|
||||||
|
<a href="/drafts?page={{add .page 1}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{{define "inbox"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>收件箱 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||||
|
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||||
|
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||||
|
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||||
|
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">收件箱</h2>
|
||||||
|
{{if not .messages}}
|
||||||
|
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:25%;">发件人</th>
|
||||||
|
<th style="width:45%;">主题</th>
|
||||||
|
<th style="width:20%;">时间</th>
|
||||||
|
<th style="width:10%;">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .messages}}
|
||||||
|
<tr class="{{if not .IsRead}}unread{{end}}">
|
||||||
|
<td>{{.FromAddr}}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/inbox/{{.ID}}" class="message-subject">
|
||||||
|
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td>
|
||||||
|
{{if not .IsRead}}
|
||||||
|
<form method="POST" action="/mail/read/{{.ID}}" style="display:inline;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">已读</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .totalPages}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .page 1}}
|
||||||
|
<a href="/inbox?page={{sub .page 1}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||||
|
{{if lt .page .totalPages}}
|
||||||
|
<a href="/inbox?page={{add .page 1}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "login"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div style="max-width:400px;margin:80px auto;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="text-align:center;margin-bottom:24px;">MailGo 登录</h2>
|
||||||
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>邮箱地址</label>
|
||||||
|
<input type="email" name="email" required autofocus placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" required placeholder="请输入密码">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{{define "sent"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>发件箱 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||||
|
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||||
|
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||||
|
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||||
|
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">发件箱</h2>
|
||||||
|
{{if not .messages}}
|
||||||
|
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无已发送邮件</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:25%;">收件人</th>
|
||||||
|
<th style="width:45%;">主题</th>
|
||||||
|
<th style="width:20%;">时间</th>
|
||||||
|
<th style="width:10%;">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .messages}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ToAddr}}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/sent/{{.ID}}" class="message-subject">
|
||||||
|
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
|
||||||
|
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .totalPages}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .page 1}}
|
||||||
|
<a href="/sent?page={{sub .page 1}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||||
|
{{if lt .page .totalPages}}
|
||||||
|
<a href="/sent?page={{add .page 1}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{{define "settings"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>设置 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||||
|
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||||
|
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||||
|
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||||
|
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">设置</h2>
|
||||||
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
|
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
|
||||||
|
<h3 style="margin-bottom:12px;">修改密码</h3>
|
||||||
|
<form method="POST" action="/settings">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>当前密码</label>
|
||||||
|
<input type="password" name="old_password" required placeholder="请输入当前密码">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>新密码</label>
|
||||||
|
<input type="password" name="new_password" required placeholder="请输入新密码">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>确认新密码</label>
|
||||||
|
<input type="password" name="confirm_password" required placeholder="请再次输入新密码">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">修改密码</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{{define "view"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>查看邮件 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||||
|
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||||
|
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||||
|
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||||
|
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<a href="javascript:history.back()" class="btn" style="background:#bdc3c7;color:#fff;">返回</a>
|
||||||
|
</div>
|
||||||
|
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h2>
|
||||||
|
<div class="mail-meta" style="margin-top:12px;">
|
||||||
|
<p><strong>发件人:</strong> {{.message.FromAddr}}</p>
|
||||||
|
<p><strong>收件人:</strong> {{.message.ToAddr}}</p>
|
||||||
|
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
|
||||||
|
<p><strong>时间:</strong> {{.message.Date.Format "2006-01-02 15:04:05"}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mail-body">
|
||||||
|
{{if .message.HtmlBody}}
|
||||||
|
{{.message.HtmlBody | safeHTML}}
|
||||||
|
{{else}}
|
||||||
|
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .attachments}}
|
||||||
|
<div class="attachment-list">
|
||||||
|
<h4 style="margin-bottom:8px;">附件</h4>
|
||||||
|
{{range .attachments}}
|
||||||
|
<div class="attachment-item">
|
||||||
|
📎 <a href="/attachment/{{.ID}}">{{.FileName}}</a>
|
||||||
|
<span style="color:#7f8c8d;font-size:12px;">({{formatBytes .FileSize}})</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #eee;">
|
||||||
|
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||||
|
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||||
|
<button type="submit" class="btn btn-danger">删除邮件</button>
|
||||||
|
</form>
|
||||||
|
<a href="/compose?to={{.message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="btn btn-primary" style="margin-left:8px;">回复</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录 - MailGo</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f5f5; color:#333; }
|
||||||
|
.navbar { background:#2c3e50; padding:0 20px; height:50px; display:flex; align-items:center; }
|
||||||
|
.navbar a { color:#ecf0f1; text-decoration:none; margin-right:20px; font-size:14px; }
|
||||||
|
.navbar a:hover { color:#3498db; }
|
||||||
|
.navbar .right { margin-left:auto; }
|
||||||
|
.container { max-width:1200px; margin:20px auto; padding:0 20px; }
|
||||||
|
.card { background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); padding:20px; margin-bottom:20px; }
|
||||||
|
table { width:100%; border-collapse:collapse; }
|
||||||
|
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #eee; }
|
||||||
|
th { background:#f8f9fa; font-weight:600; }
|
||||||
|
.btn { display:inline-block; padding:8px 16px; border-radius:4px; text-decoration:none; font-size:14px; cursor:pointer; border:none; }
|
||||||
|
.btn-primary { background:#3498db; color:#fff; }
|
||||||
|
.btn-primary:hover { background:#2980b9; }
|
||||||
|
.btn-danger { background:#e74c3c; color:#fff; }
|
||||||
|
.btn-danger:hover { background:#c0392b; }
|
||||||
|
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||||
|
.alert { padding:12px 16px; border-radius:4px; margin-bottom:16px; }
|
||||||
|
.alert-error { background:#fde8e8; color:#c0392b; }
|
||||||
|
.alert-success { background:#e8fde8; color:#27ae60; }
|
||||||
|
.form-group { margin-bottom:16px; }
|
||||||
|
.form-group label { display:block; margin-bottom:6px; font-weight:600; }
|
||||||
|
.form-group input, .form-group textarea, .form-group select { width:100%; padding:8px 12px; border:1px solid #ddd; border-radius:4px; font-size:14px; }
|
||||||
|
.form-group input[type="checkbox"] { width:auto; }
|
||||||
|
.unread { font-weight:bold; }
|
||||||
|
.message-subject { color:#2c3e50; text-decoration:none; }
|
||||||
|
.message-subject:hover { color:#3498db; }
|
||||||
|
.sidebar { width:200px; float:left; }
|
||||||
|
.sidebar a { display:block; padding:10px 15px; color:#2c3e50; text-decoration:none; border-radius:4px; margin-bottom:2px; }
|
||||||
|
.sidebar a:hover, .sidebar a.active { background:#3498db; color:#fff; }
|
||||||
|
.content { margin-left:220px; }
|
||||||
|
.pagination { margin-top:16px; text-align:center; }
|
||||||
|
.pagination a, .pagination span { display:inline-block; padding:6px 12px; margin:0 2px; border:1px solid #ddd; border-radius:4px; text-decoration:none; color:#333; }
|
||||||
|
.pagination .current { background:#3498db; color:#fff; border-color:#3498db; }
|
||||||
|
.mail-meta { color:#7f8c8d; font-size:13px; margin-bottom:8px; }
|
||||||
|
.mail-body { line-height:1.6; margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||||
|
.attachment-list { margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||||
|
.attachment-item { display:inline-block; margin-right:12px; margin-bottom:8px; padding:6px 12px; background:#ecf0f1; border-radius:4px; font-size:13px; }
|
||||||
|
.attachment-item a { color:#2c3e50; text-decoration:none; }
|
||||||
|
.attachment-item a:hover { color:#3498db; }
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; }
|
||||||
|
.badge-unread { background:#e74c3c; color:#fff; }
|
||||||
|
.stat-card { display:inline-block; width:200px; padding:20px; margin-right:20px; background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); text-align:center; }
|
||||||
|
.stat-card h3 { font-size:32px; color:#2c3e50; margin-bottom:4px; }
|
||||||
|
.stat-card p { color:#7f8c8d; font-size:14px; }
|
||||||
|
.dns-record { background:#f8f9fa; padding:12px 16px; border-radius:4px; margin-bottom:12px; font-family:monospace; font-size:13px; white-space:pre-wrap; }
|
||||||
|
.clearfix::after { content:""; display:table; clear:both; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div style="max-width:400px;margin:80px auto;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="text-align:center;margin-bottom:24px;">MailGo 登录</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>邮箱地址</label>
|
||||||
|
<input type="email" name="email" required autofocus placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" required placeholder="请输入密码">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)")
|
||||||
|
}
|
||||||
@@ -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 = ""
|
||||||
Binary file not shown.
Reference in New Issue
Block a user