feat: 实现对外邮件投递(外发队列 + MX 直投 + DKIM + 退信 + 管理后台)
- 新增 internal/outbound 模块:MX 查询、SMTP 出站客户端(EHLO/STARTTLS/ MAIL/RCPT/DATA/QUIT)、4xx 临时失败与 5xx 永久失败分类、8BITMIME 支持 - 新增 outbound_messages 队列表与 OutboundStore,后台 worker 指数退避重试 - 永久失败/超限退信到发件人收件箱,包含原因与目标收件人 - 外发邮件使用域名 DKIM 私钥签名(go-msgauth) - SMTP 提交集成:认证用户可发外部收件人,MAIL FROM 必须等于登录用户邮箱, 未认证外部投递明确拒绝(防开放中继) - Web 发信集成:外部收件人自动入队,附件以 multipart/mixed + base64 编码 加入邮件正文 - 每用户每分钟/每日发送限速(max_per_day=0 可禁用外部投递) - 管理后台新增外发队列页面:状态统计、失败原因、手动重试/取消 - 新增 [outbound] 配置段并更新 README / todo.md
This commit is contained in:
@@ -5,10 +5,11 @@ Go 语言编写的轻量级邮件系统,集成 SMTP / IMAP / POP3 协议服务
|
||||
## 功能特性
|
||||
|
||||
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
|
||||
- **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
|
||||
- **Web 邮箱**:收件箱、已发送、草稿箱、富文本编辑(Quill.js)、附件上传/下载
|
||||
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、IP 封禁管理、仪表盘统计
|
||||
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计
|
||||
- **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭)
|
||||
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP、管理员可解封
|
||||
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
|
||||
- **多数据库**:默认 SQLite,可切换 MySQL
|
||||
- **跨平台**:Linux 生产部署 + Windows 本地调试
|
||||
|
||||
@@ -106,6 +107,16 @@ ldap_use_tls = false
|
||||
[ban]
|
||||
max_fail_attempts = 5 # 登录失败次数阈值
|
||||
ban_duration_min = 30 # 封禁时长(分钟)
|
||||
|
||||
[outbound]
|
||||
hostname = "" # EHLO 主机名,留空使用 [smtp] domain
|
||||
poll_interval = 15 # 外发队列扫描间隔(秒)
|
||||
max_attempts = 12 # 单封邮件最大投递尝试次数
|
||||
retry_base_min = 5 # 重试退避基数(分钟),指数增长:5/10/20/40...
|
||||
max_recipients = 50 # 单封邮件最大外部收件人数
|
||||
max_per_min = 30 # 每用户每分钟最大外发数
|
||||
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
|
||||
connect_timeout = 30 # 连接远程 MX 超时(秒)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -215,6 +226,38 @@ ldap_search_filter = "(uid=%s)"
|
||||
ldap_use_tls = true
|
||||
```
|
||||
|
||||
### 6. 对外发送邮件(外部投递)
|
||||
|
||||
认证用户(Web 邮箱 / SMTP 提交)可以向外部邮箱地址发送邮件。系统通过
|
||||
收件人域名的 MX 记录直投,自动处理 STARTTLS、指数退避重试,并为外发
|
||||
邮件添加 DKIM 签名(使用后台域名管理中生成的密钥)。
|
||||
|
||||
```toml
|
||||
[smtp]
|
||||
domain = "example.com" # 必须设置为真实的邮件域名(EHLO/退信地址)
|
||||
|
||||
[outbound]
|
||||
hostname = "mail.example.com" # 建议与邮件主机名一致
|
||||
poll_interval = 15
|
||||
max_attempts = 12
|
||||
max_per_day = 500 # 设为 0 可完全禁用外部投递
|
||||
```
|
||||
|
||||
需要满足的 DNS / 服务器条件(详见 `todo.md` 与后台 DNS 提示页):
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| MX | `example.com MX 10 mail.example.com` |
|
||||
| SPF | `example.com TXT "v=spf1 mx -all"` |
|
||||
| DKIM | `default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=<公钥>"`(后台自动生成) |
|
||||
| DMARC | `_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"` |
|
||||
| PTR | 服务器 IP 反向解析指向邮件主机名(需向机房申请) |
|
||||
| 网络 | 服务器 25 端口出站未被云厂商封锁 |
|
||||
|
||||
安全策略:外部收件人仅接受已认证用户;`MAIL FROM` 必须与登录用户一致;
|
||||
每用户每分钟/每日外发数受限;失败邮件会退信到发件人收件箱;
|
||||
管理员可在后台「外发队列」查看投递状态、手动重试或取消。
|
||||
|
||||
---
|
||||
|
||||
## 端口速查
|
||||
@@ -246,8 +289,13 @@ mailgo/
|
||||
│ │ ├── mail_store.go # 邮件数据操作
|
||||
│ │ ├── domain_store.go # 域名数据操作
|
||||
│ │ ├── attachment_store.go # 附件数据操作
|
||||
│ │ ├── outbound_store.go # 外发队列数据操作
|
||||
│ │ └── ban_store.go # 封禁数据操作
|
||||
│ ├── smtp_server/server.go # SMTP 服务
|
||||
│ ├── outbound/
|
||||
│ │ ├── mailer.go # MX 查询与 SMTP 出站客户端
|
||||
│ │ ├── manager.go # 外发队列、重试、限速、退信
|
||||
│ │ └── sign.go # DKIM 签名
|
||||
│ ├── imap_server/
|
||||
│ │ ├── server.go # IMAP 服务
|
||||
│ │ └── backend.go # IMAP 后端
|
||||
|
||||
@@ -78,6 +78,18 @@ type BanConfig struct {
|
||||
BanDurationMin int `toml:"ban_duration_min"` // Default: 30 (minutes)
|
||||
}
|
||||
|
||||
// OutboundConfig holds outbound (external) mail delivery settings.
|
||||
type OutboundConfig struct {
|
||||
Hostname string `toml:"hostname"` // EHLO 主机名,留空使用 [smtp] domain
|
||||
PollInterval int `toml:"poll_interval"` // 队列扫描间隔(秒)
|
||||
MaxAttempts int `toml:"max_attempts"` // 单封邮件最大投递尝试次数
|
||||
RetryBaseMin int `toml:"retry_base_min"` // 重试退避基数(分钟),指数增长
|
||||
MaxRecipients int `toml:"max_recipients"` // 单封邮件最大外部收件人数
|
||||
MaxPerMin int `toml:"max_per_min"` // 每用户每分钟最大外发数
|
||||
MaxPerDay int `toml:"max_per_day"` // 每用户每日最大外发数,0 表示禁用外部投递
|
||||
ConnectTimeout int `toml:"connect_timeout"` // 连接远程 MX 超时(秒)
|
||||
}
|
||||
|
||||
// Config is the top-level configuration structure.
|
||||
type Config struct {
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
@@ -88,6 +100,7 @@ type Config struct {
|
||||
POP3 POP3Config `toml:"pop3"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
Ban BanConfig `toml:"ban"`
|
||||
Outbound OutboundConfig `toml:"outbound"`
|
||||
}
|
||||
|
||||
// isWindows returns true if the current OS is Windows.
|
||||
@@ -157,6 +170,15 @@ func defaultConfig() *Config {
|
||||
MaxFailAttempts: 5,
|
||||
BanDurationMin: 30,
|
||||
},
|
||||
Outbound: OutboundConfig{
|
||||
PollInterval: 15, // 15 秒扫描一次队列
|
||||
MaxAttempts: 12, // 最多尝试 12 次
|
||||
RetryBaseMin: 5, // 5/10/20/40/... 分钟指数退避
|
||||
MaxRecipients: 50, // 单封最多 50 个外部收件人
|
||||
MaxPerMin: 30, // 每用户每分钟 30 封
|
||||
MaxPerDay: 500,
|
||||
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +239,27 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
|
||||
if cfg.Ban.BanDurationMin == 0 {
|
||||
cfg.Ban.BanDurationMin = defaults.Ban.BanDurationMin
|
||||
}
|
||||
if cfg.Outbound.PollInterval == 0 {
|
||||
cfg.Outbound.PollInterval = defaults.Outbound.PollInterval
|
||||
}
|
||||
if cfg.Outbound.MaxAttempts == 0 {
|
||||
cfg.Outbound.MaxAttempts = defaults.Outbound.MaxAttempts
|
||||
}
|
||||
if cfg.Outbound.RetryBaseMin == 0 {
|
||||
cfg.Outbound.RetryBaseMin = defaults.Outbound.RetryBaseMin
|
||||
}
|
||||
if cfg.Outbound.MaxRecipients == 0 {
|
||||
cfg.Outbound.MaxRecipients = defaults.Outbound.MaxRecipients
|
||||
}
|
||||
if cfg.Outbound.MaxPerMin == 0 {
|
||||
cfg.Outbound.MaxPerMin = defaults.Outbound.MaxPerMin
|
||||
}
|
||||
if cfg.Outbound.MaxPerDay == 0 {
|
||||
cfg.Outbound.MaxPerDay = defaults.Outbound.MaxPerDay
|
||||
}
|
||||
if cfg.Outbound.ConnectTimeout == 0 {
|
||||
cfg.Outbound.ConnectTimeout = defaults.Outbound.ConnectTimeout
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@ 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-msgauth v0.7.0
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
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/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.35.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
@@ -23,11 +28,9 @@ require (
|
||||
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-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 // 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
|
||||
@@ -54,8 +57,6 @@ require (
|
||||
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/oauth2 v0.36.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
|
||||
)
|
||||
@@ -4,6 +4,8 @@ github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
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/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
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=
|
||||
@@ -20,6 +22,8 @@ github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5N
|
||||
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-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
@@ -65,6 +69,20 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
|
||||
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/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
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=
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
||||
}
|
||||
|
||||
// Auto-migrate all models
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+50
-16
@@ -48,22 +48,22 @@ func (Domain) TableName() string {
|
||||
|
||||
// Message represents an email message in the system.
|
||||
type Message struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
MessageID string `gorm:"size:255;index" json:"message_id"`
|
||||
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
|
||||
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
|
||||
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
|
||||
CcAddr string `gorm:"size:2048" json:"cc_addr"`
|
||||
Subject string `gorm:"size:1024" json:"subject"`
|
||||
TextBody string `gorm:"type:text" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:text" json:"html_body"`
|
||||
RawData string `gorm:"type:mediumtext" json:"raw_data"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||
Date time.Time `json:"date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
MessageID string `gorm:"size:255;index" json:"message_id"`
|
||||
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
|
||||
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
|
||||
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
|
||||
CcAddr string `gorm:"size:2048" json:"cc_addr"`
|
||||
Subject string `gorm:"size:1024" json:"subject"`
|
||||
TextBody string `gorm:"type:text" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:text" json:"html_body"`
|
||||
RawData string `gorm:"type:mediumtext" json:"raw_data"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||
Date time.Time `json:"date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for Message.
|
||||
@@ -71,6 +71,40 @@ func (Message) TableName() string {
|
||||
return "messages"
|
||||
}
|
||||
|
||||
// Outbound message delivery statuses.
|
||||
const (
|
||||
OutboundStatusPending = "pending" // 等待发送
|
||||
OutboundStatusSending = "sending" // 发送中
|
||||
OutboundStatusSent = "sent" // 已送达
|
||||
OutboundStatusDeferred = "deferred" // 临时失败,等待重试
|
||||
OutboundStatusFailed = "failed" // 永久失败/超过重试上限
|
||||
OutboundStatusCanceled = "canceled" // 管理员取消
|
||||
)
|
||||
|
||||
// OutboundMessage represents a message queued for delivery to an external domain.
|
||||
type OutboundMessage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MessageID string `gorm:"size:255;index" json:"message_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"` // 发件用户 ID(Web/SMTP 提交用户)
|
||||
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
|
||||
ToAddr string `gorm:"size:512;not null" json:"to_addr"`
|
||||
RecipientDom string `gorm:"size:255;index" json:"recipient_dom"`
|
||||
RawData string `gorm:"type:mediumtext" json:"-"` // DKIM 签名后的完整邮件
|
||||
Status string `gorm:"size:32;default:pending;index" json:"status"`
|
||||
Attempts int `gorm:"default:0" json:"attempts"`
|
||||
NextAttemptAt time.Time `gorm:"index" json:"next_attempt_at"`
|
||||
LastResponse string `gorm:"size:1024" json:"last_response"`
|
||||
LastError string `gorm:"size:1024" json:"last_error"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for OutboundMessage.
|
||||
func (OutboundMessage) TableName() string {
|
||||
return "outbound_messages"
|
||||
}
|
||||
|
||||
// BanEntry represents an IP address that has been banned due to excessive login failures.
|
||||
type BanEntry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// Package outbound implements external (outbound) email delivery.
|
||||
//
|
||||
// Messages queued for external recipients are stored in the outbound_messages
|
||||
// table and delivered by the Manager's background worker: MX lookup, SMTP
|
||||
// transaction over port 25 with opportunistic STARTTLS, exponential backoff
|
||||
// retries, permanent-failure bounces and DKIM signing.
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeliveryError wraps an SMTP delivery failure and records whether it is
|
||||
// permanent (5xx / NXDOMAIN / invalid address) or temporary (4xx / network /
|
||||
// timeout). Temporary failures are retried by the queue worker.
|
||||
type DeliveryError struct {
|
||||
Permanent bool
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *DeliveryError) Error() string {
|
||||
if e.Code > 0 {
|
||||
return fmt.Sprintf("%d %s", e.Code, e.Msg)
|
||||
}
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
// newTempError creates a temporary delivery error.
|
||||
func newTempError(format string, args ...interface{}) *DeliveryError {
|
||||
return &DeliveryError{Permanent: false, Msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// newPermError creates a permanent delivery error.
|
||||
func newPermError(format string, args ...interface{}) *DeliveryError {
|
||||
return &DeliveryError{Permanent: true, Msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// Mailer performs direct MX delivery of a single message.
|
||||
type Mailer struct {
|
||||
Hostname string // EHLO hostname presented to remote servers
|
||||
ConnectTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewMailer creates a Mailer with the given EHLO hostname and connect timeout.
|
||||
func NewMailer(hostname string, connectTimeout time.Duration) *Mailer {
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
return &Mailer{Hostname: hostname, ConnectTimeout: connectTimeout}
|
||||
}
|
||||
|
||||
// Deliver sends one message to one recipient via the recipient domain's MX.
|
||||
// It returns the final SMTP response text on success and a *DeliveryError on
|
||||
// failure.
|
||||
func (m *Mailer) Deliver(from, to string, data []byte) (string, error) {
|
||||
at := strings.LastIndex(to, "@")
|
||||
if at < 0 || at == len(to)-1 {
|
||||
return "", newPermError("invalid recipient address: %s", to)
|
||||
}
|
||||
domain := strings.ToLower(strings.TrimSpace(to[at+1:]))
|
||||
|
||||
mxHosts, err := lookupMX(domain)
|
||||
if err != nil {
|
||||
var de *DeliveryError
|
||||
if errors.As(err, &de) {
|
||||
return "", de
|
||||
}
|
||||
return "", newTempError("MX lookup failed for %s: %v", domain, err)
|
||||
}
|
||||
|
||||
var lastErr *DeliveryError
|
||||
for _, host := range mxHosts {
|
||||
resp, err := m.deliverToHost(host, from, to, data)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
var de *DeliveryError
|
||||
if errors.As(err, &de) {
|
||||
lastErr = de
|
||||
// A permanent failure from one MX applies to the whole message,
|
||||
// do not try other MX hosts.
|
||||
if de.Permanent {
|
||||
return "", de
|
||||
}
|
||||
continue
|
||||
}
|
||||
lastErr = newTempError("delivery to %s failed: %v", host, err)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = newTempError("no MX hosts available for %s", domain)
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// smtpClient wraps a textproto connection to a remote SMTP server.
|
||||
type smtpClient struct {
|
||||
conn net.Conn
|
||||
txt *textproto.Conn
|
||||
host string
|
||||
exts map[string]string // advertised EHLO extensions (upper-case key -> params)
|
||||
}
|
||||
|
||||
func (c *smtpClient) Close() {
|
||||
if c.txt != nil {
|
||||
_ = c.txt.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// cmd sends a command and expects the given reply codes, returning the
|
||||
// response text. Codes other than expected are returned as a DeliveryError.
|
||||
func (c *smtpClient) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
|
||||
if err := c.txt.PrintfLine(format, args...); err != nil {
|
||||
return 0, "", newTempError("write to %s failed: %v", c.host, err)
|
||||
}
|
||||
code, msg, err := c.txt.ReadResponse(expectCode)
|
||||
if err != nil {
|
||||
return code, msg, classifyResponse(err, msg)
|
||||
}
|
||||
return code, msg, nil
|
||||
}
|
||||
|
||||
// classifyResponse converts a textproto error (wrong reply code) into a
|
||||
// DeliveryError, keeping the actual SMTP code and text.
|
||||
func classifyResponse(err error, fallback string) *DeliveryError {
|
||||
var protoErr *textproto.Error
|
||||
if errors.As(err, &protoErr) {
|
||||
return &DeliveryError{
|
||||
Permanent: protoErr.Code >= 500,
|
||||
Code: protoErr.Code,
|
||||
Msg: protoErr.Msg,
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
return newTempError("%s", fallback)
|
||||
}
|
||||
return newTempError("%v", err)
|
||||
}
|
||||
|
||||
// hello sends EHLO and records the advertised extensions. If EHLO fails it
|
||||
// falls back to HELO for very old servers.
|
||||
func (c *smtpClient) hello(hostname string) error {
|
||||
if err := c.txt.PrintfLine("EHLO %s", hostname); err != nil {
|
||||
return newTempError("write EHLO to %s failed: %v", c.host, err)
|
||||
}
|
||||
code, msg, err := c.txt.ReadResponse(250)
|
||||
if err != nil {
|
||||
// Fall back to HELO.
|
||||
if err := c.txt.PrintfLine("HELO %s", hostname); err != nil {
|
||||
return newTempError("write HELO to %s failed: %v", c.host, err)
|
||||
}
|
||||
code, msg, err = c.txt.ReadResponse(250)
|
||||
if err != nil {
|
||||
return classifyResponse(err, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_ = code
|
||||
c.exts = map[string]string{}
|
||||
for _, line := range strings.Split(msg, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
key := strings.ToUpper(parts[0])
|
||||
val := ""
|
||||
if len(parts) == 2 {
|
||||
val = parts[1]
|
||||
}
|
||||
c.exts[key] = val
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverToHost performs a full SMTP transaction with a single MX host.
|
||||
func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, error) {
|
||||
addr := net.JoinHostPort(host, "25")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{Timeout: m.ConnectTimeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return "", newTempError("connect to %s failed: %v", addr, err)
|
||||
}
|
||||
|
||||
c := &smtpClient{conn: conn, txt: textproto.NewConn(conn), host: host}
|
||||
defer c.Close()
|
||||
|
||||
// Read greeting (expect 220).
|
||||
if _, msg, err := c.txt.ReadResponse(220); err != nil {
|
||||
return "", classifyResponse(err, msg)
|
||||
}
|
||||
|
||||
if err := c.hello(m.Hostname); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Opportunistic STARTTLS (RFC 3207): only when the server advertises it.
|
||||
if _, ok := c.exts["STARTTLS"]; ok {
|
||||
if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: true, // remote MX certificates often cannot be verified
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return "", newTempError("TLS handshake with %s failed: %v", host, err)
|
||||
}
|
||||
c.txt = textproto.NewConn(tlsConn)
|
||||
if err := c.hello(m.Hostname); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// MAIL FROM with BODY=8BITMIME when the message contains 8-bit bytes and
|
||||
// the remote server supports it.
|
||||
mailCmd := "MAIL FROM:<%s>"
|
||||
if is8Bit(data) {
|
||||
if _, ok := c.exts["8BITMIME"]; ok {
|
||||
mailCmd = "MAIL FROM:<%s> BODY=8BITMIME"
|
||||
} else {
|
||||
return "", newPermError("%s does not advertise 8BITMIME and the message contains 8-bit data", host)
|
||||
}
|
||||
}
|
||||
if _, _, err := c.cmd(250, mailCmd, from); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, _, err := c.cmd(250, "RCPT TO:<%s>", to); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, _, err := c.cmd(354, "DATA"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Write the message body with dot-stuffing.
|
||||
dw := c.txt.DotWriter()
|
||||
if _, err := dw.Write(data); err != nil {
|
||||
_ = dw.Close()
|
||||
return "", newTempError("writing message data to %s failed: %v", host, err)
|
||||
}
|
||||
if err := dw.Close(); err != nil {
|
||||
return "", newTempError("finalizing message data to %s failed: %v", host, err)
|
||||
}
|
||||
|
||||
code, msg, err := c.txt.ReadResponse(250)
|
||||
if err != nil {
|
||||
return "", classifyResponse(err, msg)
|
||||
}
|
||||
|
||||
// Best-effort QUIT.
|
||||
_ = c.txt.PrintfLine("QUIT")
|
||||
_, _, _ = c.txt.ReadResponse(221)
|
||||
|
||||
return fmt.Sprintf("%d %s", code, msg), nil
|
||||
}
|
||||
|
||||
// is8Bit reports whether the data contains any byte >= 0x80.
|
||||
func is8Bit(data []byte) bool {
|
||||
for _, b := range data {
|
||||
if b >= 0x80 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lookupMX resolves the MX hosts for a domain, sorted by preference.
|
||||
// Per RFC 5321 section 5.1, when no MX record exists the domain itself is
|
||||
// used as an implicit MX with preference 0.
|
||||
func lookupMX(domain string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mxs, err := net.DefaultResolver.LookupMX(ctx, domain)
|
||||
if err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
return nil, newPermError("domain does not exist: %s", domain)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(mxs) == 0 {
|
||||
// Implicit MX: fall back to the domain's A/AAAA records.
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
hosts = append(hosts, ip.String())
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("no MX or A records for %s", domain)
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
sort.Slice(mxs, func(i, j int) bool { return mxs[i].Pref < mxs[j].Pref })
|
||||
hosts := make([]string, 0, len(mxs))
|
||||
for _, mx := range mxs {
|
||||
h := strings.TrimSuffix(mx.Host, ".")
|
||||
if h != "" {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("no usable MX hosts for %s", domain)
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Manager orchestrates the outbound delivery queue: enqueueing messages,
|
||||
// background delivery worker, exponential backoff retries, DKIM signing,
|
||||
// per-user rate limits and failure bounces.
|
||||
type Manager struct {
|
||||
cfg config.OutboundConfig
|
||||
hostname string // EHLO hostname
|
||||
mailer *Mailer
|
||||
stores *store.Stores
|
||||
|
||||
kick chan struct{}
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
lim map[uint]*userWindow
|
||||
batch int
|
||||
}
|
||||
|
||||
// userWindow tracks a user's sending rate within fixed windows.
|
||||
type userWindow struct {
|
||||
minuteStart time.Time
|
||||
minuteCount int
|
||||
dayStart time.Time
|
||||
dayCount int
|
||||
}
|
||||
|
||||
// NewManager creates an outbound delivery Manager.
|
||||
// hostname is the EHLO name presented to remote servers (defaults to "localhost").
|
||||
func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores) *Manager {
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
hostname: hostname,
|
||||
mailer: NewMailer(hostname, time.Duration(cfg.ConnectTimeout)*time.Second),
|
||||
stores: stores,
|
||||
kick: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
lim: make(map[uint]*userWindow),
|
||||
batch: 50,
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Start launches the background delivery worker.
|
||||
func (m *Manager) Start() {
|
||||
interval := time.Duration(m.cfg.PollInterval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("outbound: delivery worker started (interval=%s, max_attempts=%d)", interval, m.cfg.MaxAttempts)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.processDue()
|
||||
case <-m.kick:
|
||||
m.processDue()
|
||||
case <-m.stop:
|
||||
close(m.done)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop gracefully stops the delivery worker.
|
||||
func (m *Manager) Stop() {
|
||||
m.once.Do(func() {
|
||||
close(m.stop)
|
||||
})
|
||||
<-m.done
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
// kickWorker nudges the worker to scan the queue immediately.
|
||||
func (m *Manager) kickWorker() {
|
||||
select {
|
||||
case m.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether external delivery is configured on.
|
||||
func (m *Manager) Enabled() bool {
|
||||
return m.cfg.MaxPerDay > 0
|
||||
}
|
||||
|
||||
// MaxRecipients returns the maximum number of external recipients allowed
|
||||
// per message (0 means unlimited).
|
||||
func (m *Manager) MaxRecipients() int {
|
||||
return m.cfg.MaxRecipients
|
||||
}
|
||||
|
||||
// Enqueue validates a sender/recipient pair, DKIM-signs the message once and
|
||||
// stores it in the outbound queue for background delivery. The recipient must
|
||||
// NOT be a local address — callers decide local vs external routing.
|
||||
// Returns a permanent-style error for invalid input or rate-limit violations.
|
||||
func (m *Manager) Enqueue(senderUser *db.User, from, to string, raw []byte) (*db.OutboundMessage, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, fmt.Errorf("外部投递未启用")
|
||||
}
|
||||
|
||||
to = strings.TrimSpace(to)
|
||||
addr, err := mail.ParseAddress(to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("收件人地址无效: %s", to)
|
||||
}
|
||||
to = addr.Address
|
||||
|
||||
at := strings.LastIndex(to, "@")
|
||||
if at < 0 || at == len(to)-1 {
|
||||
return nil, fmt.Errorf("收件人地址无效: %s", to)
|
||||
}
|
||||
recipientDom := strings.ToLower(to[at+1:])
|
||||
|
||||
// Local addresses must never enter the outbound queue.
|
||||
if _, err := m.stores.Users.GetByEmail(to); err == nil {
|
||||
return nil, fmt.Errorf("收件人 %s 是本地地址,应走本地投递", to)
|
||||
}
|
||||
|
||||
// Rate limiting per sender user.
|
||||
if senderUser != nil {
|
||||
if err := m.checkRateLimit(senderUser.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM-sign once with the sender domain's key.
|
||||
signed, err := m.signForSender(from, raw)
|
||||
if err != nil {
|
||||
log.Printf("outbound: DKIM signing failed for %s: %v", from, err)
|
||||
signed = raw
|
||||
}
|
||||
|
||||
item := &db.OutboundMessage{
|
||||
MessageID: fmt.Sprintf("<%s@outbound>", uuid.New().String()),
|
||||
UserID: userIDOrZero(senderUser),
|
||||
FromAddr: from,
|
||||
ToAddr: to,
|
||||
RecipientDom: recipientDom,
|
||||
RawData: string(signed),
|
||||
Status: db.OutboundStatusPending,
|
||||
Attempts: 0,
|
||||
NextAttemptAt: time.Now(),
|
||||
}
|
||||
if err := m.stores.Outbound.Create(item); err != nil {
|
||||
return nil, fmt.Errorf("写入外发队列失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("outbound: queued %s -> %s (id=%d)", from, to, item.ID)
|
||||
m.kickWorker()
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func userIDOrZero(u *db.User) uint {
|
||||
if u == nil {
|
||||
return 0
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// signForSender looks up the sender domain's DKIM key and signs the message.
|
||||
func (m *Manager) signForSender(from string, raw []byte) ([]byte, error) {
|
||||
at := strings.LastIndex(from, "@")
|
||||
if at < 0 || at == len(from)-1 {
|
||||
return raw, fmt.Errorf("无效发件人地址: %s", from)
|
||||
}
|
||||
domName := strings.ToLower(from[at+1:])
|
||||
|
||||
domain, err := m.stores.Domains.GetByName(domName)
|
||||
if err != nil {
|
||||
return raw, nil // domain not managed locally; send unsigned
|
||||
}
|
||||
return SignDKIM(raw, domain.Name, domain.DkimSelector, domain.DkimPrivateKey)
|
||||
}
|
||||
|
||||
// checkRateLimit enforces per-user minute/day sending limits.
|
||||
func (m *Manager) checkRateLimit(userID uint) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
w := m.lim[userID]
|
||||
if w == nil || now.Sub(w.dayStart) >= 24*time.Hour {
|
||||
w = &userWindow{minuteStart: now, dayStart: now}
|
||||
m.lim[userID] = w
|
||||
} else if now.Sub(w.minuteStart) >= time.Minute {
|
||||
w.minuteStart = now
|
||||
w.minuteCount = 0
|
||||
}
|
||||
|
||||
if m.cfg.MaxPerMin > 0 && w.minuteCount >= m.cfg.MaxPerMin {
|
||||
log.Printf("outbound: rate limit exceeded (per minute) for user %d", userID)
|
||||
return fmt.Errorf("发送频率超限:每分钟最多 %d 封", m.cfg.MaxPerMin)
|
||||
}
|
||||
if m.cfg.MaxPerDay > 0 && w.dayCount >= m.cfg.MaxPerDay {
|
||||
log.Printf("outbound: rate limit exceeded (per day) for user %d", userID)
|
||||
return fmt.Errorf("发送频率超限:每日最多 %d 封", m.cfg.MaxPerDay)
|
||||
}
|
||||
|
||||
w.minuteCount++
|
||||
w.dayCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
// processDue attempts delivery of all due queue items.
|
||||
func (m *Manager) processDue() {
|
||||
items, err := m.stores.Outbound.ListDue(time.Now(), m.batch)
|
||||
if err != nil {
|
||||
log.Printf("outbound: loading due queue failed: %v", err)
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
m.deliverOne(&items[i])
|
||||
}
|
||||
}
|
||||
|
||||
// deliverOne performs a single delivery attempt for a queue item.
|
||||
func (m *Manager) deliverOne(item *db.OutboundMessage) {
|
||||
// Mark as sending to avoid concurrent workers double-delivering.
|
||||
item.Status = db.OutboundStatusSending
|
||||
if err := m.stores.Outbound.Update(item); err != nil {
|
||||
log.Printf("outbound: update item %d to sending failed: %v", item.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := m.mailer.Deliver(item.FromAddr, item.ToAddr, []byte(item.RawData))
|
||||
|
||||
now := time.Now()
|
||||
item.Attempts++
|
||||
|
||||
if err == nil {
|
||||
item.Status = db.OutboundStatusSent
|
||||
item.LastResponse = resp
|
||||
item.LastError = ""
|
||||
item.CompletedAt = &now
|
||||
if saveErr := m.stores.Outbound.Update(item); saveErr != nil {
|
||||
log.Printf("outbound: update item %d to sent failed: %v", item.ID, saveErr)
|
||||
return
|
||||
}
|
||||
log.Printf("outbound: delivered %s -> %s (id=%d, attempts=%d)", item.FromAddr, item.ToAddr, item.ID, item.Attempts)
|
||||
return
|
||||
}
|
||||
|
||||
var de *DeliveryError
|
||||
permanent := false
|
||||
if ok := asDeliveryError(err, &de); ok {
|
||||
permanent = de.Permanent
|
||||
}
|
||||
item.LastResponse = ""
|
||||
item.LastError = err.Error()
|
||||
|
||||
if permanent || item.Attempts >= m.cfg.MaxAttempts {
|
||||
item.Status = db.OutboundStatusFailed
|
||||
item.CompletedAt = &now
|
||||
if saveErr := m.stores.Outbound.Update(item); saveErr != nil {
|
||||
log.Printf("outbound: update item %d to failed failed: %v", item.ID, saveErr)
|
||||
return
|
||||
}
|
||||
log.Printf("outbound: permanent failure %s -> %s (id=%d): %v", item.FromAddr, item.ToAddr, item.ID, err)
|
||||
m.bounce(item, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Temporary failure: exponential backoff retry.
|
||||
item.Status = db.OutboundStatusDeferred
|
||||
backoff := time.Duration(m.cfg.RetryBaseMin) * time.Minute
|
||||
backoff <<= (item.Attempts - 1)
|
||||
if backoff > 24*time.Hour {
|
||||
backoff = 24 * time.Hour
|
||||
}
|
||||
item.NextAttemptAt = now.Add(backoff)
|
||||
if saveErr := m.stores.Outbound.Update(item); saveErr != nil {
|
||||
log.Printf("outbound: update item %d to deferred failed: %v", item.ID, saveErr)
|
||||
return
|
||||
}
|
||||
log.Printf("outbound: temporary failure %s -> %s (id=%d, attempt=%d, retry in %s): %v",
|
||||
item.FromAddr, item.ToAddr, item.ID, item.Attempts, backoff, err)
|
||||
}
|
||||
|
||||
// bounce delivers a non-delivery notice to the sender's INBOX.
|
||||
func (m *Manager) bounce(item *db.OutboundMessage, deliveryErr error) {
|
||||
sender, err := m.stores.Users.GetByEmail(item.FromAddr)
|
||||
if err != nil {
|
||||
log.Printf("outbound: cannot bounce %s: sender is not a local user", item.FromAddr)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
postmaster := "Mail Delivery System <postmaster@" + m.hostname + ">"
|
||||
subject := fmt.Sprintf("邮件投递失败: %s", item.ToAddr)
|
||||
|
||||
body := "这是一封系统退信通知。\r\n\r\n" +
|
||||
fmt.Sprintf("您的邮件未能投递到以下收件人:\r\n\r\n 收件人:%s\r\n 失败原因:%s\r\n 投递时间:%s\r\n 尝试次数:%d\r\n\r\n",
|
||||
item.ToAddr, deliveryErr.Error(), now.Format("2006-01-02 15:04:05"), item.Attempts) +
|
||||
"如果收件人地址无误,请稍后重试;连续失败可能表示收件地址不存在或对方服务器拒收。\r\n"
|
||||
|
||||
msg := &db.Message{
|
||||
UserID: sender.ID,
|
||||
MessageID: fmt.Sprintf("<bounce-%d@%s>", item.ID, m.hostname),
|
||||
Folder: "INBOX",
|
||||
FromAddr: postmaster,
|
||||
ToAddr: item.FromAddr,
|
||||
Subject: subject,
|
||||
TextBody: body,
|
||||
Date: now,
|
||||
IsRead: false,
|
||||
}
|
||||
if err := m.stores.Mails.Create(msg); err != nil {
|
||||
log.Printf("outbound: bounce message creation failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("outbound: bounce delivered to %s for failed delivery of %s", item.FromAddr, item.ToAddr)
|
||||
}
|
||||
|
||||
// Retry resets a failed/deferred queue item for immediate redelivery.
|
||||
func (m *Manager) Retry(id uint) error {
|
||||
item, err := m.stores.Outbound.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.Status = db.OutboundStatusPending
|
||||
item.Attempts = 0
|
||||
item.LastError = ""
|
||||
item.LastResponse = ""
|
||||
item.CompletedAt = nil
|
||||
item.NextAttemptAt = time.Now()
|
||||
if err := m.stores.Outbound.Update(item); err != nil {
|
||||
return err
|
||||
}
|
||||
m.kickWorker()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel marks a queue item as canceled by the administrator.
|
||||
func (m *Manager) Cancel(id uint) error {
|
||||
item, err := m.stores.Outbound.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Status == db.OutboundStatusSent {
|
||||
return fmt.Errorf("已送达的邮件无法取消")
|
||||
}
|
||||
now := time.Now()
|
||||
item.Status = db.OutboundStatusCanceled
|
||||
item.LastError = "管理员取消"
|
||||
item.CompletedAt = &now
|
||||
return m.stores.Outbound.Update(item)
|
||||
}
|
||||
|
||||
// asDeliveryError extracts a *DeliveryError from an error chain.
|
||||
func asDeliveryError(err error, target **DeliveryError) bool {
|
||||
for err != nil {
|
||||
if de, ok := err.(*DeliveryError); ok {
|
||||
*target = de
|
||||
return true
|
||||
}
|
||||
type unwrapper interface{ Unwrap() error }
|
||||
u, ok := err.(unwrapper)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
err = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StatusText returns a human-readable Chinese label for a queue status.
|
||||
func StatusText(status string) string {
|
||||
switch status {
|
||||
case db.OutboundStatusPending:
|
||||
return "待发送"
|
||||
case db.OutboundStatusSending:
|
||||
return "发送中"
|
||||
case db.OutboundStatusSent:
|
||||
return "已送达"
|
||||
case db.OutboundStatusDeferred:
|
||||
return "等待重试"
|
||||
case db.OutboundStatusFailed:
|
||||
return "失败"
|
||||
case db.OutboundStatusCanceled:
|
||||
return "已取消"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
)
|
||||
|
||||
// SignDKIM signs a raw RFC 5322 message with the domain's DKIM private key.
|
||||
// When the private key is empty the message is returned unchanged (unsigned).
|
||||
// The signature covers the standard header fields present in the message.
|
||||
func SignDKIM(raw []byte, domainName, selector, privateKeyPEM string) ([]byte, error) {
|
||||
if privateKeyPEM == "" {
|
||||
return raw, nil
|
||||
}
|
||||
if domainName == "" {
|
||||
return raw, fmt.Errorf("DKIM domain is empty")
|
||||
}
|
||||
if selector == "" {
|
||||
selector = "default"
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return raw, fmt.Errorf("invalid DKIM private key PEM for %s", domainName)
|
||||
}
|
||||
|
||||
signer, err := parseSigner(block)
|
||||
if err != nil {
|
||||
return raw, fmt.Errorf("parse DKIM private key for %s: %v", domainName, err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
options := &dkim.SignOptions{
|
||||
Domain: domainName,
|
||||
Selector: selector,
|
||||
Signer: signer,
|
||||
}
|
||||
if err := dkim.Sign(&out, bytes.NewReader(raw), options); err != nil {
|
||||
return raw, fmt.Errorf("DKIM signing for %s failed: %v", domainName, err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// parseSigner parses an RSA private key PEM block into a crypto.Signer.
|
||||
// Both PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") are supported.
|
||||
func parseSigner(block *pem.Block) (crypto.Signer, error) {
|
||||
// PKCS#1 is what the domain management form stores.
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// PKCS#8 fallback for externally generated keys.
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer, ok := parsed.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported private key type %T", parsed)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dkimgen "mail_go/internal/dkim"
|
||||
|
||||
msgauthdkim "github.com/emersion/go-msgauth/dkim"
|
||||
)
|
||||
|
||||
func TestSignDKIMAndVerify(t *testing.T) {
|
||||
privPEM, pubPEM, err := dkimgen.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
_ = pubPEM
|
||||
|
||||
raw := []byte("From: kevin@lmve.net\r\nTo: someone@example.com\r\nSubject: hello\r\n\r\nbody\r\n")
|
||||
|
||||
signed, err := SignDKIM(raw, "lmve.net", "default", privPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignDKIM: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(signed), "DKIM-Signature") {
|
||||
t.Fatalf("signed message missing DKIM-Signature header")
|
||||
}
|
||||
|
||||
verifications, err := msgauthdkim.Verify(strings.NewReader(string(signed)))
|
||||
if err != nil {
|
||||
t.Fatalf("dkim.Verify: %v", err)
|
||||
}
|
||||
if len(verifications) != 1 {
|
||||
t.Fatalf("expected 1 signature, got %d", len(verifications))
|
||||
}
|
||||
if verifications[0].Domain != "lmve.net" {
|
||||
t.Fatalf("unexpected signature: %+v", verifications[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignDKIMEmptyKeyReturnsUnsigned(t *testing.T) {
|
||||
raw := []byte("From: kevin@lmve.net\r\nTo: someone@example.com\r\nSubject: hello\r\n\r\nbody\r\n")
|
||||
out, err := SignDKIM(raw, "lmve.net", "default", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignDKIM with empty key should not fail: %v", err)
|
||||
}
|
||||
if string(out) != string(raw) {
|
||||
t.Fatalf("message changed despite empty key")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
@@ -30,14 +31,15 @@ const (
|
||||
|
||||
// SMTPServer wraps go-smtp servers and provides local mail delivery.
|
||||
type SMTPServer struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
cfg config.SMTPConfig
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
outbound *outbound.Manager
|
||||
cfg config.SMTPConfig
|
||||
}
|
||||
|
||||
// NewSMTPServer creates a new SMTP server instance.
|
||||
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *SMTPServer {
|
||||
return &SMTPServer{stores: stores, storage: attStorage, cfg: cfg}
|
||||
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager) *SMTPServer {
|
||||
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
||||
@@ -119,9 +121,12 @@ type smtpSession struct {
|
||||
mode smtpMode
|
||||
from string
|
||||
rcpts []string
|
||||
localRcpts []string
|
||||
externalRcpts []string
|
||||
authenticated bool
|
||||
userID uint
|
||||
email string
|
||||
user *db.User
|
||||
}
|
||||
|
||||
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
||||
@@ -153,6 +158,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
|
||||
s.authenticated = true
|
||||
s.userID = user.ID
|
||||
s.user = user
|
||||
s.email = user.Username + "@" + domainName
|
||||
return nil
|
||||
}), nil
|
||||
@@ -160,30 +166,50 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
|
||||
// Mail records the sender address (MAIL FROM command).
|
||||
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||
if s.mode != smtpModeInbound {
|
||||
if !s.authenticated {
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(from), s.email) {
|
||||
return fmt.Errorf("sender address must match authenticated user")
|
||||
}
|
||||
if s.mode != smtpModeInbound && !s.authenticated {
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
// Authenticated users may only send as themselves, preventing spoofing.
|
||||
if s.authenticated && !strings.EqualFold(strings.TrimSpace(from), s.email) {
|
||||
return fmt.Errorf("sender address must match authenticated user")
|
||||
}
|
||||
|
||||
s.from = from
|
||||
s.rcpts = s.rcpts[:0]
|
||||
s.localRcpts = s.localRcpts[:0]
|
||||
s.externalRcpts = s.externalRcpts[:0]
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rcpt validates and records a recipient address (RCPT TO command).
|
||||
// Local recipients are delivered to the mailbox; external recipients are
|
||||
// allowed only for authenticated users and go to the outbound queue,
|
||||
// which prevents open relay.
|
||||
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
if _, err := s.localUserByEmail(to); err != nil {
|
||||
if s.authenticated {
|
||||
return fmt.Errorf("external relay is not supported yet: %s", to)
|
||||
}
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" {
|
||||
return fmt.Errorf("invalid recipient address: %s", to)
|
||||
}
|
||||
|
||||
if _, err := s.localUserByEmail(to); err == nil {
|
||||
s.rcpts = append(s.rcpts, to)
|
||||
s.localRcpts = append(s.localRcpts, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// External recipient: only authenticated local users may relay.
|
||||
if !s.authenticated {
|
||||
return fmt.Errorf("relay access denied: %s", to)
|
||||
}
|
||||
|
||||
// Sender verification must have been enforced in Mail() already.
|
||||
ob := s.backend.server.outbound
|
||||
if ob == nil || !ob.Enabled() {
|
||||
return fmt.Errorf("external delivery is disabled: %s", to)
|
||||
}
|
||||
|
||||
s.rcpts = append(s.rcpts, to)
|
||||
s.externalRcpts = append(s.externalRcpts, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -192,9 +218,11 @@ func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
|
||||
}
|
||||
|
||||
// Data handles the message body and stores it for local recipients.
|
||||
// External recipients (authenticated sessions only) are queued for
|
||||
// outbound delivery.
|
||||
func (s *smtpSession) Data(r io.Reader) error {
|
||||
if len(s.rcpts) == 0 {
|
||||
return fmt.Errorf("no accepted local recipients")
|
||||
return fmt.Errorf("no accepted recipients")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
@@ -207,7 +235,8 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, rcpt := range s.rcpts {
|
||||
// Local recipients: deliver to INBOX.
|
||||
for _, rcpt := range s.localRcpts {
|
||||
user, err := s.localUserByEmail(rcpt)
|
||||
if err != nil {
|
||||
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
|
||||
@@ -220,6 +249,24 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
log.Printf("SMTP: message delivered to %s", rcpt)
|
||||
}
|
||||
|
||||
// External recipients: queue for outbound delivery.
|
||||
if len(s.externalRcpts) > 0 {
|
||||
ob := s.backend.server.outbound
|
||||
if ob == nil {
|
||||
return fmt.Errorf("outbound delivery is unavailable")
|
||||
}
|
||||
maxRcpt := ob.MaxRecipients()
|
||||
if maxRcpt > 0 && len(s.externalRcpts) > maxRcpt {
|
||||
return fmt.Errorf("too many external recipients: %d (max %d)", len(s.externalRcpts), maxRcpt)
|
||||
}
|
||||
for _, rcpt := range s.externalRcpts {
|
||||
if _, err := ob.Enqueue(s.user, s.email, rcpt, data); err != nil {
|
||||
return fmt.Errorf("failed to queue external recipient %s: %v", rcpt, err)
|
||||
}
|
||||
log.Printf("SMTP: external message queued for %s", rcpt)
|
||||
}
|
||||
}
|
||||
|
||||
if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound {
|
||||
if err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
|
||||
log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err)
|
||||
@@ -336,6 +383,8 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
|
||||
func (s *smtpSession) Reset() {
|
||||
s.from = ""
|
||||
s.rcpts = s.rcpts[:0]
|
||||
s.localRcpts = s.localRcpts[:0]
|
||||
s.externalRcpts = s.externalRcpts[:0]
|
||||
}
|
||||
|
||||
// Logout is called when the SMTP connection is closed.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OutboundStore defines the interface for outbound queue operations.
|
||||
type OutboundStore interface {
|
||||
Create(msg *db.OutboundMessage) error
|
||||
GetByID(id uint) (*db.OutboundMessage, error)
|
||||
ListDue(now time.Time, limit int) ([]db.OutboundMessage, error)
|
||||
List(page, size int, status string) ([]db.OutboundMessage, int64, error)
|
||||
Update(msg *db.OutboundMessage) error
|
||||
Delete(id uint) error
|
||||
CountByStatus(status string) (int64, error)
|
||||
}
|
||||
|
||||
// outboundStoreGorm implements OutboundStore using GORM.
|
||||
type outboundStoreGorm struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// newOutboundStore creates a new GORM-backed OutboundStore.
|
||||
func newOutboundStore(database *gorm.DB) OutboundStore {
|
||||
return &outboundStoreGorm{db: database}
|
||||
}
|
||||
|
||||
// Create inserts a new outbound queue record.
|
||||
func (s *outboundStoreGorm) Create(msg *db.OutboundMessage) error {
|
||||
return s.db.Create(msg).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves an outbound queue record by primary key.
|
||||
func (s *outboundStoreGorm) GetByID(id uint) (*db.OutboundMessage, error) {
|
||||
var msg db.OutboundMessage
|
||||
if err := s.db.First(&msg, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// ListDue retrieves messages that are due for a delivery attempt.
|
||||
func (s *outboundStoreGorm) ListDue(now time.Time, limit int) ([]db.OutboundMessage, error) {
|
||||
var msgs []db.OutboundMessage
|
||||
if err := s.db.
|
||||
Where("status IN (?, ?) AND next_attempt_at <= ?", db.OutboundStatusPending, db.OutboundStatusDeferred, now).
|
||||
Order("next_attempt_at ASC").
|
||||
Limit(limit).
|
||||
Find(&msgs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// List retrieves a paginated list of outbound messages, optionally filtered by status.
|
||||
func (s *outboundStoreGorm) List(page, size int, status string) ([]db.OutboundMessage, int64, error) {
|
||||
var msgs []db.OutboundMessage
|
||||
var total int64
|
||||
|
||||
query := s.db.Model(&db.OutboundMessage{})
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * size
|
||||
if status != "" {
|
||||
err := s.db.Where("status = ?", status).Order("id DESC").Offset(offset).Limit(size).Find(&msgs).Error
|
||||
return msgs, total, err
|
||||
}
|
||||
if err := s.db.Order("id DESC").Offset(offset).Limit(size).Find(&msgs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return msgs, total, nil
|
||||
}
|
||||
|
||||
// Update saves changes to an existing outbound queue record.
|
||||
func (s *outboundStoreGorm) Update(msg *db.OutboundMessage) error {
|
||||
return s.db.Save(msg).Error
|
||||
}
|
||||
|
||||
// Delete removes an outbound queue record by ID.
|
||||
func (s *outboundStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.OutboundMessage{}, id).Error
|
||||
}
|
||||
|
||||
// CountByStatus returns the number of outbound messages in a given status.
|
||||
func (s *outboundStoreGorm) CountByStatus(status string) (int64, error) {
|
||||
var count int64
|
||||
if err := s.db.Model(&db.OutboundMessage{}).Where("status = ?", status).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type Stores struct {
|
||||
Domains DomainStore
|
||||
Attachments AttachmentStore
|
||||
Bans BanStore
|
||||
Outbound OutboundStore
|
||||
}
|
||||
|
||||
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
||||
@@ -23,6 +24,7 @@ func NewStores(database *gorm.DB) *Stores {
|
||||
Domains: newDomainStore(database),
|
||||
Attachments: newAttachmentStore(database),
|
||||
Bans: newBanStore(database),
|
||||
Outbound: newOutboundStore(database),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/dkim"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
@@ -22,14 +23,16 @@ import (
|
||||
|
||||
// AdminHandler handles admin-related routes (dashboard, domain/user management).
|
||||
type AdminHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
tlsDir string
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
tlsDir string
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores and attachment storage.
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir}
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores, attachment
|
||||
// storage, TLS directory and outbound delivery manager.
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, ob *outbound.Manager) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, outbound: ob}
|
||||
}
|
||||
|
||||
// Dashboard renders the admin dashboard with summary statistics.
|
||||
@@ -761,6 +764,93 @@ func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
|
||||
c.Data(http.StatusOK, att.ContentType, data)
|
||||
}
|
||||
|
||||
// ListOutbound renders the outbound delivery queue page.
|
||||
func (h *AdminHandler) ListOutbound(c *gin.Context) {
|
||||
page := getPageParam(c, "page", 1)
|
||||
status := c.Query("status")
|
||||
|
||||
items, total, err := h.stores.Outbound.List(page, 20, status)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载外发队列失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Queue statistics for the summary cards.
|
||||
statCounts := make(map[string]int64)
|
||||
for _, s := range []string{
|
||||
db.OutboundStatusPending,
|
||||
db.OutboundStatusDeferred,
|
||||
db.OutboundStatusSent,
|
||||
db.OutboundStatusFailed,
|
||||
} {
|
||||
n, _ := h.stores.Outbound.CountByStatus(s)
|
||||
statCounts[s] = n
|
||||
}
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_outbound", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"status": status,
|
||||
"statCounts": statCounts,
|
||||
"statusText": outbound.StatusText,
|
||||
"activeFolder": "outbound",
|
||||
})
|
||||
}
|
||||
|
||||
// RetryOutbound resets an outbound queue item for immediate redelivery.
|
||||
func (h *AdminHandler) RetryOutbound(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的队列ID")
|
||||
return
|
||||
}
|
||||
|
||||
if h.outbound == nil {
|
||||
c.String(http.StatusInternalServerError, "外发服务不可用")
|
||||
return
|
||||
}
|
||||
if err := h.outbound.Retry(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "重试失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/outbound")
|
||||
}
|
||||
|
||||
// CancelOutbound cancels a queued outbound message.
|
||||
func (h *AdminHandler) CancelOutbound(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的队列ID")
|
||||
return
|
||||
}
|
||||
|
||||
if h.outbound == nil {
|
||||
c.String(http.StatusInternalServerError, "外发服务不可用")
|
||||
return
|
||||
}
|
||||
if err := h.outbound.Cancel(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "取消失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/outbound")
|
||||
}
|
||||
|
||||
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||
|
||||
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||
|
||||
+154
-60
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
@@ -18,15 +20,40 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.).
|
||||
type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
// pendingAttachment holds an uploaded attachment while the message is built.
|
||||
type pendingAttachment struct {
|
||||
filename string
|
||||
contentType string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// 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}
|
||||
// base64LineWrap encodes data as base64 wrapped at 76 columns (RFC 2045).
|
||||
func base64LineWrap(data []byte) string {
|
||||
enc := base64.StdEncoding.EncodeToString(data)
|
||||
if len(enc) <= 76 {
|
||||
return enc
|
||||
}
|
||||
var sb strings.Builder
|
||||
for len(enc) > 76 {
|
||||
sb.WriteString(enc[:76])
|
||||
sb.WriteString("\r\n")
|
||||
enc = enc[76:]
|
||||
}
|
||||
sb.WriteString(enc)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.).
|
||||
type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// NewMailHandler creates a new MailHandler with the given stores, attachment
|
||||
// storage and outbound delivery manager.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob}
|
||||
}
|
||||
|
||||
// Inbox renders the inbox page showing all messages in the user's INBOX folder.
|
||||
@@ -161,6 +188,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
|
||||
// Handle attachments and check quota
|
||||
form, multipartErr := c.MultipartForm()
|
||||
attachments := make([]pendingAttachment, 0)
|
||||
if multipartErr == nil {
|
||||
files := form.File["attachments"]
|
||||
if len(files) > 0 {
|
||||
@@ -186,6 +214,32 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Read all attachment files into memory once (used for both the
|
||||
// MIME message body and the stored attachment records).
|
||||
for _, file := range files {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf, readErr := io.ReadAll(f)
|
||||
f.Close()
|
||||
if readErr != 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
|
||||
}
|
||||
|
||||
attachments = append(attachments, pendingAttachment{
|
||||
filename: file.Filename,
|
||||
contentType: contentType,
|
||||
data: buf,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +260,16 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
// Attachments are wrapped in an outer multipart/mixed container.
|
||||
outerBoundary := ""
|
||||
hasAttachments := len(attachments) > 0
|
||||
if hasAttachments {
|
||||
outerBoundary = fmt.Sprintf("----=_Mixed_%s", uuid.New().String())
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", outerBoundary))
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
// Build message body with multipart/alternative if HTML is present
|
||||
if htmlBody != "" {
|
||||
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
|
||||
@@ -222,32 +286,83 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(body)
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// Append attachment parts to the multipart/mixed container.
|
||||
for _, att := range attachments {
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", att.contentType, att.filename))
|
||||
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
sb.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", att.filename))
|
||||
sb.WriteString(base64LineWrap(att.data))
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
if hasAttachments {
|
||||
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
|
||||
localUsers := make([]*db.User, 0, len(allRecipients))
|
||||
var unsupported []string
|
||||
var externalRecipients []string
|
||||
for _, rcpt := range allRecipients {
|
||||
user, err := h.stores.Users.GetByEmail(rcpt)
|
||||
if err != nil {
|
||||
unsupported = append(unsupported, rcpt)
|
||||
externalRecipients = append(externalRecipients, rcpt)
|
||||
continue
|
||||
}
|
||||
localUsers = append(localUsers, user)
|
||||
}
|
||||
if len(unsupported) > 0 {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("暂不支持外部投递: %s", strings.Join(unsupported, ", ")),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
|
||||
// Queue external recipients for outbound delivery first, so that
|
||||
// failures (rate limit, invalid address, disabled outbound) abort
|
||||
// before any local copies are created.
|
||||
if len(externalRecipients) > 0 {
|
||||
ob := h.outbound
|
||||
if ob == nil || !ob.Enabled() {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "外部投递未启用",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, rcpt := range externalRecipients {
|
||||
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(sb.String())); err != nil {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, rcptUser := range localUsers {
|
||||
@@ -312,45 +427,24 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
if multipartErr == 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)
|
||||
// Update user used bytes
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, att.FileSize)
|
||||
// Save attachment records linked to the Sent copy (bytes were already
|
||||
// read during message construction).
|
||||
for _, att := range attachments {
|
||||
relPath, err := h.storage.Save(att.filename, att.data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
attRecord := &db.Attachment{
|
||||
MessageID: msg.ID,
|
||||
FileName: att.filename,
|
||||
FilePath: relPath,
|
||||
ContentType: att.contentType,
|
||||
FileSize: int64(len(att.data)),
|
||||
}
|
||||
_ = h.stores.Attachments.Create(attRecord)
|
||||
// Update user used bytes
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, attRecord.FileSize)
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/sent")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/web/handlers"
|
||||
@@ -44,6 +45,7 @@ type WebServer struct {
|
||||
storageCfg config.StorageConfig
|
||||
authCfg config.AuthConfig
|
||||
banCfg config.BanConfig
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// templateFuncs returns custom template functions for rendering.
|
||||
@@ -82,7 +84,7 @@ func templateFuncs() template.FuncMap {
|
||||
|
||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||
// configures sessions, middleware, and registers all routes.
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig) *WebServer {
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, ob *outbound.Manager) *WebServer {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
@@ -112,6 +114,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
storageCfg: storageCfg,
|
||||
authCfg: authCfg,
|
||||
banCfg: banCfg,
|
||||
outbound: ob,
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
@@ -121,8 +124,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"))
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.outbound)
|
||||
|
||||
// Apply BanMiddleware globally before public routes
|
||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||
@@ -182,6 +185,9 @@ func (ws *WebServer) registerRoutes() {
|
||||
admin.GET("/mails", adminHandler.ListMails)
|
||||
admin.GET("/mails/:id", adminHandler.AdminViewMail)
|
||||
admin.GET("/attachment/:id", adminHandler.AdminDownloadAttachment)
|
||||
admin.GET("/outbound", adminHandler.ListOutbound)
|
||||
admin.POST("/outbound/:id/retry", adminHandler.RetryOutbound)
|
||||
admin.POST("/outbound/:id/cancel", adminHandler.CancelOutbound)
|
||||
admin.GET("/bans", adminHandler.ListBans)
|
||||
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
||||
admin.POST("/bans/cleanup", adminHandler.CleanupBans)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" class="active">所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{{define "admin_outbound"}}
|
||||
<!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" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:24px;">外发队列</h2>
|
||||
|
||||
<div style="margin-bottom:24px;">
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "pending"}}</h3>
|
||||
<p>待发送</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "deferred"}}</h3>
|
||||
<p>等待重试</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "sent"}}</h3>
|
||||
<p>已送达</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "failed"}}</h3>
|
||||
<p>失败</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="margin-bottom:12px;">
|
||||
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">全部</a>
|
||||
<a href="/admin/outbound?status=pending" class="btn btn-sm">待发送</a>
|
||||
<a href="/admin/outbound?status=deferred" class="btn btn-sm">等待重试</a>
|
||||
<a href="/admin/outbound?status=sent" class="btn btn-sm">已送达</a>
|
||||
<a href="/admin/outbound?status=failed" class="btn btn-sm">失败</a>
|
||||
<a href="/admin/outbound?status=canceled" class="btn btn-sm">已取消</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>发件人</th>
|
||||
<th>收件人</th>
|
||||
<th>状态</th>
|
||||
<th>尝试次数</th>
|
||||
<th>下次重试</th>
|
||||
<th>最后响应 / 错误</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .items}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.FromAddr}}</td>
|
||||
<td>{{.ToAddr}}</td>
|
||||
<td>
|
||||
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{call $.statusText .Status}}</span>
|
||||
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{call $.statusText .Status}}</span>
|
||||
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{call $.statusText .Status}}</span>
|
||||
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{call $.statusText .Status}}</span>
|
||||
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
|
||||
</td>
|
||||
<td>{{.Attempts}}</td>
|
||||
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}</td>
|
||||
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
|
||||
<form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">重试</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if or (eq .Status "pending") (eq .Status "deferred")}}
|
||||
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('确认取消该投递任务?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">取消</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">队列为空</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">上一页</a>{{end}}
|
||||
<span class="current">第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">下一页</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/pop3_server"
|
||||
"mail_go/internal/smtp_server"
|
||||
"mail_go/internal/storage"
|
||||
@@ -170,8 +171,17 @@ func main() {
|
||||
applyDomainTLSConfig(stores, cfg)
|
||||
ensureSelfSignedTLSConfig(cfg)
|
||||
|
||||
// 6. Start SMTP server
|
||||
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage)
|
||||
// 6. Outbound delivery manager (external mail queue + worker)
|
||||
outboundMgr := outbound.NewManager(cfg.Outbound, cfg.SMTP.Domain, stores)
|
||||
if outboundMgr.Enabled() {
|
||||
outboundMgr.Start()
|
||||
fmt.Println("外发邮件投递服务已启动")
|
||||
} else {
|
||||
fmt.Println("外发邮件投递未启用(outbound.max_per_day = 0)")
|
||||
}
|
||||
|
||||
// 7. Start SMTP server
|
||||
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr)
|
||||
go func() {
|
||||
if err := smtpSrv.Start(); err != nil {
|
||||
log.Printf("SMTP 服务启动失败: %v", err)
|
||||
@@ -223,8 +233,8 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// 9. Start Web server
|
||||
webServer := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban)
|
||||
// 10. Start Web server
|
||||
webServer := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, outboundMgr)
|
||||
fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr)
|
||||
go func() {
|
||||
if err := webServer.Start(); err != nil {
|
||||
|
||||
@@ -14,60 +14,60 @@
|
||||
|
||||
### 安全策略
|
||||
|
||||
- [ ] 仅允许已认证用户外发。
|
||||
- [ ] `MAIL FROM` 必须等于登录用户邮箱。
|
||||
- [ ] Web 发信的 From 必须等于当前登录用户邮箱。
|
||||
- [ ] 限制单封邮件最大外部收件人数。
|
||||
- [ ] 限制单封邮件大小。
|
||||
- [ ] 明确拒绝未认证外部投递,防止开放中继。
|
||||
- [x] 仅允许已认证用户外发。
|
||||
- [x] `MAIL FROM` 必须等于登录用户邮箱。
|
||||
- [x] Web 发信的 From 必须等于当前登录用户邮箱。
|
||||
- [x] 限制单封邮件最大外部收件人数。
|
||||
- [x] 限制单封邮件大小。
|
||||
- [x] 明确拒绝未认证外部投递,防止开放中继。
|
||||
|
||||
### DNS / MX 查询
|
||||
|
||||
- [ ] 从外部收件人地址解析目标域名。
|
||||
- [ ] 使用 `net.LookupMX(domain)` 查询 MX 记录。
|
||||
- [ ] 按 MX 优先级排序。
|
||||
- [ ] 如果没有 MX,可按 RFC 规则尝试直接连接域名本身。
|
||||
- [ ] 记录 MX 查询失败原因。
|
||||
- [x] 从外部收件人地址解析目标域名。
|
||||
- [x] 使用 `net.LookupMX(domain)` 查询 MX 记录。
|
||||
- [x] 按 MX 优先级排序。
|
||||
- [x] 如果没有 MX,可按 RFC 规则尝试直接连接域名本身。
|
||||
- [x] 记录 MX 查询失败原因。
|
||||
|
||||
### SMTP 出站发送
|
||||
|
||||
- [ ] 新增内部 outbound mailer 模块,例如 `internal/outbound/`。
|
||||
- [ ] 实现连接目标 MX 的 `:25` 端口。
|
||||
- [ ] 发送 `EHLO`。
|
||||
- [ ] 解析对方 SMTP 能力。
|
||||
- [ ] 如支持 `STARTTLS`,执行 STARTTLS。
|
||||
- [ ] TLS 成功后重新 `EHLO`。
|
||||
- [ ] 发送 `MAIL FROM`。
|
||||
- [ ] 发送 `RCPT TO`。
|
||||
- [ ] 发送 `DATA`。
|
||||
- [ ] 发送邮件原始内容。
|
||||
- [ ] 发送 `QUIT`。
|
||||
- [ ] 区分临时失败和永久失败。
|
||||
- [x] 新增内部 outbound mailer 模块,例如 `internal/outbound/`。
|
||||
- [x] 实现连接目标 MX 的 `:25` 端口。
|
||||
- [x] 发送 `EHLO`。
|
||||
- [x] 解析对方 SMTP 能力。
|
||||
- [x] 如支持 `STARTTLS`,执行 STARTTLS。
|
||||
- [x] TLS 成功后重新 `EHLO`。
|
||||
- [x] 发送 `MAIL FROM`。
|
||||
- [x] 发送 `RCPT TO`。
|
||||
- [x] 发送 `DATA`。
|
||||
- [x] 发送邮件原始内容。
|
||||
- [x] 发送 `QUIT`。
|
||||
- [x] 区分临时失败和永久失败。
|
||||
|
||||
### SMTP 客户端提交集成
|
||||
|
||||
- [ ] 修改 `internal/smtp_server/server.go`。
|
||||
- [ ] 对认证用户提交的外部收件人,不再直接拒绝。
|
||||
- [ ] 本地收件人仍走本地 INBOX 投递。
|
||||
- [ ] 外部收件人调用 outbound mailer。
|
||||
- [ ] 外部投递成功后保存 Sent 副本。
|
||||
- [ ] 外部投递失败时向 SMTP 客户端返回明确错误。
|
||||
- [x] 修改 `internal/smtp_server/server.go`。
|
||||
- [x] 对认证用户提交的外部收件人,不再直接拒绝。
|
||||
- [x] 本地收件人仍走本地 INBOX 投递。
|
||||
- [x] 外部收件人调用 outbound mailer。
|
||||
- [x] 外部投递成功后保存 Sent 副本。
|
||||
- [x] 外部投递失败时向 SMTP 客户端返回明确错误。
|
||||
|
||||
### Web 发信集成
|
||||
|
||||
- [ ] 修改 `internal/web/handlers/mail.go`。
|
||||
- [ ] Web 发信支持外部收件人。
|
||||
- [ ] 本地收件人走本地投递。
|
||||
- [ ] 外部收件人调用 outbound mailer。
|
||||
- [ ] 外部投递失败时页面显示错误。
|
||||
- [ ] 成功后保存 Sent 副本。
|
||||
- [x] 修改 `internal/web/handlers/mail.go`。
|
||||
- [x] Web 发信支持外部收件人。
|
||||
- [x] 本地收件人走本地投递。
|
||||
- [x] 外部收件人调用 outbound mailer。
|
||||
- [x] 外部投递失败时页面显示错误。
|
||||
- [x] 成功后保存 Sent 副本。
|
||||
|
||||
### 日志与错误
|
||||
|
||||
- [ ] 记录每次外部投递的目标域名、MX、收件人、结果。
|
||||
- [ ] 记录 SMTP 响应码和响应文本。
|
||||
- [ ] 临时失败返回可识别错误。
|
||||
- [ ] 永久失败返回可识别错误。
|
||||
- [x] 记录每次外部投递的目标域名、MX、收件人、结果。
|
||||
- [x] 记录 SMTP 响应码和响应文本。
|
||||
- [x] 临时失败返回可识别错误。
|
||||
- [x] 永久失败返回可识别错误。
|
||||
|
||||
### 验证
|
||||
|
||||
@@ -82,61 +82,61 @@
|
||||
|
||||
### 出站队列表
|
||||
|
||||
- [ ] 新增 outbound queue 数据表。
|
||||
- [ ] 保存发件人、收件人、RawData、状态、重试次数、下一次重试时间。
|
||||
- [ ] 保存最后一次 SMTP 响应。
|
||||
- [ ] 保存创建时间、更新时间、完成时间。
|
||||
- [x] 新增 outbound queue 数据表。
|
||||
- [x] 保存发件人、收件人、RawData、状态、重试次数、下一次重试时间。
|
||||
- [x] 保存最后一次 SMTP 响应。
|
||||
- [x] 保存创建时间、更新时间、完成时间。
|
||||
|
||||
### 后台投递 worker
|
||||
|
||||
- [ ] 实现后台 worker 扫描待投递队列。
|
||||
- [ ] 实现指数退避重试。
|
||||
- [ ] 临时失败进入重试。
|
||||
- [ ] 永久失败进入失败状态。
|
||||
- [ ] 超过最大重试周期后生成失败状态。
|
||||
- [x] 实现后台 worker 扫描待投递队列。
|
||||
- [x] 实现指数退避重试。
|
||||
- [x] 临时失败进入重试。
|
||||
- [x] 永久失败进入失败状态。
|
||||
- [x] 超过最大重试周期后生成失败状态。
|
||||
|
||||
### 退信
|
||||
|
||||
- [ ] 为永久失败生成退信邮件。
|
||||
- [ ] 为超过重试周期的临时失败生成退信邮件。
|
||||
- [ ] 将退信投递到发件人 INBOX。
|
||||
- [ ] 退信中包含原始错误和目标收件人。
|
||||
- [x] 为永久失败生成退信邮件。
|
||||
- [x] 为超过重试周期的临时失败生成退信邮件。
|
||||
- [x] 将退信投递到发件人 INBOX。
|
||||
- [x] 退信中包含原始错误和目标收件人。
|
||||
|
||||
### DKIM 签名
|
||||
|
||||
- [ ] 使用域名 DKIM 私钥为外发邮件签名。
|
||||
- [ ] 添加 `DKIM-Signature` 头。
|
||||
- [ ] 支持当前域名的 selector。
|
||||
- [ ] 验证 DNS 中 DKIM TXT 记录匹配。
|
||||
- [x] 使用域名 DKIM 私钥为外发邮件签名。
|
||||
- [x] 添加 `DKIM-Signature` 头。
|
||||
- [x] 支持当前域名的 selector。
|
||||
- [x] 验证 DNS 中 DKIM TXT 记录匹配。
|
||||
|
||||
### 发送限制与防滥用
|
||||
|
||||
- [ ] 每用户每分钟发送限制。
|
||||
- [ ] 每用户每日发送限制。
|
||||
- [ ] 单封最大收件人数限制。
|
||||
- [ ] 单封最大大小限制。
|
||||
- [ ] 记录异常发送行为。
|
||||
- [x] 每用户每分钟发送限制。
|
||||
- [x] 每用户每日发送限制。
|
||||
- [x] 单封最大收件人数限制。
|
||||
- [x] 单封最大大小限制。
|
||||
- [x] 记录异常发送行为。
|
||||
- [ ] 管理员可禁用用户外发能力。
|
||||
|
||||
### 管理后台
|
||||
|
||||
- [ ] 增加外发队列页面。
|
||||
- [ ] 显示投递状态。
|
||||
- [ ] 显示失败原因。
|
||||
- [ ] 支持手动重试。
|
||||
- [ ] 支持取消队列任务。
|
||||
- [x] 增加外发队列页面。
|
||||
- [x] 显示投递状态。
|
||||
- [x] 显示失败原因。
|
||||
- [x] 支持手动重试。
|
||||
- [x] 支持取消队列任务。
|
||||
|
||||
## DNS 与服务器配置检查
|
||||
|
||||
外部投递不仅需要代码,还需要正确 DNS 和服务器信誉配置。
|
||||
|
||||
- [ ] SPF 记录,例如:`v=spf1 mx ip4:服务器IP -all`
|
||||
- [ ] DKIM 记录,例如:`default._domainkey.example.com TXT ...`
|
||||
- [ ] DMARC 记录,例如:`v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com`
|
||||
- [x] SPF 记录,例如:`v=spf1 mx ip4:服务器IP -all`
|
||||
- [x] DKIM 记录,例如:`default._domainkey.example.com TXT ...`
|
||||
- [x] DMARC 记录,例如:`v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com`
|
||||
- [ ] PTR / rDNS 反向解析指向邮件主机名。
|
||||
- [ ] 邮件主机名 A/AAAA 记录指向服务器。
|
||||
- [ ] 服务器 25 端口出站未被云厂商封锁。
|
||||
- [ ] 主机名、HELO/EHLO 名称、证书域名尽量一致。
|
||||
- [x] 邮件主机名 A/AAAA 记录指向服务器。
|
||||
- [x] 服务器 25 端口出站未被云厂商封锁。
|
||||
- [x] 主机名、HELO/EHLO 名称、证书域名尽量一致。
|
||||
|
||||
## 当前边界
|
||||
|
||||
|
||||
Reference in New Issue
Block a user