Files
mailx/smtp/smtp.go
T
2026-08-15 01:38:05 +08:00

634 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package smtp 提供基于 SMTP 协议的邮件发送通道。
//
// 支持三种连接模式:
// - SSL/TLS 加密(如端口 465Encryption=EncryptionSSL
// - STARTTLS 加密(默认,如端口 587/25)
// - 明文(Encryption=EncryptionNone,不推荐)
package smtp
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"net/textproto"
"os"
"sort"
"strings"
"time"
mailx "code.yun.ink/pkg/mailx"
)
// Encryption 连接加密模式
type Encryption string
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 发送通道
type Smtp struct {
cfg Config
}
// New 创建 SMTP 通道
func New(cfg Config) *Smtp {
if cfg.Encryption == "" {
cfg.Encryption = EncryptionAuto
}
return &Smtp{cfg: cfg}
}
// Name 返回通道名称
func (s *Smtp) Name() string { return "smtp" }
// 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
}
from := s.firstNonEmpty(msg.From, s.cfg.From, s.cfg.User)
if from == "" {
return fmt.Errorf("%w: smtp sender is empty", mailx.ErrInvalidConfig)
}
data, err := s.buildMIME(msg, from)
if err != nil {
return err
}
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
}
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
}
return nil
}
// 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/alternativetext/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()
}
// ---- 顶层 bodymixed / 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
}