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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user