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:
28 files changed
+1657
-186
No files matched your search
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user