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