// Package smtp 提供基于 SMTP 协议的邮件发送通道。 // // 支持三种连接模式: // - SSL/TLS 加密(如端口 465,Encryption=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/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 }