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:
dsh
2026-08-15 17:18:54 -04:00
parent 0545a71ba2
commit 5eb9bc2c71
28 changed files with 1657 additions and 186 deletions
+50 -16
View File
@@ -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"` // 发件用户 IDWeb/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"`