2026-08-15 01:38:05 +08:00
|
|
|
|
// Package smtp 提供基于 SMTP 协议的邮件发送通道。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 支持三种连接模式:
|
|
|
|
|
|
// - SSL/TLS 加密(如端口 465,Encryption=EncryptionSSL)
|
|
|
|
|
|
// - STARTTLS 加密(默认,如端口 587/25)
|
|
|
|
|
|
// - 明文(Encryption=EncryptionNone,不推荐)
|
2024-11-20 19:42:07 +08:00
|
|
|
|
package smtp
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
2026-08-15 01:38:05 +08:00
|
|
|
|
"crypto/tls"
|
2024-11-20 19:42:07 +08:00
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-08-15 01:38:05 +08:00
|
|
|
|
"mime"
|
|
|
|
|
|
"mime/multipart"
|
|
|
|
|
|
"mime/quotedprintable"
|
|
|
|
|
|
"net"
|
|
|
|
|
|
"net/mail"
|
2024-11-20 19:42:07 +08:00
|
|
|
|
"net/smtp"
|
2026-08-15 01:38:05 +08:00
|
|
|
|
"net/textproto"
|
2024-11-20 19:42:07 +08:00
|
|
|
|
"os"
|
2026-08-15 01:38:05 +08:00
|
|
|
|
"sort"
|
2024-11-20 19:42:07 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
mailx "code.yun.ink/pkg/mailx"
|
2024-11-20 19:42:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
// Encryption 连接加密模式
|
|
|
|
|
|
type Encryption string
|
2024-11-20 19:42:07 +08:00
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
const (
|
|
|
|
|
|
EncryptionAuto Encryption = "auto" // 根据端口自动选择:465 走 SSL,其余走 STARTTLS
|
|
|
|
|
|
EncryptionSSL Encryption = "ssl" // 隐式 TLS(端口 465)
|
|
|
|
|
|
EncryptionTLS Encryption = "tls" // STARTTLS 升级加密(端口 587/25)
|
|
|
|
|
|
EncryptionNone Encryption = "none" // 明文,不加密
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Config SMTP 通道配置
|
|
|
|
|
|
type Config struct {
|
|
|
|
|
|
Host string // SMTP 服务器地址,如 smtp.qq.com
|
|
|
|
|
|
Port int // SMTP 端口,如 465 / 587 / 25
|
|
|
|
|
|
User string // 账号
|
|
|
|
|
|
Password string // 密码或授权码
|
|
|
|
|
|
From string // 默认发件人(可选,Message.From 优先)
|
|
|
|
|
|
ReplyTo string // 默认回复地址(可选)
|
|
|
|
|
|
Encryption Encryption // 连接加密模式,默认 EncryptionAuto
|
|
|
|
|
|
TLSSkipVerify bool // 是否跳过 TLS 证书校验(仅测试环境使用,勿在生产开启)
|
|
|
|
|
|
Timeout time.Duration // 单次发送的超时(含建连与投递),默认 30s;ctx 已带 deadline 时取较小者
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// defaultTimeout 单次 SMTP 发送的默认超时
|
|
|
|
|
|
const defaultTimeout = 30 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// Smtp SMTP 发送通道
|
2024-11-20 19:42:07 +08:00
|
|
|
|
type Smtp struct {
|
2026-08-15 01:38:05 +08:00
|
|
|
|
cfg Config
|
2024-11-20 19:42:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
// New 创建 SMTP 通道
|
|
|
|
|
|
func New(cfg Config) *Smtp {
|
|
|
|
|
|
if cfg.Encryption == "" {
|
|
|
|
|
|
cfg.Encryption = EncryptionAuto
|
|
|
|
|
|
}
|
|
|
|
|
|
return &Smtp{cfg: cfg}
|
2025-08-10 21:17:10 +08:00
|
|
|
|
}
|
2024-11-20 19:42:07 +08:00
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
// Name 返回通道名称
|
|
|
|
|
|
func (s *Smtp) Name() string { return "smtp" }
|
2025-08-10 21:17:10 +08:00
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
// Send 发送一封邮件
|
|
|
|
|
|
func (s *Smtp) Send(ctx context.Context, msg *mailx.Message) error {
|
|
|
|
|
|
logger := mailx.LoggerFromContext(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
if s.cfg.Host == "" || s.cfg.Port == 0 {
|
|
|
|
|
|
return mailx.ErrInvalidConfig
|
2025-08-10 21:17:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
from := s.firstNonEmpty(msg.From, s.cfg.From, s.cfg.User)
|
|
|
|
|
|
if from == "" {
|
|
|
|
|
|
return fmt.Errorf("%w: smtp sender is empty", mailx.ErrInvalidConfig)
|
2024-11-20 19:42:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 01:38:05 +08:00
|
|
|
|
data, err := s.buildMIME(msg, from)
|
2024-11-20 19:42:07 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-08-15 01:38:05 +08:00
|
|
|
|
|
|
|
|
|
|
if err := s.deliver(ctx, from, msg.To, data); err != nil {
|
|
|
|
|
|
logger.Errorf(ctx, "mailx/smtp: send to %v failed: %v", msg.To, err)
|
|
|
|
|
|
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.Infof(ctx, "mailx/smtp: sent to %v subject=%q", msg.To, msg.Subject)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// deliver 按配置的加密模式发送邮件
|
|
|
|
|
|
func (s *Smtp) deliver(ctx context.Context, from string, to []string, data []byte) error {
|
|
|
|
|
|
switch s.effectiveEncryption() {
|
|
|
|
|
|
case EncryptionSSL:
|
|
|
|
|
|
return s.sendOverSSL(ctx, from, to, data)
|
|
|
|
|
|
case EncryptionNone:
|
|
|
|
|
|
return s.sendOverPlain(ctx, from, to, data)
|
|
|
|
|
|
default: // EncryptionTLS / EncryptionAuto(非465)
|
|
|
|
|
|
return s.sendOverStartTLS(ctx, from, to, data)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Smtp) effectiveEncryption() Encryption {
|
|
|
|
|
|
switch s.cfg.Encryption {
|
|
|
|
|
|
case EncryptionSSL, EncryptionTLS, EncryptionNone:
|
|
|
|
|
|
return s.cfg.Encryption
|
|
|
|
|
|
default: // auto
|
|
|
|
|
|
if s.cfg.Port == 465 {
|
|
|
|
|
|
return EncryptionSSL
|
2024-11-20 19:42:07 +08:00
|
|
|
|
}
|
2026-08-15 01:38:05 +08:00
|
|
|
|
return EncryptionTLS
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Smtp) tlsConfig() *tls.Config {
|
|
|
|
|
|
return &tls.Config{
|
|
|
|
|
|
ServerName: s.cfg.Host,
|
|
|
|
|
|
InsecureSkipVerify: s.cfg.TLSSkipVerify, //nolint:gosec // 仅测试环境开启
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// dialContext 建立 TCP 连接并应用 ctx 超时与 deadline
|
|
|
|
|
|
func (s *Smtp) dialContext(ctx context.Context) (net.Conn, error) {
|
|
|
|
|
|
dialer := &net.Dialer{Timeout: s.singleTimeout(ctx)}
|
|
|
|
|
|
conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
// 建连后设置整体投递 deadline,保证连接上的所有 I/O 不会无限阻塞
|
|
|
|
|
|
if err := applyDeadline(conn, s.singleTimeout(ctx)); err != nil {
|
|
|
|
|
|
_ = conn.Close()
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return conn, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sendOverSSL 直接建立 TLS 连接(端口 465)
|
|
|
|
|
|
func (s *Smtp) sendOverSSL(ctx context.Context, from string, to []string, data []byte) error {
|
|
|
|
|
|
raw, err := s.dialContext(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
tlsConn := tls.Client(raw, s.tlsConfig())
|
|
|
|
|
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
|
|
|
|
_ = raw.Close()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
client, err := smtp.NewClient(tlsConn, s.cfg.Host)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
_ = raw.Close()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
// 由 sendWithClient 统一负责 Close
|
|
|
|
|
|
return s.sendWithClient(ctx, client, from, to, data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sendOverStartTLS 建立明文连接后用 STARTTLS 升级
|
|
|
|
|
|
func (s *Smtp) sendOverStartTLS(ctx context.Context, from string, to []string, data []byte) error {
|
|
|
|
|
|
conn, err := s.dialContext(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
client, err := smtp.NewClient(conn, s.cfg.Host)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
_ = conn.Close()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.StartTLS(s.tlsConfig()); err != nil {
|
|
|
|
|
|
_ = client.Close()
|
|
|
|
|
|
return fmt.Errorf("starttls: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendWithClient(ctx, client, from, to, data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sendOverPlain 明文发送(不加密,仅用于内网/测试)
|
|
|
|
|
|
func (s *Smtp) sendOverPlain(ctx context.Context, from string, to []string, data []byte) error {
|
|
|
|
|
|
conn, err := s.dialContext(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
client, err := smtp.NewClient(conn, s.cfg.Host)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
_ = conn.Close()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendWithClient(ctx, client, from, to, data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sendWithClient 基于已建立的 SMTP 客户端完成认证与投递
|
|
|
|
|
|
// 底层连接的 deadline 已在 dialContext 阶段设置,覆盖整个投递流程。
|
|
|
|
|
|
func (s *Smtp) sendWithClient(_ context.Context, client *smtp.Client, from string, to []string, data []byte) error {
|
|
|
|
|
|
defer client.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if s.cfg.User != "" {
|
|
|
|
|
|
auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host)
|
|
|
|
|
|
if ok, _ := client.Extension("AUTH"); ok {
|
|
|
|
|
|
if err := client.Auth(auth); err != nil {
|
|
|
|
|
|
return fmt.Errorf("auth: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SMTP 命令要求纯邮箱地址(不含显示名),这里统一提取
|
|
|
|
|
|
senderAddr, err := mailx.ExtractEmail(from)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("%w: invalid from address %q: %v", mailx.ErrInvalidConfig, from, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.Mail(senderAddr); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, addr := range to {
|
|
|
|
|
|
recipient, rerr := mailx.ExtractEmail(addr)
|
|
|
|
|
|
if rerr != nil {
|
|
|
|
|
|
return fmt.Errorf("%w: invalid recipient %q: %v", mailx.ErrInvalidMessage, addr, rerr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.Rcpt(recipient); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
w, err := client.Data()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := w.Write(data); err != nil {
|
|
|
|
|
|
_ = w.Close()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := w.Close(); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.Quit(); err != nil {
|
|
|
|
|
|
return err
|
2024-11-20 19:42:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-08-15 01:38:05 +08:00
|
|
|
|
|
|
|
|
|
|
// singleTimeout 计算单次发送的 I/O 超时:取 cfg.Timeout 与 ctx deadline 中较小者
|
|
|
|
|
|
func (s *Smtp) singleTimeout(ctx context.Context) time.Duration {
|
|
|
|
|
|
timeout := s.cfg.Timeout
|
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
|
timeout = defaultTimeout
|
|
|
|
|
|
}
|
|
|
|
|
|
if dl, ok := ctx.Deadline(); ok {
|
|
|
|
|
|
if remain := time.Until(dl); remain < timeout {
|
|
|
|
|
|
timeout = remain
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return timeout
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// applyDeadline 为 net.Conn 设置整体 deadline
|
|
|
|
|
|
func applyDeadline(conn net.Conn, d time.Duration) error {
|
|
|
|
|
|
if d <= 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return conn.SetDeadline(time.Now().Add(d))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildMIME 使用标准库构造邮件体,按内容自动选择 MIME 结构:
|
|
|
|
|
|
// - 仅正文:multipart/alternative(text/plain + text/html)
|
|
|
|
|
|
// - 正文 + 内嵌图片:multipart/related,内含 alternative + inline 图片
|
|
|
|
|
|
// - 有普通附件:multipart/mixed(可再内含 related)
|
|
|
|
|
|
func (s *Smtp) buildMIME(msg *mailx.Message, from string) ([]byte, error) {
|
|
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
|
|
|
|
|
|
|
|
|
|
replyTo := s.firstNonEmpty(msg.ReplyTo, s.cfg.ReplyTo)
|
|
|
|
|
|
hasBody := msg.Body != "" || msg.TextBody != ""
|
|
|
|
|
|
hasAttachments := len(msg.Attachments) > 0
|
|
|
|
|
|
hasInline := len(msg.Inline) > 0
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 顶部 Header ----
|
|
|
|
|
|
header := textproto.MIMEHeader{}
|
|
|
|
|
|
header.Set("From", formatAddressHeader(from))
|
|
|
|
|
|
header.Set("To", formatAddressList(msg.To))
|
|
|
|
|
|
if len(msg.Cc) > 0 {
|
|
|
|
|
|
header.Set("Cc", formatAddressList(msg.Cc))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(msg.Bcc) > 0 {
|
|
|
|
|
|
header.Set("Bcc", formatAddressList(msg.Bcc))
|
|
|
|
|
|
}
|
|
|
|
|
|
header.Set("Subject", encodeHeader(msg.Subject))
|
|
|
|
|
|
header.Set("Date", time.Now().Format(time.RFC1123Z))
|
|
|
|
|
|
header.Set("MIME-Version", "1.0")
|
|
|
|
|
|
if replyTo != "" {
|
|
|
|
|
|
header.Set("Reply-To", formatAddressHeader(replyTo))
|
|
|
|
|
|
}
|
|
|
|
|
|
// 自定义头(不覆盖标准头)
|
|
|
|
|
|
for k, v := range msg.Headers {
|
|
|
|
|
|
if k != "" && v != "" && !hasStdHeader(k) {
|
|
|
|
|
|
header.Set(k, encodeHeader(v))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 正文 alternative 部分(先独立生成,便于在各结构中复用) ----
|
|
|
|
|
|
var altBytes []byte
|
|
|
|
|
|
var altBoundary string
|
|
|
|
|
|
if hasBody {
|
|
|
|
|
|
altBuf := bytes.NewBuffer(nil)
|
|
|
|
|
|
altMP := multipart.NewWriter(altBuf)
|
|
|
|
|
|
if err := writeAlternative(altMP, msg); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
altBytes = altBuf.Bytes()
|
|
|
|
|
|
altBoundary = altMP.Boundary()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- related 层(正文 + 内嵌图片) ----
|
|
|
|
|
|
var related *multipart.Writer
|
|
|
|
|
|
var relatedBytes []byte
|
|
|
|
|
|
if hasInline {
|
|
|
|
|
|
rbuf := bytes.NewBuffer(nil)
|
|
|
|
|
|
related = multipart.NewWriter(rbuf)
|
|
|
|
|
|
// 先写 alternative 作为 related 的第一个 part
|
|
|
|
|
|
if hasBody {
|
|
|
|
|
|
altHdr := textproto.MIMEHeader{}
|
|
|
|
|
|
altHdr.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
|
|
|
|
|
|
altPart, err := related.CreatePart(altHdr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: create related alternative: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := altPart.Write(altBytes); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: write related alternative: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, inl := range msg.Inline {
|
|
|
|
|
|
name, data, mtype, err := s.readInline(inl)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
inlHdr := textproto.MIMEHeader{}
|
|
|
|
|
|
inlHdr.Set("Content-Type", mtype+"; name="+encodeHeader(name))
|
|
|
|
|
|
inlHdr.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", name))
|
|
|
|
|
|
inlHdr.Set("Content-ID", "<"+inl.CID+">")
|
|
|
|
|
|
inlHdr.Set("Content-Transfer-Encoding", "base64")
|
|
|
|
|
|
part, err := related.CreatePart(inlHdr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: create inline part: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
bw := base64.NewEncoder(base64.StdEncoding, part)
|
|
|
|
|
|
if _, err := bw.Write(data); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: write inline: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := bw.Close(); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: close inline: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := related.Close(); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: close related: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
relatedBytes = rbuf.Bytes()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 顶层 body(mixed / related / alternative) ----
|
|
|
|
|
|
var bodyBytes []byte
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case hasAttachments:
|
|
|
|
|
|
mbuf := bytes.NewBuffer(nil)
|
|
|
|
|
|
mixed := multipart.NewWriter(mbuf)
|
|
|
|
|
|
if hasBody || hasInline {
|
|
|
|
|
|
bodyHdr := textproto.MIMEHeader{}
|
|
|
|
|
|
if hasInline {
|
|
|
|
|
|
bodyHdr.Set("Content-Type", mimeTypeWithBoundary("multipart/related", relatedBoundary(relatedBytes)))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
bodyHdr.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
|
|
|
|
|
|
}
|
|
|
|
|
|
bodyPart, err := mixed.CreatePart(bodyHdr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: create mixed body part: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
content := relatedBytes
|
|
|
|
|
|
if !hasInline {
|
|
|
|
|
|
content = altBytes
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := bodyPart.Write(content); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: write mixed body: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, att := range msg.Attachments {
|
|
|
|
|
|
name, data, err := s.readAttachment(att)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
attHdr := textproto.MIMEHeader{}
|
|
|
|
|
|
attHdr.Set("Content-Type", "application/octet-stream; name="+encodeHeader(name))
|
|
|
|
|
|
attHdr.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
|
|
|
|
|
attHdr.Set("Content-Transfer-Encoding", "base64")
|
|
|
|
|
|
part, err := mixed.CreatePart(attHdr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: create attachment part: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
bw := base64.NewEncoder(base64.StdEncoding, part)
|
|
|
|
|
|
if _, err := bw.Write(data); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: write attachment: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := bw.Close(); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: close attachment: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := mixed.Close(); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("mailx/smtp: close mixed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
bodyBytes = mbuf.Bytes()
|
|
|
|
|
|
header.Set("Content-Type", "multipart/mixed; boundary="+mixed.Boundary())
|
|
|
|
|
|
case hasInline:
|
|
|
|
|
|
header.Set("Content-Type", mimeTypeWithBoundary("multipart/related", relatedBoundary(relatedBytes)))
|
|
|
|
|
|
bodyBytes = relatedBytes
|
|
|
|
|
|
case hasBody:
|
|
|
|
|
|
header.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
|
|
|
|
|
|
bodyBytes = altBytes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 统一先写 headers,再写 body
|
|
|
|
|
|
writeHeaders(buf, header)
|
|
|
|
|
|
buf.Write(bodyBytes)
|
|
|
|
|
|
return buf.Bytes(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// mimeTypeWithBoundary 生成带 boundary 的 Content-Type
|
|
|
|
|
|
func mimeTypeWithBoundary(mediaType, boundary string) string {
|
|
|
|
|
|
return mediaType + "; boundary=" + boundary
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// relatedBoundary 从 related 内容中提取 boundary
|
|
|
|
|
|
func relatedBoundary(relatedBytes []byte) string {
|
|
|
|
|
|
for _, line := range bytes.Split(relatedBytes, []byte("\r\n")) {
|
|
|
|
|
|
if after, ok := bytes.CutPrefix(line, []byte("Content-Type: multipart/related; boundary=")); ok {
|
|
|
|
|
|
return string(after)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// formatAddressList 将地址列表编码为头部字符串(显示名 + 地址)
|
|
|
|
|
|
func formatAddressList(addrs []string) string {
|
|
|
|
|
|
parts := make([]string, 0, len(addrs))
|
|
|
|
|
|
for _, a := range addrs {
|
|
|
|
|
|
if s := formatAddressHeader(a); s != "" {
|
|
|
|
|
|
parts = append(parts, s)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(parts, ", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// formatAddressHeader 将单个地址(可含显示名)编码为合法头部值
|
|
|
|
|
|
func formatAddressHeader(s string) string {
|
|
|
|
|
|
a, err := mail.ParseAddress(s)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 无法解析时回退为原始值(可能带非 ASCII,做 RFC2047 编码)
|
|
|
|
|
|
return encodeHeader(s)
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.Name == "" {
|
|
|
|
|
|
return a.Address
|
|
|
|
|
|
}
|
|
|
|
|
|
return encodeHeader(a.Name) + " <" + a.Address + ">"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// hasStdHeader 判断是否为邮件标准头(这些由框架统一生成,不允许被自定义头覆盖)
|
|
|
|
|
|
func hasStdHeader(k string) bool {
|
|
|
|
|
|
switch strings.ToLower(k) {
|
|
|
|
|
|
case "from", "to", "cc", "bcc", "subject", "date", "reply-to", "mime-version", "content-type":
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// writeAlternative 将正文以 text/plain + text/html 写入 alternative 结构
|
|
|
|
|
|
func writeAlternative(mp *multipart.Writer, msg *mailx.Message) error {
|
|
|
|
|
|
// 纯文本部分
|
|
|
|
|
|
if msg.TextBody != "" {
|
|
|
|
|
|
th := textproto.MIMEHeader{}
|
|
|
|
|
|
th.Set("Content-Type", "text/plain; charset=UTF-8")
|
|
|
|
|
|
th.Set("Content-Transfer-Encoding", "quoted-printable")
|
|
|
|
|
|
part, err := mp.CreatePart(th)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("mailx/smtp: create text part: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
qp := quotedprintable.NewWriter(part)
|
|
|
|
|
|
if _, err := qp.Write([]byte(msg.TextBody)); err != nil {
|
|
|
|
|
|
return fmt.Errorf("mailx/smtp: write text: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := qp.Close(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("mailx/smtp: close text: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// HTML 部分
|
|
|
|
|
|
if msg.Body != "" {
|
|
|
|
|
|
hh := textproto.MIMEHeader{}
|
|
|
|
|
|
hh.Set("Content-Type", "text/html; charset=UTF-8")
|
|
|
|
|
|
hh.Set("Content-Transfer-Encoding", "quoted-printable")
|
|
|
|
|
|
part, err := mp.CreatePart(hh)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("mailx/smtp: create html part: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
qp := quotedprintable.NewWriter(part)
|
|
|
|
|
|
if _, err := qp.Write([]byte(msg.Body)); err != nil {
|
|
|
|
|
|
return fmt.Errorf("mailx/smtp: write html: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := qp.Close(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("mailx/smtp: close html: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := mp.Close(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("mailx/smtp: close alternative: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func writeHeaders(buf *bytes.Buffer, hdr textproto.MIMEHeader) {
|
|
|
|
|
|
// 固定顺序输出标准头,保证可读性
|
|
|
|
|
|
std := []string{"Date", "From", "To", "Cc", "Bcc", "Subject", "Reply-To", "MIME-Version", "Content-Type"}
|
|
|
|
|
|
written := make(map[string]bool, len(std))
|
|
|
|
|
|
for _, k := range std {
|
|
|
|
|
|
if v := hdr.Values(k); len(v) > 0 {
|
|
|
|
|
|
buf.WriteString(k + ": " + v[0] + "\r\n")
|
|
|
|
|
|
written[k] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 剩余的自定义头(按 key 排序保证稳定输出)
|
|
|
|
|
|
var extra []string
|
|
|
|
|
|
for k := range hdr {
|
|
|
|
|
|
if !written[k] && !hasStdHeader(k) {
|
|
|
|
|
|
extra = append(extra, k)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Strings(extra)
|
|
|
|
|
|
for _, k := range extra {
|
|
|
|
|
|
if v := hdr.Values(k); len(v) > 0 {
|
|
|
|
|
|
buf.WriteString(k + ": " + v[0] + "\r\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
buf.WriteString("\r\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// readAttachment 解析附件(路径或内存字节)
|
|
|
|
|
|
func (s *Smtp) readAttachment(att mailx.Attachment) (string, []byte, error) {
|
|
|
|
|
|
if len(att.Data) > 0 {
|
|
|
|
|
|
name := att.Name
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
name = "attachment"
|
|
|
|
|
|
}
|
|
|
|
|
|
return name, att.Data, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if att.Path != "" {
|
|
|
|
|
|
data, err := os.ReadFile(att.Path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", nil, fmt.Errorf("mailx/smtp: read attachment %q: %w", att.Path, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
name := att.Name
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
name = baseName(att.Path)
|
|
|
|
|
|
}
|
|
|
|
|
|
return name, data, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", nil, errors.New("mailx/smtp: attachment has neither path nor data")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// readInline 解析内嵌图片(路径或内存字节),返回 文件名、数据、MIME 类型
|
|
|
|
|
|
func (s *Smtp) readInline(inl mailx.InlineImage) (string, []byte, string, error) {
|
|
|
|
|
|
name := inl.Name
|
|
|
|
|
|
mtype := inl.MIMEType
|
|
|
|
|
|
if mtype == "" {
|
|
|
|
|
|
if name != "" {
|
|
|
|
|
|
mtype = mime.TypeByExtension(strings.ToLower(pathExt(name)))
|
|
|
|
|
|
}
|
|
|
|
|
|
if mtype == "" {
|
|
|
|
|
|
mtype = "image/octet-stream"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(inl.Data) > 0 {
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
name = "inline"
|
|
|
|
|
|
}
|
|
|
|
|
|
return name, inl.Data, mtype, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if inl.Path != "" {
|
|
|
|
|
|
data, err := os.ReadFile(inl.Path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", nil, "", fmt.Errorf("mailx/smtp: read inline %q: %w", inl.Path, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
name = baseName(inl.Path)
|
|
|
|
|
|
}
|
|
|
|
|
|
if mtype == "image/octet-stream" {
|
|
|
|
|
|
mtype = mime.TypeByExtension(strings.ToLower(pathExt(inl.Path)))
|
|
|
|
|
|
if mtype == "" {
|
|
|
|
|
|
mtype = "image/octet-stream"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return name, data, mtype, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", nil, "", errors.New("mailx/smtp: inline image has neither path nor data")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// pathExt 提取路径扩展名(含点),兼容 / 与 \
|
|
|
|
|
|
func pathExt(p string) string {
|
|
|
|
|
|
p = baseName(p)
|
|
|
|
|
|
if i := strings.LastIndexByte(p, '.'); i >= 0 {
|
|
|
|
|
|
return p[i:]
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// firstNonEmpty 返回第一个非空字符串
|
|
|
|
|
|
func (s *Smtp) firstNonEmpty(vals ...string) string {
|
|
|
|
|
|
for _, v := range vals {
|
|
|
|
|
|
if v != "" {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// encodeHeader 对非 ASCII 的头部字段做 RFC 2047 编码(ASCII 内容原样返回)
|
|
|
|
|
|
func encodeHeader(s string) string {
|
|
|
|
|
|
return mime.QEncoding.Encode("UTF-8", s)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// baseName 取路径中的文件名,兼容 / 与 \ 分隔符
|
|
|
|
|
|
func baseName(p string) string {
|
|
|
|
|
|
if i := strings.LastIndexAny(p, `/\`); i >= 0 {
|
|
|
|
|
|
return p[i+1:]
|
|
|
|
|
|
}
|
|
|
|
|
|
return p
|
|
|
|
|
|
}
|