一阶段ok

This commit is contained in:
2026-06-01 18:59:55 +08:00
commit 9e50d05e71
52 changed files with 6155 additions and 0 deletions
+19
View File
@@ -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 路由及密码修改功能
+24
View File
@@ -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 是betaAPI不稳定,必须用v1
- Go filepath.Glob 不支持 ** 递归匹配,模板分两轮加载
- SMTP ListenAndServeTLS() 不接受参数,TLS需通过 TLSConfig 设置
- User模型密码字段为 PasswordHash,数据库列为 password_hash
+69
View File
@@ -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 记录(管理后台有提示页面)
+232
View File
@@ -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
}
+39
View File
@@ -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"
+721
View File
@@ -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-pop3POP3 协议简单,自实现可控 |
| 邮件解析 | `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
- POP3TCP 监听 + 文本协议实现(USER/PASS/STAT/LIST/RETR/DELE/QUIT),查询 MailStore
- 三个服务均接受 Store 实例作为构造参数
**T03: Web 服务核心**
- Gin 引擎初始化 + Session 中间件配置
- AuthMiddleware:检查 Session 中的 userID,未登录重定向 /login
- AdminMiddleware:检查用户 IsAdmin 字段
- 登录/登出 handlerbcrypt 校验 + 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.shLinux 下创建 /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
View File
@@ -0,0 +1,157 @@
# PRD · MailGo 邮件系统
- **项目名称**mail_go
- **编程语言**Go
- **核心依赖**go-smtp / go-imap / go-pop3emersion 系列)+ 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 SocketWindows 仅 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 |
+56
View File
@@ -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
)
+161
View File
@@ -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=
+54
View File
@@ -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
}
+85
View File
@@ -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"
}
+786
View File
@@ -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()
}
+66
View File
@@ -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)
+465
View File
@@ -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())
}
+300
View File
@@ -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
}
+69
View File
@@ -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)
}
+59
View File
@@ -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
}
+76
View File
@@ -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
}
+125
View File
@@ -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
}
+31
View File
@@ -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{}
+146
View File
@@ -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
}
+435
View File
@@ -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)
}
+76
View File
@@ -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")
}
+521
View File
@@ -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
}
}
+31
View File
@@ -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()
}
}
+55
View File
@@ -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()
}
}
+163
View File
@@ -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}}
+79
View File
@@ -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}}
+81
View File
@@ -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}}
+66
View File
@@ -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}}
+55
View File
@@ -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}}
+74
View File
@@ -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}}
+75
View File
@@ -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}}
+33
View File
@@ -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}}
+74
View File
@@ -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}}
+48
View File
@@ -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}}
+64
View File
@@ -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}}
+84
View File
@@ -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
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+156
View File
@@ -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")
}
View File
View File
View File
+30
View File
@@ -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.