更新
This commit is contained in:
+600
-152
@@ -1,185 +1,633 @@
|
||||
// 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"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.yun.ink/pkg/mailx/interfaces"
|
||||
mailx "code.yun.ink/pkg/mailx"
|
||||
)
|
||||
|
||||
// 邮件发送的封装
|
||||
// 1. 支持文本
|
||||
// 2. 支持文件
|
||||
// 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 {
|
||||
interfaces.DefaultEmail
|
||||
// params *interfaces.EmailConfigDataSmtp
|
||||
auth smtp.Auth
|
||||
// logger loggerx.LoggerInterface
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func NewSmtp() *Smtp {
|
||||
smtp := &Smtp{}
|
||||
smtp.Options = interfaces.DefaultOptions()
|
||||
smtp.EmailType = interfaces.EmailTypeSmtp
|
||||
return smtp
|
||||
// New 创建 SMTP 通道
|
||||
func New(cfg Config) *Smtp {
|
||||
if cfg.Encryption == "" {
|
||||
cfg.Encryption = EncryptionAuto
|
||||
}
|
||||
return &Smtp{cfg: cfg}
|
||||
}
|
||||
|
||||
func (l *Smtp) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
|
||||
// Name 返回通道名称
|
||||
func (s *Smtp) Name() string { return "smtp" }
|
||||
|
||||
for _, o := range opt {
|
||||
o(&l.Options)
|
||||
// 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
|
||||
}
|
||||
|
||||
l.Options.Logger.Infof(ctx, "params:%+v", l.Options.Smtp)
|
||||
|
||||
if l.Options.Smtp == nil {
|
||||
return nil, errors.New("not smtp")
|
||||
from := s.firstNonEmpty(msg.From, s.cfg.From, s.cfg.User)
|
||||
if from == "" {
|
||||
return fmt.Errorf("%w: smtp sender is empty", mailx.ErrInvalidConfig)
|
||||
}
|
||||
|
||||
l.auth = smtp.PlainAuth("", l.Options.Smtp.Username, l.Options.Smtp.Password, l.Options.Smtp.Host)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Smtp) Send(ctx context.Context, message interfaces.Message) error {
|
||||
if l.Options.Smtp == nil {
|
||||
return errors.New("not init")
|
||||
}
|
||||
// .Auth()
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
boundary := "YunBoundaryYun"
|
||||
|
||||
Header := make(map[string]string)
|
||||
// Header["From"] = "BOP<" + message.Form + ">"
|
||||
|
||||
if message.Form != "" {
|
||||
Header["From"] = message.Form
|
||||
} else {
|
||||
Header["From"] = l.Options.Smtp.Username
|
||||
}
|
||||
|
||||
if len(message.To) > 0 {
|
||||
str := ""
|
||||
for _, val := range message.To {
|
||||
name := ""
|
||||
s := strings.Split(val, "@")
|
||||
if len(s) > 0 {
|
||||
name = s[0]
|
||||
}
|
||||
str = str + "," + name + "<" + val + ">"
|
||||
}
|
||||
Header["To"] = strings.Trim(str, ",")
|
||||
// Header["To"] = strings.Join(message.To, ",")
|
||||
}
|
||||
if len(message.Cc) > 0 {
|
||||
str := ""
|
||||
for _, val := range message.Cc {
|
||||
name := ""
|
||||
s := strings.Split(val, "@")
|
||||
if len(s) > 0 {
|
||||
name = s[0]
|
||||
}
|
||||
str = str + "," + name + "<" + val + ">"
|
||||
}
|
||||
Header["Cc"] = strings.Trim(str, ",")
|
||||
// Header["Cc"] = strings.Join(message.Cc, ",")
|
||||
}
|
||||
if len(message.Bcc) > 0 {
|
||||
str := ""
|
||||
for _, val := range message.Bcc {
|
||||
name := ""
|
||||
s := strings.Split(val, "@")
|
||||
if len(s) > 0 {
|
||||
name = s[0]
|
||||
}
|
||||
str = str + "," + name + "<" + val + ">"
|
||||
}
|
||||
Header["Bcc"] = strings.Trim(str, ",")
|
||||
// Header["Bcc"] = strings.Join(message.Bcc, ",")
|
||||
}
|
||||
|
||||
Header["Subject"] = message.Subject
|
||||
Header["Content-Type"] = "multipart/mixed; charset=UTF-8; boundary=" + boundary
|
||||
Header["Date"] = time.Now().String()
|
||||
Header["Reply-To"] = message.ReplyTo
|
||||
|
||||
Header["X-Priority"] = "3"
|
||||
l.writeHeader(buffer, Header)
|
||||
|
||||
body := "--" + boundary + "\r\n"
|
||||
// body += "Content-Type: text/plain; charset=UTF-8 \r\n"
|
||||
body += "Content-Type: text/html;charset=utf-8\r\n"
|
||||
body += "Content-Transfer-Encoding:quoted-printable\r\n\r\n"
|
||||
// body += "<html><body><h1>huang</h1><h2>xin</h2></body></html>\r\n"
|
||||
|
||||
// body += "<html><body>" + message.Body + "</body></html>\r\n"
|
||||
|
||||
body += message.Body + "\r\n"
|
||||
|
||||
// body += "--" + boundary + "--\r\n\r\n"
|
||||
buffer.WriteString(body)
|
||||
|
||||
for _, value := range message.Attachment {
|
||||
newBuf := bytes.NewBuffer(nil)
|
||||
err := l.writeFile(newBuf, value.Content)
|
||||
if err != nil {
|
||||
fmt.Println("file err:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
f_name := path.Base(value.Content)
|
||||
attachment := "--" + boundary + "\r\n"
|
||||
attachment += "Content-Transfer-Encoding:base64\r\n"
|
||||
attachment += "Content-Disposition:attachment;filename=" + f_name + "\r\n"
|
||||
attachment += "Content-Type: application/octet-stream;charset=utf-8;name=" + f_name + "\r\n"
|
||||
// attachment += "Contment-Type:" + message.attachment.contentType + ";name=\"" + message.attachment.name + "\"\r\n"
|
||||
buffer.WriteString(attachment)
|
||||
|
||||
buffer.WriteString(newBuf.String())
|
||||
}
|
||||
|
||||
buffer.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
b := buffer.Bytes()
|
||||
err := smtp.SendMail(l.Options.Smtp.Host+":"+l.Options.Smtp.Port, l.auth, l.Options.Smtp.Username, message.To, b)
|
||||
return err
|
||||
}
|
||||
|
||||
// 格式化header
|
||||
func (l *Smtp) writeHeader(buffer *bytes.Buffer, Header map[string]string) string {
|
||||
header := ""
|
||||
// header := "Content-Type: multipart/mixed;charset=UTF-8;boundary=\"YunBoundaryYun\" \r\n"
|
||||
for key, value := range Header {
|
||||
if value != "" {
|
||||
header += key + ": " + value + "\r\n"
|
||||
}
|
||||
}
|
||||
header += "\r\n"
|
||||
buffer.WriteString(header)
|
||||
return header
|
||||
}
|
||||
|
||||
// 格式化文件
|
||||
func (l *Smtp) writeFile(buffer *bytes.Buffer, fileName string) error {
|
||||
file, err := os.ReadFile(fileName)
|
||||
data, err := s.buildMIME(msg, from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := make([]byte, base64.StdEncoding.EncodedLen(len(file)))
|
||||
base64.StdEncoding.Encode(payload, file)
|
||||
buffer.WriteString("\r\n")
|
||||
for index, line := 0, len(payload); index < line; index++ {
|
||||
buffer.WriteByte(payload[index])
|
||||
if (index+1)%76 == 0 {
|
||||
buffer.WriteString("\r\n")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mailx "code.yun.ink/pkg/mailx"
|
||||
)
|
||||
|
||||
func TestBuildMIME(t *testing.T) {
|
||||
s := New(Config{Host: "h", Port: 25, From: "default@example.com", ReplyTo: "dr@example.com"})
|
||||
|
||||
msg := mailx.NewMessage().
|
||||
From("sender@example.com").
|
||||
To("to@example.com").
|
||||
Cc("cc@example.com").
|
||||
Subject("主题").
|
||||
Body("<p>hi</p>").
|
||||
ReplyTo("msg-reply@example.com").
|
||||
AttachBytes("a.txt", []byte("content")).
|
||||
Build()
|
||||
|
||||
data, err := s.buildMIME(msg, "sender@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"From: sender@example.com",
|
||||
"To: to@example.com",
|
||||
"Cc: cc@example.com",
|
||||
"Reply-To: msg-reply@example.com",
|
||||
"MIME-Version: 1.0",
|
||||
"multipart/mixed",
|
||||
"text/html; charset=UTF-8",
|
||||
"attachment; filename",
|
||||
"Y29udGVudA==", // base64("content")
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("MIME missing %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMIMEWithoutBody(t *testing.T) {
|
||||
s := New(Config{})
|
||||
msg := mailx.NewMessage().
|
||||
To("to@example.com").
|
||||
Subject("s").
|
||||
AttachBytes("a.txt", []byte("x")).
|
||||
Build()
|
||||
|
||||
data, err := s.buildMIME(msg, "f@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(data)
|
||||
if strings.Contains(out, "text/html") {
|
||||
t.Errorf("should not contain html part, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "multipart/mixed") {
|
||||
t.Errorf("should contain multipart/mixed for attachment, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMIMEFromFallback(t *testing.T) {
|
||||
// Message.From 为空时回退到 Config.From
|
||||
s := New(Config{From: "cfg-from@example.com"})
|
||||
msg := mailx.NewMessage().To("t@e.com").Subject("s").Build()
|
||||
data, err := s.buildMIME(msg, "cfg-from@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "From: cfg-from@example.com") {
|
||||
t.Fatalf("From header missing, got:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAttachment(t *testing.T) {
|
||||
s := New(Config{})
|
||||
|
||||
// 按路径
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "x.txt")
|
||||
if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, data, err := s.readAttachment(mailx.Attachment{Path: p})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "x.txt" || string(data) != "abc" {
|
||||
t.Errorf("name=%q data=%q", name, data)
|
||||
}
|
||||
|
||||
// 内存字节
|
||||
name, data, err = s.readAttachment(mailx.Attachment{Name: "m.bin", Data: []byte{1, 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "m.bin" || !bytes.Equal(data, []byte{1, 2}) {
|
||||
t.Errorf("name=%q data=%v", name, data)
|
||||
}
|
||||
|
||||
// 空附件报错
|
||||
if _, _, err := s.readAttachment(mailx.Attachment{}); err == nil {
|
||||
t.Fatal("empty attachment should error")
|
||||
}
|
||||
|
||||
// 路径不存在报错
|
||||
if _, _, err := s.readAttachment(mailx.Attachment{Path: filepath.Join(dir, "nope.txt")}); err == nil {
|
||||
t.Fatal("missing file should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeHeader(t *testing.T) {
|
||||
if got := encodeHeader("plain"); got != "plain" {
|
||||
t.Errorf("encodeHeader(plain) = %q", got)
|
||||
}
|
||||
got := encodeHeader("主题")
|
||||
if !strings.HasPrefix(got, "=?UTF-8?q?") || !strings.HasSuffix(got, "?=") {
|
||||
t.Errorf("encodeHeader(主题) = %q, want RFC 2047 encoded", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveEncryption(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
want Encryption
|
||||
}{
|
||||
{"default 587 -> tls", Config{Host: "h", Port: 587}, EncryptionTLS},
|
||||
{"default 465 -> ssl", Config{Host: "h", Port: 465}, EncryptionSSL},
|
||||
{"default 25 -> tls", Config{Host: "h", Port: 25}, EncryptionTLS},
|
||||
{"explicit ssl", Config{Host: "h", Port: 587, Encryption: EncryptionSSL}, EncryptionSSL},
|
||||
{"explicit tls", Config{Host: "h", Port: 465, Encryption: EncryptionTLS}, EncryptionTLS},
|
||||
{"explicit none", Config{Host: "h", Port: 465, Encryption: EncryptionNone}, EncryptionNone},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := New(c.cfg).effectiveEncryption(); got != c.want {
|
||||
t.Errorf("effectiveEncryption() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMIMEInlineImage(t *testing.T) {
|
||||
s := New(Config{})
|
||||
msg := mailx.NewMessage().
|
||||
From(`"张三" <sender@example.com>`).
|
||||
To("to@example.com").
|
||||
Subject("s").
|
||||
HTML(`<p>hi <img src="cid:logo1"></p>`).
|
||||
InlineImageBytes("logo1", "logo.png", []byte{0x89, 0x50, 0x4e, 0x47}).
|
||||
Build()
|
||||
|
||||
data, err := s.buildMIME(msg, `"张三" <sender@example.com>`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(data)
|
||||
|
||||
// 显示名应被 RFC2047 编码
|
||||
if !strings.Contains(out, "From: =?UTF-8?q?") {
|
||||
t.Errorf("display name not encoded, got:\n%s", out)
|
||||
}
|
||||
// related 结构 + 内嵌图片
|
||||
if !strings.Contains(out, "multipart/related") {
|
||||
t.Errorf("missing multipart/related, got:\n%s", out)
|
||||
}
|
||||
// textproto 会将 Content-ID 规范化为 Content-Id(RFC 允许,客户端能正确解析)
|
||||
if !strings.Contains(out, "Content-Id: <logo1>") && !strings.Contains(out, "Content-ID: <logo1>") {
|
||||
t.Errorf("missing Content-ID, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "inline; filename") {
|
||||
t.Errorf("missing inline disposition, got:\n%s", out)
|
||||
}
|
||||
// png 签名 base64(0x89 0x50 0x4e 0x47 -> iVBORw==)
|
||||
if !strings.Contains(out, "iVBORw==") {
|
||||
t.Errorf("png data missing, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMIMECustomHeaders(t *testing.T) {
|
||||
s := New(Config{})
|
||||
msg := mailx.NewMessage().
|
||||
To("t@e.com").
|
||||
Subject("s").
|
||||
Header("List-Unsubscribe", "<https://example.com/unsub>"). // 自定义头
|
||||
Header("X-Mailer", "mailx").
|
||||
Header("Subject", "should-not-override"). // 标准头,不应覆盖
|
||||
Build()
|
||||
|
||||
data, err := s.buildMIME(msg, "f@e.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(data)
|
||||
|
||||
if !strings.Contains(out, "List-Unsubscribe: <https://example.com/unsub>") {
|
||||
t.Errorf("missing custom header List-Unsubscribe, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "X-Mailer: mailx") {
|
||||
t.Errorf("missing custom header X-Mailer, got:\n%s", out)
|
||||
}
|
||||
// 标准头 Subject 不应被自定义值覆盖
|
||||
if strings.Contains(out, "Subject: should-not-override") {
|
||||
t.Errorf("custom header overrode standard Subject, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Subject: s") {
|
||||
t.Errorf("standard Subject lost, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInline(t *testing.T) {
|
||||
s := New(Config{})
|
||||
|
||||
// 按路径读取,MIME 类型由扩展名推断
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "img.png")
|
||||
if err := os.WriteFile(p, []byte{0x89, 0x50, 0x4e, 0x47}, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, data, mtype, err := s.readInline(mailx.InlineImage{CID: "cid1", Path: p})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "img.png" || string(data) != string([]byte{0x89, 0x50, 0x4e, 0x47}) {
|
||||
t.Errorf("path inline: name=%q data=%v", name, data)
|
||||
}
|
||||
if mtype != "image/png" {
|
||||
t.Errorf("mtype = %q, want image/png", mtype)
|
||||
}
|
||||
|
||||
// 内存字节 + 显式 MIME 类型
|
||||
name, data, mtype, err = s.readInline(mailx.InlineImage{CID: "c2", Name: "x.jpg", Data: []byte{1}, MIMEType: "image/jpeg"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "x.jpg" || mtype != "image/jpeg" {
|
||||
t.Errorf("memory inline: name=%q mtype=%q", name, mtype)
|
||||
}
|
||||
|
||||
// 空 inline 报错
|
||||
if _, _, _, err := s.readInline(mailx.InlineImage{}); err == nil {
|
||||
t.Fatal("empty inline should error")
|
||||
}
|
||||
|
||||
// 路径不存在报错
|
||||
if _, _, _, err := s.readInline(mailx.InlineImage{CID: "c", Path: filepath.Join(dir, "nope.png")}); err == nil {
|
||||
t.Fatal("missing inline file should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAddressHeader(t *testing.T) {
|
||||
// 纯地址原样
|
||||
if got := formatAddressHeader("a@example.com"); got != "a@example.com" {
|
||||
t.Errorf("plain = %q", got)
|
||||
}
|
||||
// 带显示名
|
||||
got := formatAddressHeader(`"张三" <a@example.com>`)
|
||||
if !strings.Contains(got, "=?UTF-8?q?") || !strings.Contains(got, "<a@example.com>") {
|
||||
t.Errorf("display name = %q", got)
|
||||
}
|
||||
// 非法地址回退为 RFC2047 编码
|
||||
if got := formatAddressHeader("not-an-email"); got == "" {
|
||||
t.Errorf("invalid address should fallback, got empty")
|
||||
}
|
||||
// 空字符串
|
||||
if got := formatAddressHeader(""); got != "" {
|
||||
t.Errorf("empty = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAddressList(t *testing.T) {
|
||||
got := formatAddressList([]string{"a@e.com", `"B" <b@e.com>`})
|
||||
if !strings.Contains(got, "a@e.com") || !strings.Contains(got, "b@e.com") {
|
||||
t.Errorf("list = %q", got)
|
||||
}
|
||||
// 空列表
|
||||
if got := formatAddressList(nil); got != "" {
|
||||
t.Errorf("empty list = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseNameAndPathExt(t *testing.T) {
|
||||
if got := baseName("/a/b/c.txt"); got != "c.txt" {
|
||||
t.Errorf("baseName unix = %q", got)
|
||||
}
|
||||
if got := baseName(`C:\dir\f.txt`); got != "f.txt" {
|
||||
t.Errorf("baseName win = %q", got)
|
||||
}
|
||||
if got := pathExt("a.png"); got != ".png" {
|
||||
t.Errorf("pathExt = %q", got)
|
||||
}
|
||||
if got := pathExt("noext"); got != "" {
|
||||
t.Errorf("pathExt noext = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMIMEAlternative(t *testing.T) {
|
||||
s := New(Config{})
|
||||
msg := mailx.NewMessage().
|
||||
To("t@e.com").
|
||||
Subject("s").
|
||||
Text("纯文本正文").
|
||||
HTML("<p>html正文</p>").
|
||||
Build()
|
||||
|
||||
data, err := s.buildMIME(msg, "f@e.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(data)
|
||||
|
||||
if !strings.Contains(out, "multipart/alternative") {
|
||||
t.Errorf("should use multipart/alternative, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "text/plain; charset=UTF-8") {
|
||||
t.Errorf("missing text/plain part, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "text/html; charset=UTF-8") {
|
||||
t.Errorf("missing text/html part, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
+224
-58
@@ -1,30 +1,36 @@
|
||||
package smtp_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.yun.ink/pkg/mailx/interfaces"
|
||||
"code.yun.ink/pkg/mailx"
|
||||
"code.yun.ink/pkg/mailx/smtp"
|
||||
)
|
||||
|
||||
// TestMail 真实发送到外部 SMTP 服务器,需要网络与有效凭据。
|
||||
// 用于本地联调,CI 中可跳过。
|
||||
func TestMail(t *testing.T) {
|
||||
sm := smtp.NewSmtp()
|
||||
ctx := context.Background()
|
||||
|
||||
ini, err := sm.SetOption(ctx, interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{
|
||||
Username: "support@email.blueoceanpay.com",
|
||||
Password: "SupporT2017",
|
||||
ReplyTo: "",
|
||||
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
||||
Port: "80",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if testing.Short() {
|
||||
t.Skip("skip real send in short mode")
|
||||
}
|
||||
client := smtp.New(smtp.Config{
|
||||
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
||||
Port: 80,
|
||||
User: "support@email.blueoceanpay.com",
|
||||
Password: "SupporT2017",
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
req, err := http.Get("https://baidu.com")
|
||||
if err != nil {
|
||||
@@ -37,55 +43,215 @@ func TestMail(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
msg := interfaces.Message{
|
||||
To: []string{"995116474@qq.com"},
|
||||
Cc: []string{"287852692@qq.com"},
|
||||
Bcc: []string{"1362716835@qq.com"},
|
||||
ReplyTo: "huangxinyun@dreaminglife.cn",
|
||||
Subject: "test mail",
|
||||
Body: string(by),
|
||||
Attachment: []interfaces.MessageAttachment{
|
||||
// {
|
||||
// Name: "/code/statistic/out.xlsx",
|
||||
// ContentType: "",
|
||||
// WithFile: true,
|
||||
// },
|
||||
// {
|
||||
// Name: "/code/statistic/origin.xlsx",
|
||||
// ContentType: "",
|
||||
// WithFile: true,
|
||||
// },
|
||||
// {
|
||||
// Name: "/code/statistic/out2.xlsx",
|
||||
// ContentType: "",
|
||||
// WithFile: true,
|
||||
// },
|
||||
},
|
||||
}
|
||||
msg := mailx.NewMessage().
|
||||
To("995116474@qq.com").
|
||||
Cc("287852692@qq.com").
|
||||
Bcc("1362716835@qq.com").
|
||||
ReplyTo("huangxinyun@dreaminglife.cn").
|
||||
Subject("test mail").
|
||||
Body(string(by)).
|
||||
Build()
|
||||
|
||||
err = ini.Send(ctx, msg)
|
||||
err = client.Send(ctx, msg)
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
// func TestQQ(t *testing.T) {
|
||||
// TestSendFakeServer 使用内存 SMTP 服务器做端到端验证,不依赖外部网络。
|
||||
func TestSendFakeServer(t *testing.T) {
|
||||
var got bytes.Buffer
|
||||
host, port := startFakeSMTPServer(t, func(data []byte) { got.Write(data) })
|
||||
|
||||
// // 发件人邮箱
|
||||
// from := "995116474@qq.com"
|
||||
// // 授权码,而非密码
|
||||
// authCode := "xxxxxxxxxxxxxxxxxxxxxx"
|
||||
// // 收件人邮箱,可以是多个收件人
|
||||
// to := []string{"yun@yun.ink"}
|
||||
// // 邮件服务器信息
|
||||
// smtpHost := "smtp.qq.com"
|
||||
// smtpPort := "587" // 或使用465,根据你的SMTP服务器要求设置
|
||||
client := smtp.New(smtp.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: "sender@example.com",
|
||||
Password: "secret",
|
||||
Encryption: smtp.EncryptionNone, // 假服务器不支持 TLS,使用明文验证投递流程
|
||||
})
|
||||
|
||||
// mail := mailx.NewMailx(from, authCode, smtpHost, smtpPort)
|
||||
msg := mailx.NewMessage().
|
||||
From("sender@example.com").
|
||||
To("to@example.com", "to2@example.com").
|
||||
Cc("cc@example.com").
|
||||
Subject("测试主题").
|
||||
Body("<h1>Hello</h1>").
|
||||
ReplyTo("reply@example.com").
|
||||
AttachBytes("a.txt", []byte("attachment-data")).
|
||||
Build()
|
||||
|
||||
// msg := mailx.Message{
|
||||
// To: to,
|
||||
// Subject: "test mail",
|
||||
// Body: "测试",
|
||||
// }
|
||||
// err := mail.Send(msg)
|
||||
// fmt.Println(err)
|
||||
// }
|
||||
if err := client.Send(context.Background(), msg); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
out := got.String()
|
||||
for _, want := range []string{
|
||||
"From: sender@example.com",
|
||||
"To: to@example.com, to2@example.com",
|
||||
"Cc: cc@example.com",
|
||||
"Reply-To: reply@example.com",
|
||||
"Subject: =?UTF-8?q?",
|
||||
"multipart/mixed",
|
||||
"text/html; charset=UTF-8",
|
||||
"attachment; filename",
|
||||
"base64",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivered MIME missing %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmptySender(t *testing.T) {
|
||||
client := smtp.New(smtp.Config{Host: "h", Port: 25})
|
||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
||||
if err := client.Send(context.Background(), msg); err == nil {
|
||||
t.Fatal("Send without sender should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
if got := smtp.New(smtp.Config{}).Name(); got != "smtp" {
|
||||
t.Errorf("Name() = %q, want smtp", got)
|
||||
}
|
||||
}
|
||||
|
||||
// startFakeSMTPServer 启动一个内存 SMTP 服务器,捕获 DATA 阶段内容。
|
||||
func startFakeSMTPServer(t *testing.T, capture func([]byte)) (string, int) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go serveSMTP(conn, capture)
|
||||
}
|
||||
}()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return host, port
|
||||
}
|
||||
|
||||
// serveSMTP 处理单个 SMTP 连接,支持 EHLO/AUTH PLAIN/MAIL/RCPT/DATA/QUIT。
|
||||
func serveSMTP(conn net.Conn, capture func([]byte)) {
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
w := bufio.NewWriter(conn)
|
||||
respond := func(line string) {
|
||||
_, _ = w.WriteString(line + "\r\n")
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
respond("220 fake ESMTP ready")
|
||||
|
||||
inData := false
|
||||
var data bytes.Buffer
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
|
||||
if inData {
|
||||
if line == "." {
|
||||
inData = false
|
||||
capture(data.Bytes())
|
||||
data.Reset()
|
||||
respond("250 2.0.0 Ok: queued")
|
||||
continue
|
||||
}
|
||||
data.WriteString(line + "\r\n")
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, "EHLO"):
|
||||
respond("250-localhost")
|
||||
respond("250-AUTH PLAIN LOGIN")
|
||||
respond("250 8BITMIME")
|
||||
case strings.HasPrefix(line, "AUTH"):
|
||||
respond("235 2.7.0 Authentication successful")
|
||||
case strings.HasPrefix(line, "MAIL FROM"):
|
||||
respond("250 2.1.0 Ok")
|
||||
case strings.HasPrefix(line, "RCPT TO"):
|
||||
respond("250 2.1.5 Ok")
|
||||
case strings.HasPrefix(line, "DATA"):
|
||||
respond("354 End data with <CR><LF>.<CR><LF>")
|
||||
inData = true
|
||||
case strings.HasPrefix(line, "STARTTLS"):
|
||||
// 假服务器不支持 TLS 升级,明确拒绝,避免客户端挂起
|
||||
respond("454 4.7.0 TLS not available")
|
||||
case strings.HasPrefix(line, "QUIT"):
|
||||
respond("221 2.0.0 Bye")
|
||||
return
|
||||
default:
|
||||
respond("502 5.5.2 Command not recognized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendTimeout 验证当 SMTP 服务器不响应时,deadline 能及时中断发送而非无限阻塞。
|
||||
func TestSendTimeout(t *testing.T) {
|
||||
host, port := startSilentSMTPServer(t)
|
||||
|
||||
client := smtp.New(smtp.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Encryption: smtp.EncryptionNone,
|
||||
Timeout: 300 * time.Millisecond, // 短超时
|
||||
})
|
||||
|
||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
||||
|
||||
start := time.Now()
|
||||
err := client.Send(context.Background(), msg)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("Send took %v, expected to time out quickly", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// startSilentSMTPServer 启动一个接受连接但从不响应的服务器,用于测试超时。
|
||||
func startSilentSMTPServer(t *testing.T) (string, int) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 静默:读取请求但从不回复,让客户端因 deadline 超时
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
if _, err := c.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return host, port
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user