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("

hi

"). 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(`"张三" `). To("to@example.com"). Subject("s"). HTML(`

hi

`). InlineImageBytes("logo1", "logo.png", []byte{0x89, 0x50, 0x4e, 0x47}). Build() data, err := s.buildMIME(msg, `"张三" `) 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: ") && !strings.Contains(out, "Content-ID: ") { 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", ""). // 自定义头 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: ") { 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(`"张三" `) if !strings.Contains(got, "=?UTF-8?q?") || !strings.Contains(got, "") { 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" `}) 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("

html正文

"). 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) } }