331 lines
9.2 KiB
Go
331 lines
9.2 KiB
Go
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)
|
||
}
|
||
}
|