This commit is contained in:
yun
2026-08-15 01:38:05 +08:00
parent 139330aee2
commit eb8f660ab5
57 changed files with 5401 additions and 1438 deletions
+3 -1
View File
@@ -1 +1,3 @@
*.log
*.log
.env
examples/with_env/.env
+38
View File
@@ -0,0 +1,38 @@
package mailx
import (
"net/mail"
"strings"
)
// AddressList 解析逗号/分号分隔的地址串,返回规范化后的 []mail.Address。
// 兼容纯地址与 "显示名 <addr>" 形式。
func AddressList(s string) ([]*mail.Address, error) {
parse := func(v string) ([]*mail.Address, error) {
replaced := strings.ReplaceAll(v, ";", ",")
return mail.ParseAddressList(replaced)
}
// 尝试直接解析;若因分号等失败,做简单清理
if addrs, err := parse(s); err == nil {
return addrs, nil
}
return parse(strings.TrimSpace(s))
}
// IsValidAddress 判断单个地址是否合法(兼容显示名形式)
func IsValidAddress(s string) bool {
if s == "" {
return false
}
_, err := mail.ParseAddress(s)
return err == nil
}
// ExtractEmail 从地址(可能含显示名)中提取纯邮箱地址,供 SMTP Mail/Rcpt 使用
func ExtractEmail(s string) (string, error) {
a, err := mail.ParseAddress(s)
if err != nil {
return "", err
}
return a.Address, nil
}
+171 -213
View File
@@ -1,260 +1,218 @@
// Package aliyun 提供阿里云邮件推送(DirectMail)通道。
package aliyun
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"code.yun.ink/pkg/mailx/interfaces"
mailx "code.yun.ink/pkg/mailx"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dm20151123 "github.com/alibabacloud-go/dm-20151123/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// defaultTimeout 单次 API 调用的默认超时
const defaultTimeout = 30 * time.Second
// Config 阿里云邮件推送通道配置
type Config struct {
AccessKeyID string // AccessKey ID
AccessKeySecret string // AccessKey Secret
Endpoint string // Endpoint,默认 dm.aliyuncs.com
AccountName string // 发信地址(必填)
ReplyAddress string // 默认回复地址(可选)
Timeout time.Duration // 单次 API 调用的超时,默认 30s
}
// dmMailer 阿里云 DM 客户端的最小接口(便于测试注入 mock)
type dmMailer interface {
SingleSendMailWithOptions(request *dm20151123.SingleSendMailRequest, runtime *util.RuntimeOptions) (*dm20151123.SingleSendMailResponse, error)
SenderStatisticsDetailByParamWithOptions(request *dm20151123.SenderStatisticsDetailByParamRequest, runtime *util.RuntimeOptions) (*dm20151123.SenderStatisticsDetailByParamResponse, error)
}
// Aliyun 阿里云发送通道
type Aliyun struct {
interfaces.DefaultEmail
client *dm20151123.Client
// params *interfaces.EmialConfigDataAliyun
// logger loggerx.LoggerInterface
cfg Config
initOnce sync.Once
client dmMailer
initErr error
}
func NewAliyun() *Aliyun {
aliyun := &Aliyun{}
aliyun.Options = interfaces.DefaultOptions()
aliyun.EmailType = interfaces.EmailTypeAliyun
return aliyun
// New 创建阿里云通道
func New(cfg Config) *Aliyun {
if cfg.Endpoint == "" {
cfg.Endpoint = "dm.aliyuncs.com"
}
return &Aliyun{cfg: cfg}
}
func (l *Aliyun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
// Name 返回通道名称
func (a *Aliyun) Name() string { return "aliyun" }
for _, o := range opt {
o(&l.Options)
// Send 发送一封邮件
func (a *Aliyun) Send(ctx context.Context, msg *mailx.Message) error {
logger := mailx.LoggerFromContext(ctx)
if len(msg.To) > 100 {
return fmt.Errorf("%w: aliyun up to 100 recipients allowed", mailx.ErrInvalidMessage)
}
if a.cfg.AccountName == "" {
return fmt.Errorf("%w: aliyun AccountName is required", mailx.ErrInvalidConfig)
}
if len(msg.Inline) > 0 {
return fmt.Errorf("%w: aliyun SingleSendMail does not support inline images", mailx.ErrInvalidConfig)
}
l.Options.Logger.Infof(ctx, "Aliyun:%+v", l.Options.Aliyun)
if l.Options.Aliyun == nil {
return nil, errors.New("not aliyun")
}
// 工程代码泄露可能会导致 AccessKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考。
// 建议使用更安全的 STS 方式,更多鉴权访问方式请参见:https://help.aliyun.com/document_detail/378661.html。
config := &openapi.Config{
// 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID。
AccessKeyId: tea.String(l.Options.Aliyun.AccessId),
// 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_SECRET。
AccessKeySecret: tea.String(l.Options.Aliyun.AccessKey),
}
if l.Options.Aliyun.Endpoint == "" {
l.Options.Aliyun.Endpoint = "dm.aliyuncs.com"
}
// Endpoint 请参考 https://api.aliyun.com/product/Dm
config.Endpoint = tea.String(l.Options.Aliyun.Endpoint)
result, err := dm20151123.NewClient(config)
client, err := a.newClient()
if err != nil {
return nil, err
return fmt.Errorf("mailx/aliyun: create client: %w", err)
}
return &Aliyun{
client: result,
}, nil
}
func (l *Aliyun) Send(ctx context.Context, params interfaces.Message) error {
if l.client == nil {
return errors.New("client no init")
// 阿里云 DirectMail 的 HtmlBody 必填;纯文本也需以 HTML 形式传递
htmlBody := msg.Body
if htmlBody == "" {
htmlBody = mailx.EscapeHTML(msg.TextBody)
}
if len(params.To) > 100 {
return errors.New("最多 100 个地址")
}
if l.Options.Aliyun.AccountName == "" {
return errors.New("AccountName 必填")
req := &dm20151123.SingleSendMailRequest{
AccountName: tea.String(a.cfg.AccountName),
ToAddress: tea.String(strings.Join(msg.To, ",")),
Subject: tea.String(msg.Subject),
HtmlBody: tea.String(htmlBody),
AddressType: tea.Int32(0), // 0:随机账号;1:发信地址
}
toAddress := strings.Join(params.To, ",")
singleSendMailRequest := &dm20151123.SingleSendMailRequest{}
singleSendMailRequest.AccountName = tea.String(l.Options.Aliyun.AccountName)
singleSendMailRequest.ToAddress = tea.String(toAddress) // 目标地址,多个 email 地址可以用逗号分隔,最多 100 个地址(支持邮件组)。
singleSendMailRequest.Subject = tea.String(params.Subject)
singleSendMailRequest.HtmlBody = tea.String(params.Body)
singleSendMailRequest.AddressType = tea.Int32(0) // 地址类型。取值:0:为随机账号1:为发信地址
if params.ReplyTo != "" {
singleSendMailRequest.ReplyToAddress = tea.Bool(false)
singleSendMailRequest.ReplyAddress = tea.String(params.ReplyTo)
replyTo := msg.ReplyTo
if replyTo == "" {
replyTo = a.cfg.ReplyAddress
}
if replyTo != "" {
req.ReplyToAddress = tea.Bool(false)
req.ReplyAddress = tea.String(replyTo)
} else {
singleSendMailRequest.ReplyToAddress = tea.Bool(true)
req.ReplyToAddress = tea.Bool(true)
}
runtime := &util.RuntimeOptions{}
tryErr := func() (_e error) {
defer func() {
if r := tea.Recover(recover()); r != nil {
_e = r
}
}()
// 复制代码运行请自行打印 API 的返回值
resp, err := l.client.SingleSendMailWithOptions(singleSendMailRequest, runtime)
by, _ := json.Marshal(resp)
fmt.Printf("resp:%+v err:%+v", string(by), err)
l.Options.Logger.Infof(ctx, "resp:%+v err:%+v", resp, err)
if err != nil {
return err
}
return nil
}()
if tryErr != nil {
l.Options.Logger.Errorf(ctx, "err:%+v", tryErr)
return tryErr
// var error = &tea.SDKError{}
// if _t, ok := tryErr.(*tea.SDKError); ok {
// error = _t
// } else {
// error.Message = tea.String(tryErr.Error())
// }
// // 此处仅做打印展示,请谨慎对待异常处理,在工程项目中切勿直接忽略异常。
// // 错误 message
// fmt.Println(tea.StringValue(error.Message))
// // 诊断地址
// var data interface{}
// d := json.NewDecoder(strings.NewReader(tea.StringValue(error.Data)))
// d.Decode(&data)
// if m, ok := data.(map[string]interface{}); ok {
// recommend, _ := m["Recommend"]
// fmt.Println("recommend", recommend)
// }
// _, _err := util.AssertAsString(error.Message)
// if _err != nil {
// return _err
// }
// 将 ctx 的 deadline 折算为超时,同时应用配置超时
runtime := a.runtimeOptions(ctx)
if _, err := client.SingleSendMailWithOptions(req, runtime); err != nil {
logger.Errorf(ctx, "mailx/aliyun: send failed: %v", err)
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
}
return nil // 实现具体的 Aliyun 发送方法
// 如:return aliyunSDK.SendMail(params)
logger.Infof(ctx, "mailx/aliyun: sent to %v subject=%q", msg.To, msg.Subject)
return nil
}
// 同步状态
func (l *Aliyun) SyncStatus(ctx context.Context) (resp []interfaces.EmailSendRecord, err error) {
start := ""
// 一次同步一天的数据
for {
list, next, err := l.getSendStatus(ctx, start)
l.Options.Logger.Infof(ctx, "list:%+v next:%+v err:%+v", list, next, err)
// newClient 惰性创建并复用阿里云 SDK 客户端(线程安全)。
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
func (a *Aliyun) newClient() (dmMailer, error) {
if a.client != nil {
return a.client, a.initErr
}
a.initOnce.Do(func() {
config := &openapi.Config{
AccessKeyId: tea.String(a.cfg.AccessKeyID),
AccessKeySecret: tea.String(a.cfg.AccessKeySecret),
Endpoint: tea.String(a.cfg.Endpoint),
}
c, err := dm20151123.NewClient(config)
if err != nil {
return nil, err
a.initErr = fmt.Errorf("mailx/aliyun: create client: %w", err)
return
}
a.client = c
})
return a.client, a.initErr
}
// runtimeOptions 根据配置与 ctx 计算超时,返回 RuntimeOptions
func (a *Aliyun) runtimeOptions(ctx context.Context) *util.RuntimeOptions {
timeout := a.cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
if dl, ok := ctx.Deadline(); ok {
if remain := time.Until(dl); remain > 0 && remain < timeout {
timeout = remain
}
}
ro := &util.RuntimeOptions{}
if timeout > 0 {
ms := int(timeout / time.Millisecond)
ro.SetConnectTimeout(ms)
ro.SetReadTimeout(ms)
}
return ro
}
// SyncStatus 同步发送状态(阿里云特有能力,非 Sender 接口的一部分)
func (a *Aliyun) SyncStatus(ctx context.Context) ([]mailx.EmailSendRecord, error) {
logger := mailx.LoggerFromContext(ctx)
client, err := a.newClient()
if err != nil {
return nil, fmt.Errorf("mailx/aliyun: create client: %w", err)
}
runtime := a.runtimeOptions(ctx)
var records []mailx.EmailSendRecord
start := ""
// 分页拉取最近一天的数据
for {
req := &dm20151123.SenderStatisticsDetailByParamRequest{
StartTime: tea.String(time.Now().AddDate(0, 0, -1).Format("2006-01-02 15:04")),
EndTime: tea.String(time.Now().Format("2006-01-02 15:04")),
Length: tea.Int32(100),
}
if start != "" {
req.NextStart = tea.String(start)
}
for _, val := range list {
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(val.LastUpdateTime), time.Local)
// 0:成功 2:无效地址 3:垃圾邮件 4:失败
record := interfaces.EmailSendRecord{
AccountName: tea.StringValue(val.AccountName),
UpdateTime: t.UnixMilli(),
ToUser: tea.StringValue(val.ToAddress),
Subject: tea.StringValue(val.Subject),
ErrorMessage: tea.StringValue(val.Message),
}
switch tea.Int32Value(val.Status) {
case 0:
record.Status = interfaces.EmailSendStatusSuccess
case 2:
record.Status = interfaces.EmailSendStatusInvalidAddress
case 3:
record.Status = interfaces.EmailSendStatusSpam
case 4:
record.Status = interfaces.EmailSendStatusFailed
default:
record.Status = interfaces.EmailSendStatusUnknown
}
resp = append(resp, record)
resp, err := client.SenderStatisticsDetailByParamWithOptions(req, runtime)
if err != nil {
logger.Errorf(ctx, "mailx/aliyun: sync status failed: %v", err)
return nil, fmt.Errorf("mailx/aliyun: sync status: %w", err)
}
if next == nil || len(*next) == 0 {
if resp == nil || resp.Body == nil || resp.Body.Data == nil {
break
}
start = *next
}
return resp, nil
}
func (l *Aliyun) getSendStatus(ctx context.Context, start string) (list []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail, nextStart *string, err error) {
now := time.Now().Local()
senderStatisticsDetailByParamRequest := &dm20151123.SenderStatisticsDetailByParamRequest{
StartTime: tea.String(now.AddDate(0, 0, -1).Format("2006-01-02 15:04")),
EndTime: tea.String(now.Format("2006-01-02 15:04")),
Length: tea.Int32(100),
}
if start != "" {
senderStatisticsDetailByParamRequest.NextStart = tea.String(start)
}
runtime := &util.RuntimeOptions{}
tryErr := func() (_e error) {
defer func() {
if r := tea.Recover(recover()); r != nil {
_e = r
}
}()
// 复制代码运行请自行打印 API 的返回值
resp, _err := l.client.SenderStatisticsDetailByParamWithOptions(senderStatisticsDetailByParamRequest, runtime)
if _err != nil {
l.Options.Logger.Errorf(ctx, "resp:%+v err:%+v", resp, _err)
return _err
for _, d := range resp.Body.Data.MailDetail {
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(d.LastUpdateTime), time.Local)
records = append(records, mailx.EmailSendRecord{
AccountName: tea.StringValue(d.AccountName),
UpdateTime: t.UnixMilli(),
ToUser: tea.StringValue(d.ToAddress),
Subject: tea.StringValue(d.Subject),
ErrorMessage: tea.StringValue(d.Message),
Status: mapSendStatus(tea.Int32Value(d.Status)),
})
}
if resp == nil || resp.Body == nil || resp.Body.Data == nil {
return errors.New("resp.Body.Data is nil")
if resp.Body.NextStart == nil || *resp.Body.NextStart == "" {
break
}
start = *resp.Body.NextStart
}
return records, nil
}
list = resp.Body.Data.MailDetail
nextStart = resp.Body.NextStart
return nil
}()
if tryErr != nil {
l.Options.Logger.Errorf(ctx, "err:%+v", tryErr)
return nil, nil, tryErr
// var error = &tea.SDKError{}
// if _t, ok := tryErr.(*tea.SDKError); ok {
// error = _t
// } else {
// error.Message = tea.String(tryErr.Error())
// }
// // 此处仅做打印展示,请谨慎对待异常处理,在工程项目中切勿直接忽略异常。
// // 错误 message
// fmt.Println(tea.StringValue(error.Message))
// // 诊断地址
// var data interface{}
// d := json.NewDecoder(strings.NewReader(tea.StringValue(error.Data)))
// d.Decode(&data)
// if m, ok := data.(map[string]interface{}); ok {
// recommend, _ := m["Recommend"]
// fmt.Println("recommend:", recommend)
// }
// _, _err := util.AssertAsString(error.Message)
// if _err != nil {
// l.logger.Errorf(ctx, "resp:%+v err:%+v", error.Message, _err)
// return nil, nil, _err
// }
// mapSendStatus 阿里云状态码映射为统一状态
func mapSendStatus(code int32) mailx.EmailSendStatus {
switch code {
case 0:
return mailx.EmailSendStatusSuccess
case 2:
return mailx.EmailSendStatusInvalidAddress
case 3:
return mailx.EmailSendStatusSpam
case 4:
return mailx.EmailSendStatusFailed
default:
return mailx.EmailSendStatusUnknown
}
return list, nextStart, nil
}
+58
View File
@@ -0,0 +1,58 @@
package aliyun
import (
"context"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
var _ mailx.Sender = (*Aliyun)(nil)
func TestConfigDefaults(t *testing.T) {
a := New(Config{})
if a.cfg.Endpoint != "dm.aliyuncs.com" {
t.Errorf("default endpoint = %q, want dm.aliyuncs.com", a.cfg.Endpoint)
}
// 显式指定 Endpoint 时不应被覆盖
a = New(Config{Endpoint: "dm.eu-central-1.aliyuncs.com"})
if a.cfg.Endpoint != "dm.eu-central-1.aliyuncs.com" {
t.Errorf("explicit endpoint overwritten: %q", a.cfg.Endpoint)
}
}
func TestName(t *testing.T) {
if got := New(Config{}).Name(); got != "aliyun" {
t.Errorf("Name() = %q, want aliyun", got)
}
}
func TestMapSendStatus(t *testing.T) {
cases := []struct {
in int32
want mailx.EmailSendStatus
}{
{0, mailx.EmailSendStatusSuccess},
{2, mailx.EmailSendStatusInvalidAddress},
{3, mailx.EmailSendStatusSpam},
{4, mailx.EmailSendStatusFailed},
{9, mailx.EmailSendStatusUnknown},
}
for _, c := range cases {
if got := mapSendStatus(c.in); got != c.want {
t.Errorf("mapSendStatus(%d) = %v, want %v", c.in, got, c.want)
}
}
}
func TestSendWithoutConfig(t *testing.T) {
// 缺少 AccountName 时应在发送前报错,而不是触发网络请求
a := New(Config{AccessKeyID: "ak", AccessKeySecret: "sk"})
err := a.Send(context.Background(), mailx.NewMessage().
To("a@b.com").
Subject("s").
Build())
if err == nil {
t.Fatal("Send without AccountName should error")
}
}
+290
View File
@@ -0,0 +1,290 @@
package aliyun
import (
"context"
"errors"
"testing"
"time"
mailx "code.yun.ink/pkg/mailx"
dm20151123 "github.com/alibabacloud-go/dm-20151123/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// mockDM 实现 dmMailer 接口,记录请求并可按需返回错误
type mockDM struct {
sendErr error
statsErr error
lastSend *dm20151123.SingleSendMailRequest
lastStats *dm20151123.SenderStatisticsDetailByParamRequest
// statsPages 模拟分页返回;nil 时返回单个空页
statsPages []*dm20151123.SenderStatisticsDetailByParamResponse
}
func (m *mockDM) SingleSendMailWithOptions(req *dm20151123.SingleSendMailRequest, _ *util.RuntimeOptions) (*dm20151123.SingleSendMailResponse, error) {
m.lastSend = req
if m.sendErr != nil {
return nil, m.sendErr
}
return &dm20151123.SingleSendMailResponse{}, nil
}
func (m *mockDM) SenderStatisticsDetailByParamWithOptions(req *dm20151123.SenderStatisticsDetailByParamRequest, _ *util.RuntimeOptions) (*dm20151123.SenderStatisticsDetailByParamResponse, error) {
m.lastStats = req
if m.statsErr != nil {
return nil, m.statsErr
}
if len(m.statsPages) > 0 {
resp := m.statsPages[0]
m.statsPages = m.statsPages[1:]
return resp, nil
}
return &dm20151123.SenderStatisticsDetailByParamResponse{Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{}}, nil
}
// TestSendSuccess 验证完整发送流程与请求构造
func TestSendSuccess(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com", ReplyAddress: "r@example.com"}, client: mock}
msg := mailx.NewMessage().
To("a@example.com", "b@example.com").
Subject("hi").
HTML("<b>html</b>").
Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
req := mock.lastSend
if req == nil {
t.Fatal("no request captured")
}
if tea.StringValue(req.AccountName) != "noreply@example.com" {
t.Errorf("AccountName = %q", tea.StringValue(req.AccountName))
}
if tea.StringValue(req.ToAddress) != "a@example.com,b@example.com" {
t.Errorf("ToAddress = %q", tea.StringValue(req.ToAddress))
}
if tea.StringValue(req.Subject) != "hi" {
t.Errorf("Subject = %q", tea.StringValue(req.Subject))
}
if tea.StringValue(req.HtmlBody) != "<b>html</b>" {
t.Errorf("HtmlBody = %q", tea.StringValue(req.HtmlBody))
}
if tea.BoolValue(req.ReplyToAddress) {
t.Errorf("ReplyToAddress = %v, want false (uses custom ReplyAddress)", tea.BoolValue(req.ReplyToAddress))
}
if tea.StringValue(req.ReplyAddress) != "r@example.com" {
t.Errorf("ReplyAddress = %q", tea.StringValue(req.ReplyAddress))
}
}
// TestSendTextOnly 验证纯文本正文被转义为 HTML
func TestSendTextOnly(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Text("a < b & c").Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
got := tea.StringValue(mock.lastSend.HtmlBody)
if got != "a &lt; b &amp; c" {
t.Errorf("escaped HtmlBody = %q", got)
}
}
// TestSendMsgReplyTo 验证 msg.ReplyTo 优先于 cfg.ReplyAddress
func TestSendMsgReplyTo(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com", ReplyAddress: "cfg@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").ReplyTo("msg@example.com").Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if tea.StringValue(mock.lastSend.ReplyAddress) != "msg@example.com" {
t.Errorf("ReplyAddress = %q, want msg@example.com", tea.StringValue(mock.lastSend.ReplyAddress))
}
}
// TestSendNoReplyTo 验证无回复地址时 ReplyToAddress=true
func TestSendNoReplyTo(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if !tea.BoolValue(mock.lastSend.ReplyToAddress) {
t.Errorf("ReplyToAddress = %v, want true", tea.BoolValue(mock.lastSend.ReplyToAddress))
}
}
// TestSendTooManyRecipients 验证超过 100 收件人报错
func TestSendTooManyRecipients(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
to := make([]string, 101)
for i := range to {
to[i] = "u@example.com"
}
msg := mailx.NewMessage().To(to...).Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidMessage) {
t.Fatalf("err = %v, want ErrInvalidMessage", err)
}
}
// TestSendNoAccount 验证缺少 AccountName 报错
func TestSendNoAccount(t *testing.T) {
a := &Aliyun{cfg: Config{}, client: &mockDM{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendInlineError 验证内嵌图片报错
func TestSendInlineError(t *testing.T) {
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: &mockDM{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").
InlineImageBytes("cid1", "a.png", []byte{1}).
Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendFail 验证发送失败包装为 ErrSendFailed
func TestSendFail(t *testing.T) {
mock := &mockDM{sendErr: errors.New("aliyun down")}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
t.Fatalf("err = %v, want ErrSendFailed", err)
}
}
// TestRuntimeOptions 验证超时计算
func TestRuntimeOptions(t *testing.T) {
// 默认超时 30s
a := &Aliyun{cfg: Config{}}
ro := a.runtimeOptions(context.Background())
if ro == nil {
t.Fatal("runtime options nil")
}
// 配置超时 1s
a = &Aliyun{cfg: Config{Timeout: time.Second}}
ro = a.runtimeOptions(context.Background())
// ctx 已有更早 deadline
early, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
ro = a.runtimeOptions(early)
_ = ro // 只要不 panic 即可
}
// TestSyncStatusSuccess 验证状态同步记录映射
func TestSyncStatusSuccess(t *testing.T) {
mock := &mockDM{
statsPages: []*dm20151123.SenderStatisticsDetailByParamResponse{
{
Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{
Data: &dm20151123.SenderStatisticsDetailByParamResponseBodyData{
MailDetail: []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail{
{
AccountName: tea.String("noreply@example.com"),
ToAddress: tea.String("a@example.com"),
Subject: tea.String("hi"),
Status: tea.Int32(0),
Message: tea.String(""),
LastUpdateTime: tea.String("2026-08-15T10:00Z"),
},
{
AccountName: tea.String("noreply@example.com"),
ToAddress: tea.String("b@example.com"),
Subject: tea.String("bye"),
Status: tea.Int32(4),
Message: tea.String("rejected"),
LastUpdateTime: tea.String("2026-08-15T11:00Z"),
},
},
},
NextStart: tea.String("page2"),
},
},
{
Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{
Data: &dm20151123.SenderStatisticsDetailByParamResponseBodyData{
MailDetail: []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail{
{
ToAddress: tea.String("c@example.com"),
Subject: tea.String("page2 mail"),
Status: tea.Int32(2),
},
},
},
},
},
},
}
a := &Aliyun{cfg: Config{}, client: mock}
records, err := a.SyncStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if len(records) != 3 {
t.Fatalf("records len = %d, want 3 (pagination)", len(records))
}
if records[0].Status != mailx.EmailSendStatusSuccess {
t.Errorf("records[0].Status = %v, want Success", records[0].Status)
}
if records[1].Status != mailx.EmailSendStatusFailed {
t.Errorf("records[1].Status = %v, want Failed", records[1].Status)
}
if records[1].ErrorMessage != "rejected" {
t.Errorf("records[1].ErrorMessage = %q", records[1].ErrorMessage)
}
if records[2].Status != mailx.EmailSendStatusInvalidAddress {
t.Errorf("records[2].Status = %v, want InvalidAddress", records[2].Status)
}
// 验证第二页请求带 NextStart
if mock.lastStats == nil || tea.StringValue(mock.lastStats.NextStart) != "page2" {
t.Errorf("second page NextStart = %v", mock.lastStats)
}
}
// TestSyncStatusFail 验证状态同步失败包装错误
func TestSyncStatusFail(t *testing.T) {
mock := &mockDM{statsErr: errors.New("api error")}
a := &Aliyun{cfg: Config{}, client: mock}
if _, err := a.SyncStatus(context.Background()); err == nil {
t.Fatal("SyncStatus should error")
}
}
// TestNewClientReuse 验证 client 惰性复用(只初始化一次)
func TestNewClientReuse(t *testing.T) {
a := New(Config{AccessKeyID: "ak", AccessKeySecret: "sk", AccountName: "n@e.com"})
c1, err := a.newClient()
if err != nil {
t.Fatal(err)
}
c2, err := a.newClient()
if err != nil {
t.Fatal(err)
}
if c1 != c2 {
t.Error("client should be reused")
}
}
+30 -51
View File
@@ -5,73 +5,52 @@ import (
"os"
"testing"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/aliyun"
"code.yun.ink/pkg/mailx/interfaces"
)
func TestSend(t *testing.T) {
aliyun := aliyun.NewAliyun()
ctx := context.Background()
ali, err := aliyun.SetOption(ctx, interfaces.SetAliyun(&interfaces.EmialConfigDataAliyun{
AccessId: "LTAI5tEQ8L8fmDir8udD3CFr",
AccessKey: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
Endpoint: "dm.aliyuncs.com",
AccountName: "test@email.aisz.org", //"test@email.aisz.org",
ReplyAddress: "287852692@qq.com",
}))
if err != nil {
t.Fatal(err)
if testing.Short() {
t.Skip("skip real send in short mode")
}
by, err := os.ReadFile("./assets/zh_Hant.html")
if err != nil {
t.Fatal(err)
}
t.Log(string(by))
err = ali.Send(ctx, interfaces.Message{
To: []string{"995116474@qq.com"},
Subject: "测试主题",
Body: string(by),
client := aliyun.New(aliyun.Config{
AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
Endpoint: "dm.aliyuncs.com",
AccountName: "test@email.aisz.org",
ReplyAddress: "287852692@qq.com",
})
ctx := context.Background()
body, err := os.ReadFile("./assets/zh_Hant.html")
if err != nil {
t.Fatal(err)
}
t.Log(string(body))
err = client.Send(ctx, mailx.NewMessage().
To("995116474@qq.com").
Subject("测试主题").
Body(string(body)).
Build())
if err != nil {
t.Fatal("resp err", err)
}
t.Log("send success")
}
// func TestSyncStatus(t *testing.T) {
// aliyun := &aliyun.Aliyun{}
// ctx := context.Background()
// global.Logger = loggerx.NewLogger(ctx)
// ali, err := aliyun.InitEmail(ctx, interfaces.EmailConfigData{
// Aliyun: &interfaces.EmialConfigDataAliyun{
// AccessId: "LTAI5tEQ8L8fmDir8udD3CFr",
// AccessKey: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
// Endpoint: "dm.aliyuncs.com",
// AccountName: "test@email.aisz.org",
// ReplyAddress: "287852692@qq.com",
// },
// client := aliyun.New(aliyun.Config{
// AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
// AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
// Endpoint: "dm.aliyuncs.com",
// AccountName: "test@email.aisz.org",
// ReplyAddress: "287852692@qq.com",
// })
// ctx := context.Background()
// list, err := client.SyncStatus(ctx)
// if err != nil {
// t.Fatal(err)
// }
// list, err := ali.SyncStatus(ctx)
// if err != nil {
// t.Fatal(err)
// }
// _ = list
// // global.Logger.Infof(ctx, "status: %v", list)
// t.Log("status:", list)
// }
+108 -61
View File
@@ -1,90 +1,137 @@
// Package aws 提供 Amazon SES 邮件发送通道。
package aws
import (
"context"
"errors"
"fmt"
"sync"
"time"
"code.yun.ink/pkg/mailx/interfaces"
mailx "code.yun.ink/pkg/mailx"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/aws/aws-sdk-go/service/ses/sesiface"
)
// 不支持变更发信人(必须配置好)
// defaultTimeout 单次 API 调用的默认超时
const defaultTimeout = 30 * time.Second
// Config AWS SES 通道配置
type Config struct {
AccessKeyID string // AccessKey ID
AccessKeySecret string // AccessKey Secret
Region string // Region,默认 ap-northeast-1
Sender string // 默认发件人(必填,AWS 需要预先验证发件地址)
Timeout time.Duration // 单次 API 调用的超时,默认 30s;0 表示交给 ctx 控制
}
// Aws AWS SES 发送通道
type Aws struct {
interfaces.DefaultEmail
// params *interfaces.EmailConfigDataAws
cfg Config
initOnce sync.Once
svc sesiface.SESAPI
initErr error
}
func NewAws() *Aws {
aws := &Aws{}
aws.Options = interfaces.DefaultOptions()
aws.EmailType = interfaces.EmailTypeAws
return aws
// New 创建 AWS 通道
func New(cfg Config) *Aws {
if cfg.Region == "" {
cfg.Region = "ap-northeast-1"
}
return &Aws{cfg: cfg}
}
func (l *Aws) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
// Name 返回通道名称
func (a *Aws) Name() string { return "aws" }
for _, o := range opt {
o(&l.Options)
// Send 发送一封邮件
func (a *Aws) Send(ctx context.Context, msg *mailx.Message) error {
logger := mailx.LoggerFromContext(ctx)
sender := msg.From
if sender == "" {
sender = a.cfg.Sender
}
if sender == "" {
return fmt.Errorf("%w: aws sender is required", mailx.ErrInvalidConfig)
}
if len(msg.Inline) > 0 {
return fmt.Errorf("%w: aws SendEmail does not support inline images; use raw message mode", mailx.ErrInvalidConfig)
}
l.Options.Logger.Infof(ctx, "Aws:%+v", l.Options.Aws)
if l.Options.Aws == nil {
return nil, errors.New("not aws")
svc, err := a.client()
if err != nil {
return err
}
if l.Options.Aws.Region == "" {
l.Options.Aws.Region = "ap-northeast-1"
}
return l, nil
}
func (l *Aws) Send(ctx context.Context, params interfaces.Message) error {
if l.Options.Aws == nil {
return errors.New("not init")
}
// 配置AWS认证信息
config := aws.Config{
Region: aws.String(l.Options.Aws.Region), // 设置你的AWS区域
Credentials: credentials.NewStaticCredentials(l.Options.Aws.AccessId, l.Options.Aws.AccessSecret, ""),
}
// 创建AWS会话
sess := session.Must(session.NewSession(&config))
// 创建SES客户端
svc := ses.New(sess)
toAddress := []*string{}
for _, val := range params.To {
toAddress = append(toAddress, aws.String(val))
}
// 使用SES服务发送邮件
_, err := svc.SendEmail(&ses.SendEmailInput{
input := &ses.SendEmailInput{
Source: aws.String(sender),
Destination: &ses.Destination{
ToAddresses: toAddress,
ToAddresses: aws.StringSlice(msg.To),
CcAddresses: aws.StringSlice(msg.Cc),
BccAddresses: aws.StringSlice(msg.Bcc),
},
Message: &ses.Message{
Body: &ses.Body{
Html: &ses.Content{
Data: aws.String(params.Body),
Charset: aws.String("UTF-8"),
},
},
Subject: &ses.Content{
Data: aws.String(params.Subject),
Charset: aws.String("UTF-8"),
},
Subject: &ses.Content{Data: aws.String(msg.Subject), Charset: aws.String("UTF-8")},
Body: awsBody(msg),
},
Source: aws.String(l.Options.Aws.Sender),
})
}
if msg.ReplyTo != "" {
input.ReplyToAddresses = aws.StringSlice([]string{msg.ReplyTo})
}
// svc.SendRawEmail()
ctx, cancel := a.withTimeout(ctx)
defer cancel()
if _, err := svc.SendEmailWithContext(ctx, input); err != nil {
logger.Errorf(ctx, "mailx/aws: send to %v failed: %v", msg.To, err)
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
}
logger.Infof(ctx, "mailx/aws: sent to %v subject=%q", msg.To, msg.Subject)
return nil
}
return err
// client 惰性初始化并复用 SES 客户端(线程安全)。
// 若已通过测试或其他方式注入 svc,则直接返回注入的客户端。
func (a *Aws) client() (sesiface.SESAPI, error) {
if a.svc != nil {
return a.svc, a.initErr
}
a.initOnce.Do(func() {
sess, err := session.NewSession(&aws.Config{
Region: aws.String(a.cfg.Region),
Credentials: credentials.NewStaticCredentials(a.cfg.AccessKeyID, a.cfg.AccessKeySecret, ""),
})
if err != nil {
a.initErr = fmt.Errorf("%w: create aws session: %v", mailx.ErrInvalidConfig, err)
return
}
a.svc = ses.New(sess)
})
return a.svc, a.initErr
}
// withTimeout 叠加配置超时到 ctx(已有更早 deadline 时保持不变)
func (a *Aws) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
if a.cfg.Timeout <= 0 {
return ctx, func() {}
}
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= a.cfg.Timeout {
return ctx, func() {}
}
return context.WithTimeout(ctx, a.cfg.Timeout)
}
// awsBody 构造 SES Body:优先使用 Html,其次 Text;两者都有时同时提供
func awsBody(msg *mailx.Message) *ses.Body {
body := &ses.Body{}
if msg.Body != "" {
body.Html = &ses.Content{Data: aws.String(msg.Body), Charset: aws.String("UTF-8")}
}
if msg.TextBody != "" {
body.Text = &ses.Content{Data: aws.String(msg.TextBody), Charset: aws.String("UTF-8")}
}
return body
}
+40
View File
@@ -0,0 +1,40 @@
package aws
import (
"context"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
var _ mailx.Sender = (*Aws)(nil)
func TestConfigDefaults(t *testing.T) {
a := New(Config{})
if a.cfg.Region != "ap-northeast-1" {
t.Errorf("default region = %q, want ap-northeast-1", a.cfg.Region)
}
// 显式指定 Region 时不应被覆盖
a = New(Config{Region: "us-east-1"})
if a.cfg.Region != "us-east-1" {
t.Errorf("explicit region overwritten: %q", a.cfg.Region)
}
}
func TestName(t *testing.T) {
if got := New(Config{}).Name(); got != "aws" {
t.Errorf("Name() = %q, want aws", got)
}
}
func TestSendWithoutSender(t *testing.T) {
// 缺少 Sender 时应立即报错,不创建 AWS 会话
a := New(Config{})
err := a.Send(context.Background(), mailx.NewMessage().
To("a@b.com").
Subject("s").
Build())
if err == nil {
t.Fatal("Send without sender should error")
}
}
+183
View File
@@ -0,0 +1,183 @@
package aws
import (
"context"
"errors"
"testing"
"time"
mailx "code.yun.ink/pkg/mailx"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/aws/aws-sdk-go/service/ses/sesiface"
)
// mockSES 通过嵌入 sesiface.SESAPI 实现 mock,仅覆盖 SendEmailWithContext
type mockSES struct {
sesiface.SESAPI
sendErr error
lastIn *ses.SendEmailInput
lastCtx context.Context
}
func (m *mockSES) SendEmailWithContext(ctx aws.Context, input *ses.SendEmailInput, _ ...request.Option) (*ses.SendEmailOutput, error) {
m.lastCtx = ctx
m.lastIn = input
if m.sendErr != nil {
return nil, m.sendErr
}
return &ses.SendEmailOutput{MessageId: aws.String("mock-id")}, nil
}
// TestSendSuccess 验证完整发送流程与输入构造
func TestSendSuccess(t *testing.T) {
mock := &mockSES{}
a := &Aws{cfg: Config{Sender: "noreply@example.com"}, svc: mock}
msg := mailx.NewMessage().
To("a@example.com", "b@example.com").
Cc("cc@example.com").
Bcc("bcc@example.com").
Subject("hi").
Text("plain").
HTML("<b>html</b>").
ReplyTo("reply@example.com").
Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
in := mock.lastIn
if in == nil {
t.Fatal("no input captured")
}
if *in.Source != "noreply@example.com" {
t.Errorf("Source = %q", *in.Source)
}
if len(in.Destination.ToAddresses) != 2 || len(in.Destination.CcAddresses) != 1 || len(in.Destination.BccAddresses) != 1 {
t.Errorf("dest addresses wrong: %+v", in.Destination)
}
if len(in.ReplyToAddresses) != 1 || *in.ReplyToAddresses[0] != "reply@example.com" {
t.Errorf("ReplyTo = %+v", in.ReplyToAddresses)
}
if in.Message.Body.Html == nil || in.Message.Body.Text == nil {
t.Errorf("body should have both html and text: %+v", in.Message.Body)
}
if *in.Message.Subject.Charset != "UTF-8" {
t.Errorf("subject charset = %q", *in.Message.Subject.Charset)
}
}
// TestSendFromFallback 验证 msg.From 为空时回退 cfg.Sender
func TestSendFromFallback(t *testing.T) {
mock := &mockSES{}
a := &Aws{cfg: Config{Sender: "cfg-sender@example.com"}, svc: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if *mock.lastIn.Source != "cfg-sender@example.com" {
t.Errorf("Source = %q, want cfg-sender", *mock.lastIn.Source)
}
}
// TestSendFail 验证发送失败包装为 ErrSendFailed
func TestSendFail(t *testing.T) {
mock := &mockSES{sendErr: errors.New("aws down")}
a := &Aws{cfg: Config{Sender: "s@example.com"}, svc: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
t.Fatalf("err = %v, want ErrSendFailed", err)
}
}
// TestSendNoSender 验证缺少 Sender 时报错且不调用 SDK
func TestSendNoSender(t *testing.T) {
mock := &mockSES{}
a := &Aws{cfg: Config{}, svc: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
if mock.lastIn != nil {
t.Error("SDK should not be called")
}
}
// TestSendInlineError 验证内嵌图片返回明确错误
func TestSendInlineError(t *testing.T) {
mock := &mockSES{}
a := &Aws{cfg: Config{Sender: "s@example.com"}, svc: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").
InlineImageBytes("cid1", "a.png", []byte{1}).
Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestAwsBody 验证 awsBody 各分支
func TestAwsBody(t *testing.T) {
// 仅 HTML
b := awsBody(&mailx.Message{Body: "<b>hi</b>"})
if b.Html == nil || b.Text != nil {
t.Errorf("html only: %+v", b)
}
// 仅 Text
b = awsBody(&mailx.Message{TextBody: "plain"})
if b.Html != nil || b.Text == nil {
t.Errorf("text only: %+v", b)
}
// 两者都有
b = awsBody(&mailx.Message{Body: "<b>hi</b>", TextBody: "plain"})
if b.Html == nil || b.Text == nil {
t.Errorf("both: %+v", b)
}
// 都没有
b = awsBody(&mailx.Message{})
if b.Html != nil || b.Text != nil {
t.Errorf("neither: %+v", b)
}
}
// TestWithTimeout 验证超时叠加逻辑
func TestWithTimeout(t *testing.T) {
ctx := context.Background()
// 未配置 Timeout:原样返回
a := &Aws{cfg: Config{}}
c, cancel := a.withTimeout(ctx)
if c != ctx {
t.Error("no timeout should return original ctx")
}
cancel()
// 配置了 Timeout:返回带 deadline 的 ctx
a = &Aws{cfg: Config{Timeout: time.Second}}
c, cancel = a.withTimeout(ctx)
if c == ctx {
t.Error("with timeout should return new ctx")
}
if _, ok := c.Deadline(); !ok {
t.Error("new ctx should have deadline")
}
cancel()
// ctx 已有更早 deadline:保持原 ctx
early, ecancel := context.WithTimeout(ctx, time.Millisecond)
defer ecancel()
c, cancel = a.withTimeout(early)
if c != early {
t.Error("earlier deadline should keep original ctx")
}
cancel()
}
+16 -29
View File
@@ -4,43 +4,30 @@ import (
"context"
"testing"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/aws"
"code.yun.ink/pkg/mailx/interfaces"
)
// https://ap-northeast-1.console.aws.amazon.com/ses/home?region=ap-northeast-1#/identities
func TestSend(t *testing.T) {
// email:
// #区域
// AwsRegion: "ap-northeast-1"
// #秘钥ID
// AwsAccessKeyId: "AKIAU6GD3MNRHKR4RZG5"
// #秘钥
// AwsSecretAccessKey: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l"
// #发件人
// Source: "chenlihan@dreaminglife.cn"
a := aws.NewAws()
if testing.Short() {
t.Skip("skip real send in short mode")
}
client := aws.New(aws.Config{
AccessKeyID: "AKIAU6GD3MNRHKR4RZG5",
AccessKeySecret: "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",
Region: "ap-northeast-1",
Sender: "chenlihan@dreaminglife.cn",
})
ctx := context.Background()
ini, err := a.SetOption(ctx, interfaces.SetAws(&interfaces.EmailConfigDataAws{
AccessId: "AKIAU6GD3MNRHKR4RZG5",
AccessSecret: "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",
Region: "ap-northeast-1",
Sender: "chenlihan@dreaminglife.cn",
}))
if err != nil {
t.Fatal(err)
}
err = ini.Send(ctx, interfaces.Message{
Form: "chenlihan@dreaminglife.cn",
To: []string{"huangxinyun@dreaminglife.cn"},
Body: "Hello",
Subject: "主题",
})
err := client.Send(ctx, mailx.NewMessage().
From("chenlihan@dreaminglife.cn").
To("huangxinyun@dreaminglife.cn").
Subject("主题").
Body("Hello").
Build())
if err != nil {
t.Fatal(err)
}
+81 -24
View File
@@ -1,42 +1,99 @@
// 使用示例:展示 mailx 的三种典型用法
package main
import (
"context"
"errors"
"fmt"
"log"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/interfaces"
"code.yun.ink/pkg/mailx/aliyun"
"code.yun.ink/pkg/mailx/aws"
"code.yun.ink/pkg/mailx/mailgun"
"code.yun.ink/pkg/mailx/smtp"
)
func main() {
ctx := context.Background()
ch := mailx.Platform
em, err := ch.GetEmail(interfaces.EmailTypeSmtp)
if err != nil {
panic(err)
}
// 使用em进行后续操作
em,err = em.SetOption(ctx, interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{
Username: "support@email.blueoceanpay.com",
Password: "SupporT2017",
ReplyTo: "",
// ===== 方式一:单个通道直接发送 =====
// 465 端口走 SSL587/25 走 STARTTLS;也可显式指定 Encryption
err := smtp.New(smtp.Config{
Host: "smtpdm-ap-southeast-1.aliyun.com",
Port: "80",
Port: 80,
User: "support@email.blueoceanpay.com",
Password: "SupporT2017",
From: "yun@blueoceanpay.com",
}).Send(ctx, mailx.NewMessage().
From(`"蓝海支付" <yun@blueoceanpay.com>`). // 发件人可带显示名
To("995116474@qq.com").
Subject("Test Email").
Text("纯文本正文").
HTML(`<h1>Hello</h1><p>This is a test email.</p><img src="cid:logo1">`).
InlineImage("logo1", "assets/logo.png"). // 内嵌图片
Build())
fmt.Println("smtp send:", err)
// ===== 方式二:多通道管理器,发送时切换通道 =====
mgr := mailx.NewManager()
_ = mgr.Register(smtp.New(smtp.Config{
Host: "smtp.qq.com", Port: 587, User: "from@qq.com", Password: "auth-code",
}))
if err != nil {
panic(err)
_ = mgr.Register(aliyun.New(aliyun.Config{
AccessKeyID: "your-access-key-id", AccessKeySecret: "your-access-key-secret",
AccountName: "noreply@example.com",
}))
_ = mgr.Register(aws.New(aws.Config{
AccessKeyID: "your-ak", AccessKeySecret: "your-sk", Region: "ap-northeast-1",
Sender: "noreply@example.com",
}))
_ = mgr.Register(mailgun.New(mailgun.Config{
APIKey: "your-api-key", Domain: "mg.example.com", Sender: "noreply@example.com",
}))
// 同一通道类型(smtp)注册多份不同配置,用实例名区分
_ = mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{
Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "code-main",
}))
_ = mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{
Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "code-backup",
}))
_ = mgr.SetDefault("aliyun")
// 遍历当前已注册的通道实例信息(实例名 + 类型)
for _, info := range mgr.Senders() {
log.Printf("registered sender: instance=%s type=%s", info.Name, info.Type)
}
log.Println("default:", mgr.Default())
msg := mailx.NewMessage().
From("noreply@example.com").
To("user@example.com").
Subject("Hello").
Text("hello").
HTML("<h1>Hello</h1>").
Build()
if err := mgr.Send(ctx, msg); err != nil { // 默认通道
log.Println("default send:", err)
}
if err := mgr.SendWith(ctx, "aws", msg); err != nil { // 按名称指定通道
log.Println("aws send:", err)
}
err = em.Send(ctx, interfaces.Message{
Form: "yun@blueoceanpay.com",
To: []string{"995116474@qq.com"},
Subject: "Test Email",
Body: "Hello, this is a test email.",
})
if err != nil {
panic(err)
// ===== 方式三:发送时临时指定配置(无需提前注册) =====
if err := mgr.SendBy(ctx, mailgun.New(mailgun.Config{
APIKey: "another-key", Domain: "mg2.example.com", Sender: "noreply@example.com",
}), msg); err != nil {
log.Println("mailgun send:", err)
}
// ===== 错误类型判断 =====
if err := mgr.SendWith(ctx, "no-such-channel", msg); err != nil {
if errors.Is(err, mailx.ErrSenderNotFound) {
log.Println("channel not found, will retry with another channel")
}
}
}
+15
View File
@@ -0,0 +1,15 @@
package mailx
import "errors"
// 包级哨兵错误,供调用方使用 errors.Is / errors.As 精确判断失败原因。
var (
// ErrSenderNotFound 通道未注册时返回
ErrSenderNotFound = errors.New("mailx: sender not found")
// ErrInvalidConfig 通道配置缺失/非法时返回
ErrInvalidConfig = errors.New("mailx: invalid configuration")
// ErrInvalidMessage 消息校验未通过时返回
ErrInvalidMessage = errors.New("mailx: invalid message")
// ErrSendFailed 发送过程中通道返回错误时返回(可继续用 %w 链式展开底层原因)
ErrSendFailed = errors.New("mailx: send failed")
)
+73
View File
@@ -0,0 +1,73 @@
package mailx_test
import (
"context"
"errors"
"fmt"
mailx "code.yun.ink/pkg/mailx"
)
func ExampleNewMessage() {
msg := mailx.NewMessage().
From("noreply@example.com").
To("user@example.com").
Cc("cc@example.com").
Subject("Hello").
HTML("<h1>Hello</h1>").
ReplyTo("support@example.com").
AttachBytes("report.txt", []byte("data")).
Build()
fmt.Println(msg.From, msg.To[0], msg.Subject, len(msg.Attachments))
// Output: noreply@example.com user@example.com Hello 1
}
func ExampleManager_Send() {
mgr := mailx.NewManager()
_ = mgr.Register(newMockSender("mock"))
err := mgr.Send(context.Background(), mailx.NewMessage().
To("user@example.com").
Subject("Hello").
Build())
fmt.Println("send err:", err)
// Output: send err: <nil>
}
func ExampleManager_SendWith() {
mgr := mailx.NewManager()
_ = mgr.Register(newMockSender("smtp"))
_ = mgr.Register(newMockSender("aliyun"))
_ = mgr.SetDefault("smtp")
ctx := context.Background()
msg := mailx.NewMessage().To("user@example.com").Subject("Hi").Build()
_ = mgr.Send(ctx, msg) // 默认通道 smtp
err := mgr.SendWith(ctx, "aliyun", msg)
fmt.Println("send with aliyun err:", err)
// Output: send with aliyun err: <nil>
}
func ExampleManager_SendBy() {
mgr := mailx.NewManager()
// 无需注册,发送时临时指定通道
err := mgr.SendBy(context.Background(), newMockSender("temp"), mailx.NewMessage().
To("user@example.com").
Subject("Hi").
Build())
fmt.Println("send by err:", err)
// Output: send by err: <nil>
}
func ExampleMessage_Validate() {
msg := mailx.NewMessage().Subject("no recipients").Build()
err := msg.Validate()
fmt.Println(errors.Is(err, mailx.ErrInvalidMessage))
fmt.Println(err)
// Output:
// true
// mailx: invalid message: requires at least one recipient
}
+50
View File
@@ -0,0 +1,50 @@
# mailx 使用示例
本目录包含多个由浅入深的示例,方便快速接入。
## 示例总览
| 示例 | 难度 | 说明 |
| --- | --- | --- |
| [`quickstart`](./quickstart) | 入门 | 最简 SMTP 发送,逐行注释,适合第一次接触 |
| [`basic`](./basic) | 入门 | 单通道直接发送,含附件/抄送/回复地址 |
| [`manager`](./manager) | 入门 | 多通道管理器:注册、路由、默认通道、临时指定配置 |
| [`with_env`](./with_env) | 进阶 | 用环境变量管理多通道凭据,避免密钥写死在代码里 |
| [`advanced`](./advanced) | 进阶 | 主备切换、实例遍历、日志注入、超时、内嵌图片、错误分类 |
| [`custom_sender`](./custom_sender) | 进阶 | 实现自定义通道 + 自定义 Logger |
## 快速开始(5 分钟)
```bash
# 1. 进入最简示例
cd examples/quickstart
# 2. 打开 main.go,把里面的 4 个 SMTP 配置改成你自己的
# (QQ 邮箱需先开启 SMTP 服务并获取授权码)
# 3. 运行
go run main.go
```
看到 `send success` 即表示发送成功。
## 运行方式
所有示例都在 mailx 主模块内,直接在示例目录执行 `go run .` 即可:
```bash
# 示例:运行多通道管理器示例
cd examples/manager
go run .
# 示例:运行环境变量示例(先配置好 .env)
cd examples/with_env
go run .
```
## 注意事项
1. **凭据**:所有示例中的密钥都是占位符,运行前必须替换为真实值
2. **SMTP 授权码**:QQ/163 等邮箱不是用邮箱登录密码,而是需要在邮箱后台开启 SMTP 服务后生成的授权码
3. **AWS/阿里云**:需要先完成服务开通、域名验证(发件地址需为已验证身份)
4. **`.env`**`with_env` 示例依赖 `github.com/joho/godotenv`,首次运行前执行 `go get github.com/joho/godotenv``with_env/.env` 含敏感信息,勿提交 git
+111
View File
@@ -0,0 +1,111 @@
// advanced 展示生产环境常用的一些进阶能力。
//
// 覆盖以下内容:
// 1. 同一通道类型(smtp)注册多份不同配置(主备切换)
// 2. 遍历当前注册的通道实例信息
// 3. 注入自定义 Logger(日志可插拔)
// 4. 设置通道超时,防止发送阻塞
// 5. 内嵌图片 + HTML 邮件
// 6. 用 FromMap 从 map 快捷构建消息
// 7. 按错误类型精确处理(errors.Is)
//
// 怎么运行:设置好下方配置后执行:go run main.go
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/smtp"
)
// appLogger 一个简单的 Logger 实现(也可接入 zap/logrus)。
// 生产环境建议实现 Debugf/Infof/Warnf/Errorf 四个方法,用于观测发送过程。
type appLogger struct{}
func (appLogger) Debugf(_ context.Context, format string, args ...any) {
log.Printf("[debug] "+format, args...)
}
func (appLogger) Infof(_ context.Context, format string, args ...any) {
log.Printf("[info] "+format, args...)
}
func (appLogger) Warnf(_ context.Context, format string, args ...any) {
log.Printf("[warn] "+format, args...)
}
func (appLogger) Errorf(_ context.Context, format string, args ...any) {
log.Printf("[error] "+format, args...)
}
func main() {
ctx := context.Background()
mgr := mailx.NewManager()
mgr.SetLogger(appLogger{}) // 注入全局 Logger
// ===== 1. 同一通道类型注册多份配置(主备切换) =====
_ = mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{
Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "main-code",
Timeout: 10 * time.Second, // ===== 4. 通道超时,防止发送阻塞 =====
}))
_ = mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{
Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "backup-code",
Timeout: 10 * time.Second,
}))
_ = mgr.SetDefault("smtp-main") // 默认用主通道
// ===== 2. 遍历当前注册的通道实例信息 =====
for _, info := range mgr.Senders() {
fmt.Printf("sender instance: name=%s type=%s\n", info.Name, info.Type)
}
// ===== 5. 内嵌图片 + HTML 邮件 =====
msg := mailx.NewMessage().
From(`"通知中心" <a@qq.com>`). // 发件人带显示名
To("user@example.com").
Subject("生产环境告警").
Text("这是一封告警邮件,请及时处理。").
HTML(`<h2>磁盘使用率超过 90%</h2><img src="cid:chart1">`).
InlineImageBytes("chart1", "chart.png", []byte{0x89, 0x50, 0x4e, 0x47}). // 内嵌图片,cid:chart1 对应
Header("X-Mailer", "mailx"). // 自定义邮件头
Header("List-Unsubscribe", "<https://example.com/unsub>"). // 退订头(营销邮件合规)
Build()
// ===== 3. 用默认通道发送(自动注入 Logger) =====
if err := mgr.Send(ctx, msg); err != nil {
fmt.Println("main send failed:", err)
}
// ===== 7. 错误类型精确处理:主通道失败自动切换备通道 =====
err := mgr.SendWith(ctx, "smtp-main", msg)
if err != nil {
// 发送失败(如网络/认证)属于 ErrSendFailed,而非配置/消息问题
if errors.Is(err, mailx.ErrSendFailed) {
fmt.Println("main failed, switching to backup:", err)
if err2 := mgr.SendWith(ctx, "smtp-backup", msg); err2 != nil {
fmt.Println("backup send failed:", err2)
return
}
} else if errors.Is(err, mailx.ErrInvalidMessage) {
fmt.Println("message invalid:", err) // 消息本身有问题,重试无意义
return
} else {
fmt.Println("send error:", err)
}
}
// ===== 6. 用 FromMap 从 map 快捷构建消息(适合接入 HTTP 请求体) =====
m, err := mailx.FromMap(map[string]any{
"from": "a@qq.com",
"to": "user@example.com, admin@example.com",
"subject": "welcome",
"text": "hi",
"html": "<b>welcome</b>",
})
if err == nil {
_ = mgr.Send(ctx, m)
}
}
+67
View File
@@ -0,0 +1,67 @@
// basic 展示单个通道直接发送的完整用法。
//
// 功能:用 SMTP 发送一封含 HTML 正文、抄送、附件、内嵌图片的邮件。
//
// 怎么运行:
// 1. 把下方 smtp.Config 的 4 个配置改成你自己的
// 2. 在项目根目录执行:go run ./examples/basic
package main
import (
"context"
"fmt"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/smtp"
)
func main() {
ctx := context.Background()
// ===== 第 1 步:创建 SMTP 通道 =====
client := smtp.New(smtp.Config{
Host: "smtp.qq.com", // SMTP 服务器地址
Port: 587, // 端口:465=SSL587/25=STARTTLS
User: "sender@qq.com", // 账号
Password: "your-auth-code", // 授权码(非邮箱密码)
From: "sender@qq.com", // 默认发件人(可选,Message.From 未设置时使用)
ReplyTo: "sender@qq.com", // 默认回复地址(可选)
})
// ===== 第 2 步:构建消息 =====
// 地址都可带显示名,如 "张三" <a@qq.com>;收件人/抄送/密送可传多个
msg := mailx.NewMessage().
From(`"客服中心" <sender@qq.com>`). // 发件人(带显示名)
To("receiver@example.com"). // 收件人
Cc("manager@example.com"). // 抄送(可选)
Bcc("leader@example.com"). // 密送(可选)
Subject("hello from mailx").
Text("如果邮件客户端不支持 HTML,会显示这段纯文本。"). // 纯文本正文(推荐)
HTML(`<h1>Hello</h1><p>This email is sent by mailx.</p><img src="cid:logo1">`).
ReplyTo("support@example.com"). // 回复地址(可选,覆盖 Config.ReplyTo
Attach("report.txt"). // 按路径添加附件(路径需真实存在)
AttachBytes("summary.txt", []byte("summary content")). // 内存附件
InlineImageBytes("logo1", "logo.png", mustPNG()). // 内嵌图片(HTML 中 src="cid:logo1"
Build()
// ===== 第 3 步:发送 =====
if err := client.Send(ctx, msg); err != nil {
fmt.Println("send failed:", err)
return
}
fmt.Println("send success")
}
// mustPNG 返回一个极小的 1x1 PNG(1 像素透明图)用于演示内嵌图片。
// 实际使用中请替换为真实的图片文件路径或字节内容。
func mustPNG() []byte {
// 一个合法的 1x1 透明 PNG
return []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
}
+80
View File
@@ -0,0 +1,80 @@
// custom_sender 展示如何接入一个全新的发送通道(自定义 Sender)。
//
// 适用场景:公司内部自研邮件网关、短信通道、钉钉/飞书通知等,
// 只需实现 mailx.Sender 接口(Name + Send),即可复用 Manager 的
// 注册路由、默认通道、日志注入、消息校验等全部能力。
//
// 怎么运行:在项目根目录执行:go run ./examples/custom_sender
package main
import (
"context"
"fmt"
"log"
"code.yun.ink/pkg/mailx"
)
// printSender 自定义通道:仅把消息打印到控制台,不真正发送。
// 只需实现 Sender 接口的两个方法:Name() 和 Send()。
type printSender struct {
name string // 通道类型名
}
func newPrintSender(name string) *printSender { return &printSender{name: name} }
// Name 返回通道类型名(如 "print"),用于 Manager 注册与路由
func (p *printSender) Name() string { return p.name }
// Send 实现发送逻辑。ctx 里可通过 mailx.LoggerFromContext 拿到注入的日志器。
func (p *printSender) Send(ctx context.Context, msg *mailx.Message) error {
logger := mailx.LoggerFromContext(ctx) // 读取 Manager 注入的 Logger(可选)
logger.Infof(ctx, "printSender Send called")
fmt.Printf("[%s] to=%v cc=%v subject=%q text=%q html=%q\n",
p.name, msg.To, msg.Cc, msg.Subject, msg.TextBody, msg.Body)
return nil
}
// appLogger 实现 mailx.Logger 接口(4 个方法)。
// 生产中可改用 zap / logrus / slog 等,只需实现同样 4 个方法即可接入。
type appLogger struct{}
func (appLogger) Debugf(_ context.Context, format string, args ...any) {
log.Printf("[debug] "+format, args...)
}
func (appLogger) Infof(_ context.Context, format string, args ...any) {
log.Printf("[info] "+format, args...)
}
func (appLogger) Warnf(_ context.Context, format string, args ...any) {
log.Printf("[warn] "+format, args...)
}
func (appLogger) Errorf(_ context.Context, format string, args ...any) {
log.Printf("[error] "+format, args...)
}
func main() {
ctx := context.Background()
mgr := mailx.NewManager()
mgr.SetLogger(appLogger{}) // 注入 Logger,自定义通道内可读取
// 注册自定义通道
_ = mgr.Register(newPrintSender("print"))
// 同一自定义类型也可注册多份实例(不同配置)
_ = mgr.RegisterNamed("print-debug", newPrintSender("print"))
msg := mailx.NewMessage().
To("user@example.com").
Subject("hello").
Text("from custom sender").
HTML("<b>from custom sender</b>").
Build()
// 通过 Manager 统一发送
if err := mgr.Send(ctx, msg); err != nil {
fmt.Println("send failed:", err)
return
}
fmt.Println("send ok")
}
+92
View File
@@ -0,0 +1,92 @@
// manager 展示多通道管理器的完整用法。
//
// 功能:
// 1. 注册多个不同类型的通道(smtp/aliyun/aws),统一路由
// 2. 同一类型注册多份配置(主备 SMTP 切换)
// 3. 发送时按实例名指定通道,或发送时临时指定配置
// 4. 遍历当前注册的通道实例信息
//
// 怎么运行:
// 1. 替换下方各通道的凭据为真实值
// 2. 在项目根目录执行:go run ./examples/manager
package main
import (
"context"
"fmt"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/aliyun"
"code.yun.ink/pkg/mailx/aws"
"code.yun.ink/pkg/mailx/smtp"
)
func main() {
ctx := context.Background()
mgr := mailx.NewManager()
// ===== 注册多个不同类型的通道 =====
// 用 Register 注册时,实例名 = 通道类型名(smtp / aliyun / aws / mailgun
_ = mgr.Register(smtp.New(smtp.Config{
Host: "smtp.qq.com", Port: 587, User: "a@qq.com", Password: "code",
}))
_ = mgr.Register(aliyun.New(aliyun.Config{
AccessKeyID: "ak", AccessKeySecret: "sk", AccountName: "noreply@example.com",
}))
_ = mgr.Register(aws.New(aws.Config{
AccessKeyID: "ak", AccessKeySecret: "sk", Region: "ap-northeast-1",
Sender: "noreply@example.com",
}))
// ===== 同一类型注册多份配置(主备切换) =====
// 用 RegisterNamed 指定实例名,可注册多个 smtp 实例
_ = mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{
Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "main-code",
}))
_ = mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{
Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "backup-code",
}))
// ===== 设置默认通道(不设置则用第一个注册的) =====
_ = mgr.SetDefault("aliyun")
// ===== 遍历当前注册的通道实例信息 =====
fmt.Println("registered instances:")
for _, info := range mgr.Senders() {
fmt.Printf(" - %s (type: %s)\n", info.Name, info.Type)
}
fmt.Println("default:", mgr.Default())
msg := mailx.NewMessage().
From("noreply@example.com").
To("user@example.com").
Subject("Notice").
Text("hello").
HTML("<h1>Hello</h1>").
Build()
// ===== 用默认通道发送 =====
if err := mgr.Send(ctx, msg); err != nil {
fmt.Println("default send:", err)
}
// ===== 按实例名指定通道发送 =====
if err := mgr.SendWith(ctx, "aws", msg); err != nil {
fmt.Println("aws send:", err)
}
if err := mgr.SendWith(ctx, "smtp-backup", msg); err != nil { // 主 SMTP 失败时可切备用
fmt.Println("smtp-backup send:", err)
}
// ===== 发送时临时指定配置,无需提前注册 =====
if err := mgr.SendBy(ctx, smtp.New(smtp.Config{
Host: "smtp.163.com", Port: 465, User: "b@163.com", Password: "code",
}), msg); err != nil {
fmt.Println("temp smtp send:", err)
}
// ===== 注销一个实例(可选) =====
mgr.Unregister("smtp-main")
fmt.Println("after unregister, instances:", mgr.Names())
}
+51
View File
@@ -0,0 +1,51 @@
// quickstart 是最简单的 mailx 入门示例。
//
// 功能:用 QQ 邮箱 SMTP 发送一封纯文本邮件。
//
// 怎么运行:
// 1. 在 QQ 邮箱「设置 -> 账户」中开启 SMTP,获取授权码(不是 QQ 密码)
// 2. 把下方 main.go 里的 4 个配置改成你自己的
// 3. 在本目录执行:go run main.go
//
// 参考:https://mail.qq.com 开启 SMTP 服务的授权码获取方法
package main
import (
"context"
"fmt"
"code.yun.ink/pkg/mailx" // mailx 核心包:消息构建 + Sender 接口
"code.yun.ink/pkg/mailx/smtp" // smtp 通道:基于 SMTP 协议发送
)
func main() {
// context 用于控制发送的超时与取消,先创建空背景 context
ctx := context.Background()
// ===== 第 1 步:创建发送通道 =====
// smtp.New 接收一个 smtp.Config 配置,返回一个发送通道实例
client := smtp.New(smtp.Config{
Host: "smtp.qq.com", // SMTP 服务器地址
Port: 587, // 端口:465 走 SSL587 走 STARTTLS
User: "sender@qq.com", // 你的 QQ 邮箱地址
Password: "your-auth-code", // 授权码(QQ 邮箱后台开启 SMTP 后获取)
// From: "sender@qq.com", // 可选:默认发件人,不填则用上面的 User
})
// ===== 第 2 步:构建邮件消息 =====
// 使用链式调用(builder 模式),一行一个字段,最后 Build() 生成消息
msg := mailx.NewMessage().
From("sender@qq.com"). // 发件人(可带显示名,如 "张三" <a@qq.com>
To("receiver@example.com"). // 收件人,可传多个:To("a@x.com", "b@x.com")
Subject("hello from mailx"). // 邮件主题
Text("This is a plain text email."). // 纯文本正文
Build() // 生成 *Message
// ===== 第 3 步:发送 =====
// 传入 ctx 和消息,返回 error(nil 表示成功)
if err := client.Send(ctx, msg); err != nil {
fmt.Println("send failed:", err)
return
}
fmt.Println("send success")
}
+31
View File
@@ -0,0 +1,31 @@
# 复制本文件为 .env 并填入你自己的真实凭据
# 注意:.env 包含敏感信息,不要提交到 git(已在 .gitignore 中忽略)
# ===== 收件人(必填) =====
TO_ADDR=receiver@example.com
# 发件人兜底地址(选填)
FROM_ADDR=sender@example.com
# ===== SMTP 通道(选填,配置后即启用) =====
SMTP_HOST=smtp.qq.com
SMTP_PORT=587
SMTP_USER=sender@qq.com
SMTP_PASSWORD=your-auth-code
SMTP_FROM=sender@qq.com
# ===== 阿里云邮件推送(选填) =====
ALIYUN_ACCESS_KEY_ID=your-access-key-id
ALIYUN_ACCESS_KEY_SECRET=your-access-key-secret
ALIYUN_ACCOUNT_NAME=noreply@example.com
# ALIYUN_ENDPOINT=dm.aliyuncs.com
# ===== AWS SES(选填) =====
AWS_ACCESS_KEY_ID=your-aws-ak
AWS_ACCESS_KEY_SECRET=your-aws-sk
AWS_REGION=ap-northeast-1
AWS_SENDER=noreply@example.com
# ===== Mailgun(选填) =====
MAILGUN_API_KEY=your-api-key
MAILGUN_DOMAIN=mg.example.com
MAILGUN_SENDER=noreply@example.com
+127
View File
@@ -0,0 +1,127 @@
// with_env 展示如何通过环境变量管理通道凭据(避免把密钥写死在代码里)。
//
// 适用场景:已有多个通道的凭据(SMTP / 阿里云 / AWS SES / Mailgun),
// 希望在程序启动时从环境变量读取配置,注册到 Manager 统一管理。
//
// 怎么运行:
// 1. 复制 .env.example 为 .env(或直接设置下方环境变量)
// 2. 安装 dotenv 依赖:go get github.com/joho/godotenv
// 3. 设置好至少一个通道的凭据后执行:go run main.go
package main
import (
"context"
"fmt"
"log"
"os"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/aliyun"
"code.yun.ink/pkg/mailx/aws"
"code.yun.ink/pkg/mailx/mailgun"
"code.yun.ink/pkg/mailx/smtp"
"github.com/joho/godotenv" // 读取 .env 文件(可选)
)
// buildSenders 从环境变量读取凭据,返回已配置好的通道列表。
// 只返回凭据齐全的通道,未配置的通道直接跳过,方便按需启用。
func buildSenders() []mailx.Sender {
var senders []mailx.Sender
// --- SMTP ---
if os.Getenv("SMTP_HOST") != "" {
senders = append(senders, smtp.New(smtp.Config{
Host: os.Getenv("SMTP_HOST"),
Port: atoi(os.Getenv("SMTP_PORT"), 465),
User: os.Getenv("SMTP_USER"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
}))
}
// --- 阿里云邮件推送 ---
if os.Getenv("ALIYUN_ACCESS_KEY_ID") != "" {
senders = append(senders, aliyun.New(aliyun.Config{
AccessKeyID: os.Getenv("ALIYUN_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("ALIYUN_ACCESS_KEY_SECRET"),
AccountName: os.Getenv("ALIYUN_ACCOUNT_NAME"),
Endpoint: os.Getenv("ALIYUN_ENDPOINT"), // 可选,默认 dm.aliyuncs.com
}))
}
// --- AWS SES ---
if os.Getenv("AWS_ACCESS_KEY_ID") != "" {
senders = append(senders, aws.New(aws.Config{
AccessKeyID: os.Getenv("AWS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("AWS_ACCESS_KEY_SECRET"),
Region: os.Getenv("AWS_REGION"),
Sender: os.Getenv("AWS_SENDER"),
}))
}
// --- Mailgun ---
if os.Getenv("MAILGUN_API_KEY") != "" {
senders = append(senders, mailgun.New(mailgun.Config{
APIKey: os.Getenv("MAILGUN_API_KEY"),
Domain: os.Getenv("MAILGUN_DOMAIN"),
Sender: os.Getenv("MAILGUN_SENDER"),
}))
}
return senders
}
func main() {
ctx := context.Background()
// 可选:加载 .env 文件(没有 .env 时静默忽略,改用系统环境变量)
_ = godotenv.Load()
senders := buildSenders()
if len(senders) == 0 {
log.Fatal("no channel configured, please set env vars (see .env.example)")
}
// 注册到 Manager,第一个注册的成为默认通道
mgr := mailx.NewManager()
for _, s := range senders {
if err := mgr.Register(s); err != nil {
log.Fatalf("register %s: %v", s.Name(), err)
}
}
log.Printf("registered channels: %v (default: %s)", mgr.Names(), mgr.Default())
msg := mailx.NewMessage().
From(firstNonEmpty(os.Getenv("FROM_ADDR"), os.Getenv("SMTP_FROM"), os.Getenv("AWS_SENDER"), os.Getenv("MAILGUN_SENDER"))).
To(os.Getenv("TO_ADDR")).
Subject("hello from mailx (env config)").
Text("This email is sent using env-var configured channel.").
Build()
// 用默认通道发送
if err := mgr.Send(ctx, msg); err != nil {
fmt.Println("send failed:", err)
return
}
fmt.Println("send success")
}
// atoi 解析端口,失败时返回默认值
func atoi(s string, def int) int {
n := 0
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n <= 0 {
return def
}
return n
}
// firstNonEmpty 返回第一个非空字符串
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+144
View File
@@ -0,0 +1,144 @@
package mailx
import (
"encoding/json"
"fmt"
"strings"
)
// FromBytes 从 JSON 字节构建消息(便捷入口,用于接入配置/存储)。
func FromBytes(data []byte) (*Message, error) {
var m Message
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("mailx: unmarshal message: %w", err)
}
return &m, nil
}
// FromMap 从 map 构建消息,常用字段自动映射:
// - from / to / cc / bcc / subject / text / html / body / replyto / reply_to
// - to/cc/bcc 可为 []string、[]any 或 "a@x.com,b@y.com" 分隔串
// - attachments: []map{name,path,data} 或 []string(path)
func FromMap(m map[string]any) (*Message, error) {
if m == nil {
return nil, fmt.Errorf("%w: map is nil", ErrInvalidConfig)
}
msg := &Message{}
var err error
str := func(keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok {
return v
}
}
return ""
}
msg.From = str("from")
msg.Subject = str("subject")
msg.TextBody = firstNonEmptyStr(str("text"), str("textbody"), str("text_body"))
msg.Body = firstNonEmptyStr(str("html"), str("body"))
msg.ReplyTo = firstNonEmptyStr(str("replyto"), str("reply_to"))
if msg.To, err = toList(m["to"]); err != nil {
return nil, err
}
if msg.Cc, err = toList(m["cc"]); err != nil {
return nil, err
}
if msg.Bcc, err = toList(m["bcc"]); err != nil {
return nil, err
}
if atts, ok := m["attachments"]; ok {
msg.Attachments, err = toAttachments(atts)
if err != nil {
return nil, err
}
}
return msg, nil
}
// toList 将任意输入转为地址列表([]string、[]any 或逗号/分号分隔串)
func toList(v any) ([]string, error) {
switch t := v.(type) {
case nil:
return nil, nil
case []string:
return t, nil
case string:
var out []string
for _, p := range strings.FieldsFunc(t, func(r rune) bool { return r == ',' || r == ';' }) {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out, nil
case []any:
out := make([]string, 0, len(t))
for _, item := range t {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out, nil
default:
return nil, fmt.Errorf("mailx: invalid address list type %T", v)
}
}
// toAttachments 将 attachments 输入转为附件列表
func toAttachments(v any) ([]Attachment, error) {
switch t := v.(type) {
case []string:
out := make([]Attachment, 0, len(t))
for _, p := range t {
out = append(out, Attachment{Name: fileBaseName(p), Path: p})
}
return out, nil
case []map[string]any:
out := make([]Attachment, 0, len(t))
for _, mm := range t {
var att Attachment
if name, ok := mm["name"].(string); ok {
att.Name = name
}
if p, ok := mm["path"].(string); ok {
att.Path = p
if att.Name == "" {
att.Name = fileBaseName(p)
}
}
if d, ok := mm["data"]; ok {
if bs, ok := d.([]byte); ok {
att.Data = bs
}
}
out = append(out, att)
}
return out, nil
case []any:
return toAttachments(mapsFromAny(t))
default:
return nil, fmt.Errorf("mailx: invalid attachments type %T", v)
}
}
// mapsFromAny 将 []any 转为 []map[string]any(忽略非 map 元素)
func mapsFromAny(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, item := range arr {
if mm, ok := item.(map[string]any); ok {
out = append(out, mm)
}
}
return out
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+1 -21
View File
@@ -11,8 +11,8 @@ require (
github.com/alibabacloud-go/tea v1.2.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
github.com/aws/aws-sdk-go v1.55.5
github.com/joho/godotenv v1.5.1
github.com/mailgun/mailgun-go/v4 v4.18.5
github.com/yuninks/loggerx v1.0.12
)
require (
@@ -24,36 +24,16 @@ require (
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/aliyun/credentials-go v1.3.10 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/clbanning/mxj/v2 v2.5.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.9.1 // indirect
github.com/go-chi/chi/v5 v5.0.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailgun/errors v0.3.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+2 -63
View File
@@ -58,13 +58,7 @@ github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsVi
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E=
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -75,24 +69,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -104,13 +82,10 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@@ -118,24 +93,18 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mailgun/errors v0.3.0 h1:g8R8lodkwqk5WIVMAClyUqt0PSd5JTVgobB+H7C2sLs=
github.com/mailgun/errors v0.3.0/go.mod h1:+ltknP+jhv3gZ1StKY6ugoQECcPxDCaSdmYesqTZcLQ=
github.com/mailgun/mailgun-go/v4 v4.18.5 h1:wZnTutW/fzxNyJVBMa6O4LLGW/hO+IGfKpuqBT4nbVs=
github.com/mailgun/mailgun-go/v4 v4.18.5/go.mod h1:+d4FCswFAukgYc1XtKK2IxOYaVxjVm8AN2z/5TBiT8M=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -143,10 +112,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -157,33 +123,17 @@ github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYl
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuninks/loggerx v1.0.12 h1:5XxhSo5bQZEjLRdJ9FPmj+uS018s1meo/9OhG9y2hUg=
github.com/yuninks/loggerx v1.0.12/go.mod h1:+QFoywQ1ICh4v40zj6OHM8GBZHEqV0yvRbkZjZUe2o4=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -196,8 +146,6 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -244,11 +192,9 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -276,7 +222,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -290,7 +235,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -306,11 +250,7 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
@@ -323,4 +263,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+34 -60
View File
@@ -2,76 +2,50 @@ package mailx
import (
"bytes"
"fmt"
"github.com/PuerkitoBio/goquery"
)
// html路径替换
func ParseHtmlResource(html string) ([]string, error) {
resp := []string{}
b := bytes.NewBufferString(html)
doc, err := goquery.NewDocumentFromReader(b)
// ParseHTMLResource 解析 HTML 中引用的静态资源地址(css/js/img/video/audio)。
// 返回资源 URL 列表;忽略空属性与 dns-prefetch 预请求。
func ParseHTMLResource(html string) ([]string, error) {
doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(html))
if err != nil {
return nil, err
return nil, fmt.Errorf("mailx: parse html: %w", err)
}
// 找到所有的 css 标签,并且打印它们的 href 属性
doc.Find("link").Each(func(i int, s *goquery.Selection) {
// 忽略dns预请求
r, ok := s.Attr("rel")
if ok && r == "dns-prefetch" {
var res []string
collect := func(sel, attr string) {
doc.Find(sel).Each(func(_ int, s *goquery.Selection) {
if v, ok := s.Attr(attr); ok && v != "" {
res = append(res, v)
}
})
}
// link:跳过 dns-prefetch
doc.Find("link").Each(func(_ int, s *goquery.Selection) {
if rel, ok := s.Attr("rel"); ok && rel == "dns-prefetch" {
return
}
href, ok := s.Attr("href")
if ok && href != "" {
resp = append(resp, href)
if href, ok := s.Attr("href"); ok && href != "" {
res = append(res, href)
}
})
collect("script", "src")
collect("img", "src")
collect("img", "data-src")
collect("video", "src")
collect("video", "data-src")
collect("audio", "src")
// 找到所有的 script 标签,并且打印它们的 src 属性
doc.Find("script").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
})
// 找到所有的 img 标签,并且打印它们的 src 属性
doc.Find("img").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
data_src, ok := s.Attr("data-src")
if ok && data_src != "" {
resp = append(resp, data_src)
}
})
// 找到所有的 video 标签,并且打印它们的 src 属性
doc.Find("video").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
data_src, ok := s.Attr("data-src")
if ok && data_src != "" {
resp = append(resp, data_src)
}
})
// 找到所有的 audio 标签,并且打印它们的 src 属性
doc.Find("audio").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
})
return resp, nil
return res, nil
}
// ParseHtmlResource 是 ParseHTMLResource 的旧名称,保留以兼容历史调用,建议使用新名。
//
// Deprecated: 请使用 ParseHTMLResource。
func ParseHtmlResource(html string) ([]string, error) {
return ParseHTMLResource(html)
}
+71
View File
@@ -0,0 +1,71 @@
package mailx_test
import (
"reflect"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
func TestParseHtmlResourceAll(t *testing.T) {
html := `<html>
<head>
<link rel="dns-prefetch" href="//dns.example.com">
<link rel="stylesheet" href="./css/a.css">
<script src="./js/app.js"></script>
</head>
<body>
<img src="./img/a.png" data-src="./img/b.png">
<video src="./v/a.mp4" data-src="./v/b.mp4"></video>
<audio src="./au/a.mp3"></audio>
</body>
</html>`
got, err := mailx.ParseHTMLResource(html)
if err != nil {
t.Fatal(err)
}
want := []string{
"./css/a.css",
"./js/app.js",
"./img/a.png",
"./img/b.png",
"./v/a.mp4",
"./v/b.mp4",
"./au/a.mp3",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestParseHtmlResourceNoResources(t *testing.T) {
got, err := mailx.ParseHtmlResource("<html><body>plain</body></html>")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("got %v, want empty", got)
}
}
func TestParseHtmlResourceInvalidInput(t *testing.T) {
// 非法 HTML 输入不应返回错误(goquery 容错解析)
if _, err := mailx.ParseHtmlResource("not html at all <<<"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseHtmlResourceEmptyHrefIgnored(t *testing.T) {
got, err := mailx.ParseHtmlResource(`<html>
<link rel="stylesheet" href="">
<script src=""></script>
<img src="">
</html>`)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("got %v, want empty", got)
}
}
+3 -2
View File
@@ -2,7 +2,8 @@ package mailx_test
import (
"testing"
"wallet-pay-api/pkg/mailx"
mailx "code.yun.ink/pkg/mailx"
)
func TestParseHtmlResource(t *testing.T) {
@@ -49,7 +50,7 @@ func TestParseHtmlResource(t *testing.T) {
</html>
`
res, err := mailx.ParseHtmlResource(html)
res, err := mailx.ParseHTMLResource(html)
if err != nil {
t.Fatal(err)
}
-76
View File
@@ -1,76 +0,0 @@
package interfaces
type EmailType string
const (
EmailTypeAliyun EmailType = "aliyun"
EmailTypeAws EmailType = "aws"
EmailTypeMailgun EmailType = "mailgun"
EmailTypeSmtp EmailType = "smtp"
)
type EmialConfigDataMailgun struct {
ApiKey string `json:"api_key"` // mailgun api key
Domain string `json:"domain"` // mailgun domain
Sender string `json:"sender"` // 发件人
}
type EmailConfigDataSmtp struct {
Username string `json:"username"` // 邮箱账号
Password string `json:"password"` // 授权码
Host string `json:"host"` // SMTP 服务器【默认smtpdm.aliyun.com】
Port string `json:"port"` // 发信端口
ReplyTo string `json:"reply_to"` // 【选填】回复地址
// From string `json:"from"` // 【选填】阿里云邮箱发件人
}
type EmailConfigDataAws struct {
AccessId string `json:"access_id"` // 亚马逊AccessId
AccessSecret string `json:"access_secret"` // 亚马逊AccessSecret
Region string `json:"region"` // 亚马逊Region
Sender string `json:"sender"` // 亚马逊发件人
}
type EmialConfigDataAliyun struct {
AccessId string `json:"access_id"` // 阿里云AccessId
AccessKey string `json:"access_key"` // 阿里云AccessKey
Endpoint string `json:"endpoint"` // 区域 默认dm.aliyuncs.com
AccountName string `json:"account_name"` // 账号名
ReplyAddress string `json:"reply_address"` // 邮件回复地址
}
type Message struct {
Form string
To []string
Cc []string
Bcc []string
Subject string
Body string
ReplyTo string
Attachment []MessageAttachment // 附件
}
type MessageAttachment struct {
Content string
ContentType string
}
type EmailSendRecord struct {
AccountName string // 发件人
UpdateTime int64 // 毫秒时间戳
Status EmailSendStatus // 状态
ToUser string // 收件人
Subject string // 邮件主题
ErrorMessage string // 错误信息
}
type EmailSendStatus int
const (
EmailSendStatusUnknown EmailSendStatus = 0
EmailSendStatusSuccess EmailSendStatus = 1
EmailSendStatusInvalidAddress EmailSendStatus = 2
EmailSendStatusSpam EmailSendStatus = 3
EmailSendStatusFailed EmailSendStatus = 4
)
-85
View File
@@ -1,85 +0,0 @@
package interfaces
import (
"context"
"errors"
)
type EmailInterface interface {
SetOption(ctx context.Context, opt ...Option) (EmailInterface, error) // 初始化
GetEmailType() EmailType
// Send 发送邮件
Send(ctx context.Context, params Message) error
}
type EmailFactoryInterface interface {
Register(EmailInterface) // 注册一个接口
SetOption(opt ...Option) // 针对已注册的进行初始化
GetEmail(EmailType) (EmailInterface, error)
UnRegister(EmailType)
}
type DefaultEmail struct {
Options emailOption
EmailType EmailType
}
func NewDefaultEmail() *DefaultEmail {
return &DefaultEmail{
Options: DefaultOptions(),
EmailType: "DefaultEmail",
}
}
func (l *DefaultEmail) SetOption(ctx context.Context, opt ...Option) (EmailInterface, error) {
// 深复制l并且返回新的
newL := *l
for _, o := range opt {
o(&newL.Options)
}
return &newL, nil
}
func (l *DefaultEmail) GetEmailType() EmailType {
return l.EmailType
}
func (l *DefaultEmail) Send(ctx context.Context, params Message) error {
return errors.New("not implemented")
}
type DefaultEmailFactory struct {
Emails map[EmailType]EmailInterface
}
func NewDefaultEmailFactory() *DefaultEmailFactory {
return &DefaultEmailFactory{
Emails: make(map[EmailType]EmailInterface),
}
}
func (l *DefaultEmailFactory) Register(email EmailInterface) {
l.Emails[email.GetEmailType()] = email
}
func (l *DefaultEmailFactory) SetOption(opt ...Option) {
for _, email := range l.Emails {
email.SetOption(context.Background(), opt...)
}
}
func (l *DefaultEmailFactory) GetEmail(emailType EmailType) (EmailInterface, error) {
email, ok := l.Emails[emailType]
if !ok {
return nil, errors.New("not implemented")
}
return email, nil
}
func (l *DefaultEmailFactory) UnRegister(emailType EmailType) {
delete(l.Emails, emailType)
}
-56
View File
@@ -1,56 +0,0 @@
package interfaces
import (
"context"
"github.com/yuninks/loggerx"
)
type emailOption struct {
Logger loggerx.LoggerInterface
Smtp *EmailConfigDataSmtp `json:"smtp,omitempty"` // smtp
Aws *EmailConfigDataAws `json:"aws,omitempty"` // 亚马逊
Aliyun *EmialConfigDataAliyun `json:"aliyun,omitempty"` // 阿里云
Mailgun *EmialConfigDataMailgun `json:"mailgun,omitempty"` // mailgun
}
func DefaultOptions() emailOption {
ctx := context.Background()
return emailOption{
Logger: loggerx.NewLogger(ctx),
}
}
type Option func(*emailOption)
// 设置日志
func SetLogger(logger loggerx.LoggerInterface) Option {
return func(o *emailOption) {
o.Logger = logger
}
}
func SetSmtp(smtp *EmailConfigDataSmtp) Option {
return func(o *emailOption) {
o.Smtp = smtp
}
}
func SetAws(aws *EmailConfigDataAws) Option {
return func(o *emailOption) {
o.Aws = aws
}
}
func SetAliyun(aliyun *EmialConfigDataAliyun) Option {
return func(o *emailOption) {
o.Aliyun = aliyun
}
}
func SetMailgun(mailgun *EmialConfigDataMailgun) Option {
return func(o *emailOption) {
o.Mailgun = mailgun
}
}
+20
View File
@@ -0,0 +1,20 @@
package mailx
import "testing"
func TestFileBaseName(t *testing.T) {
cases := []struct{ in, want string }{
{"dir/file.txt", "file.txt"},
{`dir\file.txt`, "file.txt"},
{"/a/b/c.txt", "c.txt"},
{"file.txt", "file.txt"},
{"", "attachment"},
{"/", "attachment"},
{".", "attachment"},
}
for _, c := range cases {
if got := fileBaseName(c.in); got != c.want {
t.Errorf("fileBaseName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package mailx
import (
"context"
"log"
)
// Logger 日志接口,可实现或适配任意日志库(如 zap、logrus、slog)。
// 通过 Manager.SetLogger 或 context 注入(WithLogger)后,
// 各通道在发送成功/失败时会自动记录。
type Logger interface {
Debugf(ctx context.Context, format string, args ...any)
Infof(ctx context.Context, format string, args ...any)
Warnf(ctx context.Context, format string, args ...any)
Errorf(ctx context.Context, format string, args ...any)
}
// noopLogger 空实现,作为未注入日志器时的安全默认值
type noopLogger struct{}
func (noopLogger) Debugf(context.Context, string, ...any) {}
func (noopLogger) Infof(context.Context, string, ...any) {}
func (noopLogger) Warnf(context.Context, string, ...any) {}
func (noopLogger) Errorf(context.Context, string, ...any) {}
// stdLogger 基于标准库 log 实现的 Logger
type stdLogger struct{}
func (stdLogger) Debugf(_ context.Context, format string, args ...any) {
log.Printf("[debug] "+format, args...)
}
func (stdLogger) Infof(_ context.Context, format string, args ...any) {
log.Printf("[info] "+format, args...)
}
func (stdLogger) Warnf(_ context.Context, format string, args ...any) {
log.Printf("[warn] "+format, args...)
}
func (stdLogger) Errorf(_ context.Context, format string, args ...any) {
log.Printf("[error] "+format, args...)
}
// StdLogger 返回一个基于标准库 log 的 Logger 实现。
// 若希望日志带行号/级别前缀,或接入 zap/logrus/slog 等,请自定义实现 Logger 接口。
func StdLogger() Logger {
return stdLogger{}
}
// noop 返回一个空实现的 Logger,用于未注入时的默认值。
func noop() Logger {
return noopLogger{}
}
// ctxKey 用于在 context 中存取 Logger 的私有键
type ctxKey struct{}
// WithLogger 将日志器注入 context,通道发送时自动读取。
func WithLogger(ctx context.Context, l Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
// LoggerFromContext 从 context 读取日志器;未注入时返回空实现(不输出任何日志)。
func LoggerFromContext(ctx context.Context) Logger {
if l, ok := ctx.Value(ctxKey{}).(Logger); ok && l != nil {
return l
}
return noop()
}
+44
View File
@@ -0,0 +1,44 @@
package mailx_test
import (
"context"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
// recordingLogger 记录日志调用的最小实现
type recordingLogger struct{}
func (recordingLogger) Debugf(context.Context, string, ...any) {}
func (recordingLogger) Infof(context.Context, string, ...any) {}
func (recordingLogger) Warnf(context.Context, string, ...any) {}
func (recordingLogger) Errorf(context.Context, string, ...any) {}
var _ mailx.Logger = recordingLogger{}
func TestLoggerRoundTrip(t *testing.T) {
ctx := mailx.WithLogger(context.Background(), recordingLogger{})
l := mailx.LoggerFromContext(ctx)
if _, ok := l.(recordingLogger); !ok {
t.Fatalf("logger type = %T, want recordingLogger", l)
}
}
func TestLoggerDefaultNotNil(t *testing.T) {
l := mailx.LoggerFromContext(context.Background())
if l == nil {
t.Fatal("default logger should not be nil")
}
// 不应 panic
l.Debugf(context.Background(), "noop")
l.Infof(context.Background(), "noop")
l.Warnf(context.Background(), "noop")
l.Errorf(context.Background(), "noop")
}
func TestSetLoggerNilIgnored(t *testing.T) {
m := mailx.NewManager()
m.SetLogger(nil) // 不应 panic
m.SetLogger(recordingLogger{})
}
+140 -36
View File
@@ -1,58 +1,162 @@
// Package mailgun 提供 Mailgun 邮件发送通道。
package mailgun
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"sync"
"time"
"code.yun.ink/pkg/mailx/interfaces"
mailx "code.yun.ink/pkg/mailx"
"github.com/mailgun/mailgun-go/v4"
)
// defaultTimeout 单次 API 调用的默认超时
const defaultTimeout = 30 * time.Second
// Config Mailgun 通道配置
type Config struct {
APIKey string // Mailgun API Key
Domain string // Mailgun Domain
Sender string // 默认发件人(可选,Message.From 优先)
Timeout time.Duration // 单次 API 调用的超时,默认 30s;0 表示交给 ctx 控制
}
// mgClient Mailgun 客户端的最小接口(便于测试注入 mock)
type mgClient interface {
NewMessage(from, subject, text string, to ...string) *mailgun.Message
Send(ctx context.Context, m *mailgun.Message) (string, string, error)
}
// MailGun Mailgun 发送通道
type MailGun struct {
interfaces.DefaultEmail
// params *interfaces.EmialConfigDataMailgun
mg *mailgun.MailgunImpl
// logger loggerx.LoggerInterface
cfg Config
initOnce sync.Once
client mgClient
}
func NewMailGun() *MailGun {
mailgun := &MailGun{}
mailgun.Options = interfaces.DefaultOptions()
mailgun.EmailType = interfaces.EmailTypeMailgun
return mailgun
// New 创建 Mailgun 通道
func New(cfg Config) *MailGun {
return &MailGun{cfg: cfg}
}
func (l *MailGun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
// Name 返回通道名称
func (g *MailGun) Name() string { return "mailgun" }
for _, o := range opt {
o(&l.Options)
// Send 发送一封邮件
func (g *MailGun) Send(ctx context.Context, msg *mailx.Message) error {
logger := mailx.LoggerFromContext(ctx)
sender := msg.From
if sender == "" {
sender = g.cfg.Sender
}
if sender == "" {
return fmt.Errorf("%w: mailgun sender is required", mailx.ErrInvalidConfig)
}
if g.cfg.Domain == "" || g.cfg.APIKey == "" {
return fmt.Errorf("%w: mailgun domain and api key are required", mailx.ErrInvalidConfig)
}
if l.Options.Mailgun == nil {
return nil, errors.New("not mailgun")
mg := g.getClient()
text := msg.TextBody
if text == "" && !mailx.IsHTML(msg.Body) {
text = msg.Body
}
m := mg.NewMessage(sender, msg.Subject, text, msg.To...)
if msg.Body != "" {
m.SetHtml(msg.Body)
}
if msg.ReplyTo != "" {
m.SetReplyTo(msg.ReplyTo)
}
for _, cc := range msg.Cc {
m.AddCC(cc)
}
for _, bcc := range msg.Bcc {
m.AddBCC(bcc)
}
for _, att := range msg.Attachments {
if len(att.Data) > 0 {
name := att.Name
if name == "" {
name = "attachment"
}
m.AddBufferAttachment(name, att.Data)
continue
}
if att.Path != "" {
if _, err := os.Stat(att.Path); err != nil {
return fmt.Errorf("mailx/mailgun: attachment %q: %w", att.Path, err)
}
m.AddAttachment(att.Path)
continue
}
return fmt.Errorf("mailx/mailgun: attachment has neither path nor data (name=%q)", att.Name)
}
// 内嵌图片(HTML 中用 <img src="cid:...">mailgun 以 filename 作为 CID
for _, inl := range msg.Inline {
if len(inl.Data) > 0 {
m.AddReaderInline(inl.CID, nopCloser{bytes.NewReader(inl.Data)})
continue
}
if inl.Path != "" {
if _, err := os.Stat(inl.Path); err != nil {
return fmt.Errorf("mailx/mailgun: inline %q: %w", inl.Path, err)
}
// AddInline 以文件路径为参数,其 CID 由文件名推导;
// 因此这里将 CID 写入同名的临时文件不可行,改用 ReaderInline 读取路径
f, err := os.Open(inl.Path)
if err != nil {
return fmt.Errorf("mailx/mailgun: open inline %q: %w", inl.Path, err)
}
m.AddReaderInline(inl.CID, f)
continue
}
return fmt.Errorf("mailx/mailgun: inline image has neither path nor data (cid=%q)", inl.CID)
}
l.Options.Logger.Infof(ctx, "Mailgun:%+v", l.Options.Mailgun)
mg := mailgun.NewMailgun(l.Options.Mailgun.Domain, l.Options.Mailgun.ApiKey)
l.mg = mg
return l, nil
// 超时兜底:mailgun-go 的 Send 已接受 ctx,这里叠加配置超时
ctx, cancel := g.withTimeout(ctx)
defer cancel()
if _, _, err := mg.Send(ctx, m); err != nil {
logger.Errorf(ctx, "mailx/mailgun: send to %v failed: %v", msg.To, err)
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
}
logger.Infof(ctx, "mailx/mailgun: sent to %v subject=%q", msg.To, msg.Subject)
return nil
}
func (l *MailGun) Send(ctx context.Context, params interfaces.Message) error {
if l.Options.Mailgun == nil {
return errors.New("not init")
// getClient 惰性创建并复用 Mailgun 客户端(线程安全)。
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
func (g *MailGun) getClient() mgClient {
if g.client != nil {
return g.client
}
message := l.mg.NewMessage(l.Options.Mailgun.Sender, params.Subject, params.Body, params.To...)
resp, id, err := l.mg.Send(ctx, message)
if err != nil {
l.Options.Logger.Errorf(ctx, "Could not send email: %v, resp message: %s, id: %s", err, resp, id)
return err
}
return err
g.initOnce.Do(func() {
g.client = mailgun.NewMailgun(g.cfg.Domain, g.cfg.APIKey)
})
return g.client
}
// withTimeout 叠加配置超时到 ctx(已有更早 deadline 时保持不变)
func (g *MailGun) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
if g.cfg.Timeout <= 0 {
return ctx, func() {}
}
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= g.cfg.Timeout {
return ctx, func() {}
}
return context.WithTimeout(ctx, g.cfg.Timeout)
}
// nopCloser 包装 io.Reader 为 io.ReadCloserClose 为空操作)
type nopCloser struct {
io.Reader
}
func (nopCloser) Close() error { return nil }
+28
View File
@@ -0,0 +1,28 @@
package mailgun
import (
"context"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
var _ mailx.Sender = (*MailGun)(nil)
func TestName(t *testing.T) {
if got := New(Config{}).Name(); got != "mailgun" {
t.Errorf("Name() = %q, want mailgun", got)
}
}
func TestSendWithoutSender(t *testing.T) {
// 缺少 Sender 时应立即报错,不调用 mailgun SDK
g := New(Config{})
err := g.Send(context.Background(), mailx.NewMessage().
To("a@b.com").
Subject("s").
Build())
if err == nil {
t.Fatal("Send without sender should error")
}
}
+189
View File
@@ -0,0 +1,189 @@
package mailgun
import (
"context"
"errors"
"testing"
mailx "code.yun.ink/pkg/mailx"
"github.com/mailgun/mailgun-go/v4"
)
// mockMG 实现 mgClient 接口,记录 NewMessage 的参数与 Send 调用
type mockMG struct {
sendErr error
// NewMessage 捕获
from, subject, text string
to []string
// Send 捕获
msgSent bool
sentMsg *mailgun.Message
}
func (m *mockMG) NewMessage(from, subject, text string, to ...string) *mailgun.Message {
m.from, m.subject, m.text = from, subject, text
m.to = to
return mailgun.NewMessage(from, subject, text, to...)
}
func (m *mockMG) Send(_ context.Context, msg *mailgun.Message) (string, string, error) {
m.msgSent = true
m.sentMsg = msg
if m.sendErr != nil {
return "", "", m.sendErr
}
return "mock-id", "queued", nil
}
// TestSendSuccess 验证完整发送流程与消息构造参数
func TestSendSuccess(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "noreply@example.com"}, client: mock}
msg := mailx.NewMessage().
To("a@example.com", "b@example.com").
Cc("cc@example.com").
Bcc("bcc@example.com").
Subject("hi").
Text("plain").
HTML("<b>html</b>").
ReplyTo("reply@example.com").
AttachBytes("a.txt", []byte("data")).
InlineImageBytes("cid1", "a.png", []byte{1}).
Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if !mock.msgSent {
t.Fatal("message not sent")
}
if mock.from != "noreply@example.com" {
t.Errorf("From = %q", mock.from)
}
if mock.subject != "hi" {
t.Errorf("Subject = %q", mock.subject)
}
if mock.text != "plain" {
t.Errorf("Text = %q", mock.text)
}
if len(mock.to) != 2 || mock.to[0] != "a@example.com" || mock.to[1] != "b@example.com" {
t.Errorf("To = %v", mock.to)
}
}
// TestSendFromFallback 验证 msg.From 为空时回退 cfg.Sender
func TestSendFromFallback(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "cfg@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if mock.from != "cfg@example.com" {
t.Errorf("From = %q, want cfg fallback", mock.from)
}
}
// TestSendTextFallback 验证 Body 为纯文本时作为 text 传递
func TestSendTextFallback(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Body("plain body no html").Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if mock.text != "plain body no html" {
t.Errorf("Text = %q, want fallback to Body", mock.text)
}
}
// TestSendHTMLOnly 验证 Body 为 HTML 时不作为 text 传递
func TestSendHTMLOnly(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").HTML("<b>html</b>").Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if mock.text != "" {
t.Errorf("Text = %q, want empty for html body", mock.text)
}
}
// TestSendFail 验证发送失败包装为 ErrSendFailed
func TestSendFail(t *testing.T) {
mock := &mockMG{sendErr: errors.New("mailgun down")}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := g.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
t.Fatalf("err = %v, want ErrSendFailed", err)
}
}
// TestSendNoSender 验证缺少 Sender 报错
func TestSendNoSender(t *testing.T) {
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com"}, client: &mockMG{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := g.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendNoDomain 验证缺少 Domain/APIKey 报错
func TestSendNoDomain(t *testing.T) {
g := &MailGun{cfg: Config{Sender: "s@example.com"}, client: &mockMG{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := g.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendAttachmentErrors 验证附件边界情况
func TestSendAttachmentErrors(t *testing.T) {
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: &mockMG{}}
// 附件既无 path 也无 data
msg := mailx.NewMessage().To("a@example.com").Subject("s").
AttachBytes("x.txt", []byte{1}).
Build()
msg.Attachments[0].Data = nil // 清空 data
if err := g.Send(context.Background(), msg); err == nil {
t.Fatal("attachment without path/data should error")
}
// 附件路径不存在
msg = mailx.NewMessage().To("a@example.com").Subject("s").
Attach("no-such-file.txt").
Build()
if err := g.Send(context.Background(), msg); err == nil {
t.Fatal("missing attachment file should error")
}
// 内嵌图片路径不存在
msg = mailx.NewMessage().To("a@example.com").Subject("s").
InlineImage("cid1", "no-such-img.png").
Build()
if err := g.Send(context.Background(), msg); err == nil {
t.Fatal("missing inline file should error")
}
}
// TestGetClient 验证 getClient 惰性复用
func TestGetClient(t *testing.T) {
g := New(Config{APIKey: "key", Domain: "mg.example.com"})
c1 := g.getClient()
c2 := g.getClient()
if c1 != c2 {
t.Error("client should be reused")
}
}
+13 -16
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"code.yun.ink/pkg/mailx/interfaces"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/mailgun"
)
@@ -15,26 +15,23 @@ var (
)
func TestSendEmail(t *testing.T) {
gun := mailgun.NewMailGun()
ctx := context.Background()
ini, err := gun.SetOption(ctx, interfaces.SetMailgun(&interfaces.EmialConfigDataMailgun{
ApiKey: apikey,
if testing.Short() {
t.Skip("skip real send in short mode")
}
client := mailgun.New(mailgun.Config{
APIKey: apikey,
Domain: domain,
Sender: sender,
}))
if err != nil {
t.Fatal(err)
}
err = ini.Send(ctx, interfaces.Message{
To: []string{"995116474@qq.com"},
Subject: "test mail",
Body: "Hello",
})
ctx := context.Background()
err := client.Send(ctx, mailx.NewMessage().
To("995116474@qq.com").
Subject("test mail").
Body("Hello").
Build())
if err != nil {
t.Fatal(err)
}
t.Log("send success")
}
+28 -28
View File
@@ -1,29 +1,29 @@
// Package mailx 是一个简单易用的多通道邮件发送库。
//
// 核心特性:
// - 多通道支持:smtp / 阿里云 / AWS SES / mailgun 等,可自由扩展
// - 链式消息构建:mailx.NewMessage().From(...).To(...).Subject(...).Build()
// - 通道管理器:注册多个通道,发送时按名称路由或临时指定配置
// - 发送时指定配置:SendWith 选已注册通道,SendBy 直接传入带配置的通道
//
// 快速开始:
//
// ctx := context.Background()
//
// // 方式一:直接使用单个通道
// err := smtp.New(smtp.Config{Host: "...", Port: 465, User: "...", Password: "..."}).
// Send(ctx, mailx.NewMessage().
// From("a@example.com").
// To("b@example.com").
// Subject("hello").
// Body("world").
// Build())
//
// // 方式二:多通道管理器,发送时切换通道
// mgr := mailx.NewManager()
// mgr.Register(smtp.New(...))
// mgr.Register(aliyun.New(...))
// mgr.Send(ctx, msg) // 默认通道
// mgr.SendWith(ctx, "aliyun", msg) // 指定通道
// mgr.SendBy(ctx, mailgun.New(...), msg) // 临时指定配置的通道
package mailx
import (
"code.yun.ink/pkg/mailx/aliyun"
"code.yun.ink/pkg/mailx/aws"
"code.yun.ink/pkg/mailx/interfaces"
"code.yun.ink/pkg/mailx/mailgun"
"code.yun.ink/pkg/mailx/smtp"
)
var Platform interfaces.EmailFactoryInterface
// 注册
func init() {
Platform = interfaces.NewDefaultEmailFactory()
// 阿里
Platform.Register(aliyun.NewAliyun())
// AWS
Platform.Register(aws.NewAws())
// Smtp
Platform.Register(smtp.NewSmtp())
// mailgun
Platform.Register(mailgun.NewMailGun())
}
+218
View File
@@ -0,0 +1,218 @@
package mailx
import (
"context"
"fmt"
"sync"
"time"
)
// SenderInfo 描述一个已注册通道实例的信息
type SenderInfo struct {
// Name 实例名,是管理器中的唯一标识,用于 SendWith/Unregister/SetDefault 路由
Name string
// Type 通道类型(底层 Sender.Name()),如 "smtp" / "aliyun" / "aws" / "mailgun"
Type string
}
// senderEntry 管理器内部存储的通道条目
type senderEntry struct {
instance string // 实例名(管理器内的唯一标识)
sender Sender
}
// Manager 多通道管理器:注册多个发送通道实例,发送时按实例名路由
type Manager struct {
mu sync.RWMutex
senders map[string]senderEntry // key 为实例名
def string
loggerMu sync.RWMutex
logger Logger
}
// NewManager 创建多通道管理器
func NewManager() *Manager {
return &Manager{
senders: make(map[string]senderEntry),
logger: noopLogger{},
}
}
// New 创建多通道管理器(NewManager 的别名,简化调用)
func New() *Manager {
return NewManager()
}
// Register 注册一个发送通道,实例名即通道的 Name()。
// 若需同一类型通道注册多份不同配置,请使用 RegisterNamed。
func (m *Manager) Register(s Sender) error {
if s == nil {
return ErrInvalidConfig
}
return m.RegisterNamed(s.Name(), s)
}
// RegisterNamed 以指定实例名注册一个发送通道。
// 实例名是管理器内的唯一标识;允许同一通道类型(如 smtp)注册多份不同配置,
// 例如 "smtp-main" 与 "smtp-backup"。
func (m *Manager) RegisterNamed(name string, s Sender) error {
if s == nil {
return ErrInvalidConfig
}
if name == "" {
return fmt.Errorf("%w: sender instance name is empty", ErrInvalidConfig)
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.senders[name]; ok {
return fmt.Errorf("%w: %q already registered", ErrInvalidConfig, name)
}
m.senders[name] = senderEntry{instance: name, sender: s}
if m.def == "" {
m.def = name
}
return nil
}
// Unregister 注销指定实例名的通道
func (m *Manager) Unregister(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.senders, name)
if m.def == name {
m.def = ""
for n := range m.senders {
m.def = n
break
}
}
}
// SetDefault 设置默认通道实例
func (m *Manager) SetDefault(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.senders[name]; !ok {
return fmt.Errorf("%w: %q", ErrSenderNotFound, name)
}
m.def = name
return nil
}
// Sender 按实例名获取已注册的通道;name 为空时返回默认实例
func (m *Manager) Sender(name string) (Sender, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if name == "" {
name = m.def
}
if name == "" {
return nil, ErrSenderNotFound
}
e, ok := m.senders[name]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrSenderNotFound, name)
}
return e.sender, nil
}
// Registered 判断指定实例名的通道是否已注册
func (m *Manager) Registered(name string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.senders[name]
return ok
}
// Names 返回所有已注册通道的实例名(顺序不保证稳定)。
// 如需包含通道类型信息,请使用 Senders。
func (m *Manager) Names() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.senders))
for n := range m.senders {
names = append(names, n)
}
return names
}
// Senders 返回所有已注册通道实例的信息列表(含实例名与通道类型)。
func (m *Manager) Senders() []SenderInfo {
m.mu.RLock()
defer m.mu.RUnlock()
infos := make([]SenderInfo, 0, len(m.senders))
for _, e := range m.senders {
infos = append(infos, SenderInfo{Name: e.instance, Type: e.sender.Name()})
}
return infos
}
// Default 返回当前默认通道实例名(无默认时返回空串)
func (m *Manager) Default() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.def
}
// SetLogger 设置管理器全局日志器
func (m *Manager) SetLogger(l Logger) *Manager {
if l != nil {
m.loggerMu.Lock()
m.logger = l
m.loggerMu.Unlock()
}
return m
}
// Logger 返回管理器当前的日志器
func (m *Manager) Logger() Logger {
m.loggerMu.RLock()
defer m.loggerMu.RUnlock()
return m.logger
}
// Send 使用默认通道发送
func (m *Manager) Send(ctx context.Context, msg *Message) error {
return m.SendWith(ctx, "", msg)
}
// SendWith 使用指定实例名的通道发送(发送时指定配置/通道)
func (m *Manager) SendWith(ctx context.Context, name string, msg *Message) error {
s, err := m.Sender(name)
if err != nil {
return err
}
return m.dispatch(ctx, s, msg)
}
// SendBy 使用临时传入的通道发送,无需提前注册(发送时指定配置)
func (m *Manager) SendBy(ctx context.Context, s Sender, msg *Message) error {
if s == nil {
return ErrInvalidConfig
}
return m.dispatch(ctx, s, msg)
}
// dispatch 统一校验消息并注入日志器后发送
func (m *Manager) dispatch(ctx context.Context, s Sender, msg *Message) error {
if msg == nil {
return ErrInvalidMessage
}
if err := msg.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidMessage, err)
}
logger := m.Logger()
start := time.Now()
err := s.Send(WithLogger(ctx, logger), msg)
elapsed := time.Since(start)
if err != nil {
logger.Errorf(ctx, "mailx: send via %q failed in %v: %v", s.Name(), elapsed, err)
return err
}
logger.Infof(ctx, "mailx: sent via %q to %v in %v", s.Name(), msg.To, elapsed)
return nil
}
+486
View File
@@ -0,0 +1,486 @@
package mailx_test
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
func TestNewManagerEmpty(t *testing.T) {
m := mailx.NewManager()
if _, err := m.Sender(""); err == nil {
t.Fatal("Sender() on empty manager should error")
}
if err := m.Send(context.Background(), mailx.NewMessage().To("a@b.com").Subject("s").Build()); err == nil {
t.Fatal("Send() on empty manager should error")
}
}
func TestRegisterSetsDefault(t *testing.T) {
m := mailx.NewManager()
if err := m.Register(newMockSender("a")); err != nil {
t.Fatal(err)
}
if err := m.Register(newMockSender("b")); err != nil {
t.Fatal(err)
}
s, err := m.Sender("")
if err != nil {
t.Fatal(err)
}
if s.Name() != "a" {
t.Errorf("default sender = %q, want a", s.Name())
}
}
func TestRegisterDuplicate(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("a"))
err := m.Register(newMockSender("a"))
if err == nil || !strings.Contains(err.Error(), "already registered") {
t.Fatalf("err = %v, want already registered", err)
}
}
func TestRegisterNil(t *testing.T) {
m := mailx.NewManager()
if err := m.Register(nil); err == nil {
t.Fatal("Register(nil) should error")
}
}
func TestRegisterEmptyName(t *testing.T) {
m := mailx.NewManager()
if err := m.Register(newMockSender("")); err == nil {
t.Fatal("Register(empty name) should error")
}
}
func TestUnregister(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("a"))
_ = m.Register(newMockSender("b"))
m.Unregister("a")
s, err := m.Sender("")
if err != nil {
t.Fatal(err)
}
if s.Name() != "b" {
t.Errorf("fallback default = %q, want b", s.Name())
}
// 注销最后一个后不再有默认通道
m.Unregister("b")
if _, err := m.Sender(""); err == nil {
t.Fatal("expected error after unregistering all senders")
}
}
func TestSetDefault(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("a"))
_ = m.Register(newMockSender("b"))
if err := m.SetDefault("b"); err != nil {
t.Fatal(err)
}
s, _ := m.Sender("")
if s.Name() != "b" {
t.Errorf("default = %q, want b", s.Name())
}
if err := m.SetDefault("nope"); err == nil {
t.Fatal("SetDefault(unknown) should error")
}
}
func TestSenderLookup(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("a"))
if _, err := m.Sender("a"); err != nil {
t.Fatalf("Sender(a) = %v", err)
}
if _, err := m.Sender("nope"); err == nil {
t.Fatal("Sender(unknown) should error")
}
}
func TestSend(t *testing.T) {
m := mailx.NewManager()
mock := newMockSender("mock")
_ = m.Register(mock)
msg := mailx.NewMessage().To("a@b.com").Subject("s").Body("b").Build()
if err := m.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if mock.count() != 1 {
t.Fatalf("send count = %d, want 1", mock.count())
}
}
func TestSendWithRoutesByName(t *testing.T) {
m := mailx.NewManager()
a := newMockSender("a")
b := newMockSender("b")
_ = m.Register(a)
_ = m.Register(b)
msg := mailx.NewMessage().To("x@y.com").Subject("s").Build()
if err := m.SendWith(context.Background(), "b", msg); err != nil {
t.Fatal(err)
}
if a.count() != 0 || b.count() != 1 {
t.Fatalf("a=%d b=%d, want a=0 b=1", a.count(), b.count())
}
if err := m.SendWith(context.Background(), "nope", msg); err == nil {
t.Fatal("SendWith(unknown) should error")
}
}
func TestSendBy(t *testing.T) {
m := mailx.NewManager()
mock := newMockSender("temp")
msg := mailx.NewMessage().To("x@y.com").Subject("s").Build()
if err := m.SendBy(context.Background(), mock, msg); err != nil {
t.Fatal(err)
}
if mock.count() != 1 {
t.Fatalf("count = %d, want 1", mock.count())
}
if err := m.SendBy(context.Background(), nil, msg); err == nil {
t.Fatal("SendBy(nil) should error")
}
}
func TestSendValidation(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("mock"))
if err := m.Send(context.Background(), nil); err == nil {
t.Fatal("Send(nil) should error")
}
if err := m.Send(context.Background(), &mailx.Message{Subject: "s"}); err == nil {
t.Fatal("message without recipients should error")
}
}
func TestSendErrorPropagated(t *testing.T) {
m := mailx.NewManager()
mock := newMockSender("mock")
mock.err = errors.New("boom")
_ = m.Register(mock)
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
err := m.Send(context.Background(), msg)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("err = %v, want containing boom", err)
}
}
func TestSendInjectsLogger(t *testing.T) {
m := mailx.NewManager()
mock := newMockSender("mock")
_ = m.Register(mock)
m.SetLogger(recordingLogger{})
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
ctx := context.Background()
if err := m.Send(ctx, msg); err != nil {
t.Fatal(err)
}
// 发送时 context 应带有管理器注入的 logger(通道可通过 LoggerFromContext 取到)
if _, ok := mock.lastLogger().(recordingLogger); !ok {
t.Fatalf("injected logger type = %T, want recordingLogger", mock.lastLogger())
}
}
func TestQueryMethods(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("a"))
_ = m.Register(newMockSender("b"))
if !m.Registered("a") || !m.Registered("b") {
t.Error("Registered() should be true for registered senders")
}
if m.Registered("nope") {
t.Error("Registered() should be false for unknown sender")
}
names := m.Names()
if len(names) != 2 {
t.Fatalf("Names() = %v, want 2", names)
}
seen := map[string]bool{}
for _, n := range names {
seen[n] = true
}
if !seen["a"] || !seen["b"] {
t.Errorf("Names() missing a/b: %v", names)
}
if m.Default() != "a" {
t.Errorf("Default() = %q, want a", m.Default())
}
_ = m.SetDefault("b")
if m.Default() != "b" {
t.Errorf("Default() = %q, want b", m.Default())
}
}
func TestErrSentinel(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("mock"))
// 空 manager 且未注册任何通道时,查默认通道返回 ErrSenderNotFound
if _, err := mailx.NewManager().Sender(""); !errors.Is(err, mailx.ErrSenderNotFound) {
t.Errorf("Sender() on empty manager = %v, want ErrSenderNotFound", err)
}
// 未注册的名称返回 ErrSenderNotFound
if _, err := m.Sender("nope"); !errors.Is(err, mailx.ErrSenderNotFound) {
t.Errorf("Sender(unknown) = %v, want ErrSenderNotFound", err)
}
// 消息校验失败返回 ErrInvalidMessage
if err := m.Send(context.Background(), &mailx.Message{Subject: "s"}); !errors.Is(err, mailx.ErrInvalidMessage) {
t.Errorf("Send(no recipients) = %v, want ErrInvalidMessage", err)
}
// 非法配置返回 ErrInvalidConfig
if err := m.Register(nil); !errors.Is(err, mailx.ErrInvalidConfig) {
t.Errorf("Register(nil) = %v, want ErrInvalidConfig", err)
}
}
// TestConcurrentRegisterUnregister 验证并发注册/注销不 panic 且不丢数据
func TestConcurrentRegisterUnregister(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("base"))
ctx := context.Background()
const n = 30
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
name := fmt.Sprintf("sender-%d", i)
_ = m.RegisterNamed(name, newMockSender("mock"))
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
_ = m.SendWith(ctx, name, msg)
m.Unregister(name)
}(i)
}
wg.Wait()
if !m.Registered("base") {
t.Error("base sender should still be registered")
}
}
func TestConcurrentSetLogger(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("mock"))
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
ctx := context.Background()
const n = 100
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if i%2 == 0 {
m.SetLogger(recordingLogger{})
} else {
_ = m.Send(ctx, msg) // 内部读取 Logger,验证并发读写不 panic/不竞态
}
}(i)
}
wg.Wait()
// Logger() 应始终返回非 nil 且类型正确
if _, ok := m.Logger().(recordingLogger); !ok {
t.Fatalf("Logger() type = %T, want recordingLogger", m.Logger())
}
}
func TestConcurrentSend(t *testing.T) {
m := mailx.NewManager()
mock := newMockSender("mock")
_ = m.Register(mock)
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
ctx := context.Background()
const n = 50
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := m.Send(ctx, msg); err != nil {
t.Errorf("Send: %v", err)
}
}()
}
wg.Wait()
if mock.count() != n {
t.Fatalf("count = %d, want %d", mock.count(), n)
}
}
// TestRegisterNamedSameType 验证同一通道类型可注册多份不同配置的实例
func TestRegisterNamedSameType(t *testing.T) {
m := mailx.NewManager()
main := newMockSender("smtp")
backup := newMockSender("smtp")
if err := m.RegisterNamed("smtp-main", main); err != nil {
t.Fatal(err)
}
if err := m.RegisterNamed("smtp-backup", backup); err != nil {
t.Fatal(err)
}
// 两个实例都注册成功
if len(m.Names()) != 2 {
t.Fatalf("Names() = %v, want 2 instances", m.Names())
}
// 类型相同但实例不同
infos := m.Senders()
if len(infos) != 2 {
t.Fatalf("Senders() = %v, want 2", infos)
}
for _, info := range infos {
if info.Type != "smtp" {
t.Errorf("SenderInfo.Type = %q, want smtp", info.Type)
}
}
// 按实例名路由发送到具体配置
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
if err := m.SendWith(context.Background(), "smtp-backup", msg); err != nil {
t.Fatal(err)
}
if main.count() != 0 || backup.count() != 1 {
t.Errorf("main=%d backup=%d, want main=0 backup=1", main.count(), backup.count())
}
}
// TestRegisterNamedDuplicateInstance 验证实例名冲突时报错
func TestRegisterNamedDuplicateInstance(t *testing.T) {
m := mailx.NewManager()
_ = m.RegisterNamed("smtp-main", newMockSender("smtp"))
err := m.RegisterNamed("smtp-main", newMockSender("smtp"))
if err == nil || !strings.Contains(err.Error(), "already registered") {
t.Fatalf("err = %v, want already registered", err)
}
}
// TestRegisterNamedEmptyName 验证空实例名时报错
func TestRegisterNamedEmptyName(t *testing.T) {
m := mailx.NewManager()
if err := m.RegisterNamed("", newMockSender("smtp")); !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestRegisterSameTypeDirect 验证 Register 用通道类型名注册时,同类型只允许一个(默认实例)
func TestRegisterSameTypeDirect(t *testing.T) {
m := mailx.NewManager()
_ = m.Register(newMockSender("smtp"))
if err := m.Register(newMockSender("smtp")); err == nil {
t.Fatal("Register(same type) should error")
}
}
// TestSendersInfo 验证 Senders() 返回实例名与类型
func TestSendersInfo(t *testing.T) {
m := mailx.NewManager()
_ = m.RegisterNamed("smtp-main", newMockSender("smtp"))
_ = m.RegisterNamed("aliyun-prod", newMockSender("aliyun"))
infos := m.Senders()
want := map[string]string{"smtp-main": "smtp", "aliyun-prod": "aliyun"}
got := map[string]string{}
for _, i := range infos {
got[i.Name] = i.Type
}
for k, v := range want {
if got[k] != v {
t.Errorf("Senders()[%s] = %q, want %q (all: %+v)", k, got[k], v, infos)
}
}
if len(got) != len(want) {
t.Errorf("Senders() count = %d, want %d", len(got), len(want))
}
}
// TestUnregisterNamedInstance 验证按实例名注销后不再路由
func TestUnregisterNamedInstance(t *testing.T) {
m := mailx.NewManager()
main := newMockSender("smtp")
backup := newMockSender("smtp")
_ = m.RegisterNamed("smtp-main", main)
_ = m.RegisterNamed("smtp-backup", backup)
m.Unregister("smtp-main")
if m.Registered("smtp-main") {
t.Error("smtp-main should be unregistered")
}
if !m.Registered("smtp-backup") {
t.Error("smtp-backup should remain registered")
}
// 注销的实例不可再路由
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
if err := m.SendWith(context.Background(), "smtp-main", msg); !errors.Is(err, mailx.ErrSenderNotFound) {
t.Errorf("SendWith(unregistered) = %v, want ErrSenderNotFound", err)
}
}
// TestUnregisterLastFallback 验证注销默认实例后自动回退到其他实例
func TestUnregisterLastFallback(t *testing.T) {
m := mailx.NewManager()
_ = m.RegisterNamed("a", newMockSender("smtp"))
_ = m.RegisterNamed("b", newMockSender("aliyun"))
if m.Default() != "a" {
t.Fatalf("Default() = %q, want a", m.Default())
}
m.Unregister("a")
if m.Default() != "b" {
t.Errorf("Default() after unregister = %q, want b", m.Default())
}
}
// TestSetDefaultNamedInstance 验证 SetDefault 支持实例名
func TestSetDefaultNamedInstance(t *testing.T) {
m := mailx.NewManager()
_ = m.RegisterNamed("smtp-main", newMockSender("smtp"))
_ = m.RegisterNamed("smtp-backup", newMockSender("smtp"))
if err := m.SetDefault("smtp-backup"); err != nil {
t.Fatal(err)
}
s, _ := m.Sender("")
if s.Name() != "smtp" {
t.Errorf("default sender type = %q, want smtp", s.Name())
}
// 确认默认实例是 backup(发送到 backup
backup, _ := m.Sender("smtp-backup")
if _, ok := backup.(*mockSender); !ok || m.Default() != "smtp-backup" {
t.Errorf("Default() = %q, want smtp-backup", m.Default())
}
}
+218
View File
@@ -0,0 +1,218 @@
package mailx
import "fmt"
// Message 邮件消息
type Message struct {
From string // 发件人(可含显示名,如 "张三" <a@b.com>
To []string // 收件人(可含显示名)
Cc []string // 抄送(可含显示名)
Bcc []string // 密送(可含显示名)
Subject string // 主题
TextBody string // 纯文本正文(可选,推荐设置以便纯文本客户端阅读)
Body string // HTML 正文(可选,若同时设置 TextBody 则邮件含纯文本与 HTML 两个版本)
Headers map[string]string // 自定义邮件头(可选),如 {"List-Unsubscribe": "<...>"}
ReplyTo string // 回复地址
Attachments []Attachment // 附件
Inline []InlineImage // 内嵌图片(HTML 中用 <img src="cid:..."> 引用)
}
// Attachment 附件(普通附件,下载式)
type Attachment struct {
Name string // 附件名称(可选,默认取 Path 的文件名)
Path string // 附件文件路径(Path 与 Data 二选一)
Data []byte // 附件内容(Path 与 Data 二选一)
}
// InlineImage 内嵌图片(HTML 邮件正文中引用的图片,显示在正文内)
type InlineImage struct {
CID string // Content-IDHTML 中用 src="cid:<CID>" 引用
Name string // 图片名称(可选,默认取 Path 的文件名或 "inline"
Path string // 图片文件路径(Path 与 Data 二选一)
Data []byte // 图片内容(Path 与 Data 二选一)
MIMEType string // MIME 类型(可选,如 image/png;留空则由扩展名推断或默认 image/octet-stream
}
// 单封邮件的生产建议上限
const (
MaxRecipients = 50 // 收件人 + 抄送 + 密送的总上限,防止滥用
MaxAttachments = 20 // 附件数量上限
MaxMessageSize = 25 * 1024 * 1024 // 整封邮件(正文 + 附件)的最大字节数,默认 25MB
MaxHeaderCount = 20 // 自定义头数量上限
)
// Validate 校验消息必填项与基本约束
func (m *Message) Validate() error {
if len(m.To) == 0 {
return fmt.Errorf("%w: requires at least one recipient", ErrInvalidMessage)
}
if m.Subject == "" {
return fmt.Errorf("%w: requires a subject", ErrInvalidMessage)
}
if m.From != "" && !IsValidAddress(m.From) {
return fmt.Errorf("%w: invalid From address %q", ErrInvalidMessage, m.From)
}
for _, addr := range m.allRecipients() {
if !IsValidAddress(addr) {
return fmt.Errorf("%w: invalid recipient address %q", ErrInvalidMessage, addr)
}
}
total := len(m.To) + len(m.Cc) + len(m.Bcc)
if total > MaxRecipients {
return fmt.Errorf("%w: too many recipients (%d > %d)", ErrInvalidMessage, total, MaxRecipients)
}
if len(m.Attachments) > MaxAttachments {
return fmt.Errorf("%w: too many attachments (%d > %d)", ErrInvalidMessage, len(m.Attachments), MaxAttachments)
}
for _, inl := range m.Inline {
if inl.CID == "" {
return fmt.Errorf("%w: inline image requires a CID", ErrInvalidMessage)
}
}
if len(m.Headers) > MaxHeaderCount {
return fmt.Errorf("%w: too many custom headers (%d > %d)", ErrInvalidMessage, len(m.Headers), MaxHeaderCount)
}
if size := m.size(); size > MaxMessageSize {
return fmt.Errorf("%w: message too large (%d > %d bytes)", ErrInvalidMessage, size, MaxMessageSize)
}
return nil
}
// size 估算整封邮件的字节数(正文 + 附件 + 内嵌图片)
func (m *Message) size() int {
n := len(m.Body) + len(m.TextBody)
for _, att := range m.Attachments {
n += len(att.Data)
}
for _, inl := range m.Inline {
n += len(inl.Data)
}
return n
}
// allRecipients 汇总所有接收方地址
func (m *Message) allRecipients() []string {
out := make([]string, 0, len(m.To)+len(m.Cc)+len(m.Bcc))
out = append(out, m.To...)
out = append(out, m.Cc...)
out = append(out, m.Bcc...)
return out
}
// MessageBuilder 消息构建器,支持链式调用
type MessageBuilder struct {
msg *Message
}
// NewMessage 创建消息构建器
func NewMessage() *MessageBuilder {
return &MessageBuilder{msg: &Message{}}
}
// From 设置发件人(可含显示名,如 "张三" <a@b.com>
func (b *MessageBuilder) From(from string) *MessageBuilder {
b.msg.From = from
return b
}
// To 添加收件人
func (b *MessageBuilder) To(to ...string) *MessageBuilder {
b.msg.To = append(b.msg.To, to...)
return b
}
// Cc 添加抄送
func (b *MessageBuilder) Cc(cc ...string) *MessageBuilder {
b.msg.Cc = append(b.msg.Cc, cc...)
return b
}
// Bcc 添加密送
func (b *MessageBuilder) Bcc(bcc ...string) *MessageBuilder {
b.msg.Bcc = append(b.msg.Bcc, bcc...)
return b
}
// Subject 设置主题
func (b *MessageBuilder) Subject(subject string) *MessageBuilder {
b.msg.Subject = subject
return b
}
// Body 设置 HTML 正文(等价于 HTML)。
// 如需同时提供纯文本版本给不支持 HTML 的客户端,请再调用 Text。
func (b *MessageBuilder) Body(body string) *MessageBuilder {
b.msg.Body = body
return b
}
// Text 设置纯文本正文(可选,便于纯文本邮件客户端阅读)
func (b *MessageBuilder) Text(text string) *MessageBuilder {
b.msg.TextBody = text
return b
}
// HTML 设置正文(HTML
func (b *MessageBuilder) HTML(html string) *MessageBuilder {
b.msg.Body = html
return b
}
// ReplyTo 设置回复地址
func (b *MessageBuilder) ReplyTo(replyTo string) *MessageBuilder {
b.msg.ReplyTo = replyTo
return b
}
// Header 设置一个自定义邮件头(如 "List-Unsubscribe"、"X-Mailer")。
// 与标准头(From/To/Subject 等)重名时,自定义值不会覆盖标准头。
func (b *MessageBuilder) Header(key, value string) *MessageBuilder {
if b.msg.Headers == nil {
b.msg.Headers = make(map[string]string)
}
b.msg.Headers[key] = value
return b
}
// Attach 添加文件附件(按路径)
func (b *MessageBuilder) Attach(path string) *MessageBuilder {
b.msg.Attachments = append(b.msg.Attachments, Attachment{
Name: fileBaseName(path),
Path: path,
})
return b
}
// AttachBytes 添加内存附件(按字节内容)
func (b *MessageBuilder) AttachBytes(name string, data []byte) *MessageBuilder {
b.msg.Attachments = append(b.msg.Attachments, Attachment{
Name: name,
Data: data,
})
return b
}
// InlineImage 添加内嵌图片(按路径),HTML 中用 <img src="cid:<cid>">
func (b *MessageBuilder) InlineImage(cid, path string) *MessageBuilder {
b.msg.Inline = append(b.msg.Inline, InlineImage{
CID: cid,
Name: fileBaseName(path),
Path: path,
})
return b
}
// InlineImageBytes 添加内存内嵌图片,HTML 中用 <img src="cid:<cid>">
func (b *MessageBuilder) InlineImageBytes(cid, name string, data []byte) *MessageBuilder {
b.msg.Inline = append(b.msg.Inline, InlineImage{
CID: cid,
Name: name,
Data: data,
})
return b
}
// Build 生成消息
func (b *MessageBuilder) Build() *Message {
return b.msg
}
+117
View File
@@ -0,0 +1,117 @@
package mailx_test
import (
"fmt"
"strings"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
// manyValidAddresses 生成 n 个合法地址
func manyValidAddresses(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = fmt.Sprintf("user%d@example.com", i)
}
return out
}
// manyHeaders 生成 n 个自定义头
func manyHeaders(n int) map[string]string {
out := make(map[string]string, n)
for i := 0; i < n; i++ {
out[fmt.Sprintf("X-Test-%d", i)] = "v"
}
return out
}
func TestMessageBuilder(t *testing.T) {
msg := mailx.NewMessage().
From("sender@example.com").
To("a@example.com", "b@example.com").
Cc("cc@example.com").
Bcc("bcc@example.com").
Subject("subject").
Body("body").
ReplyTo("reply@example.com").
Attach("dir/file.txt").
AttachBytes("data.txt", []byte("hello")).
Build()
if msg.From != "sender@example.com" {
t.Errorf("From = %q", msg.From)
}
if len(msg.To) != 2 || msg.To[0] != "a@example.com" || msg.To[1] != "b@example.com" {
t.Errorf("To = %v", msg.To)
}
if len(msg.Cc) != 1 || msg.Cc[0] != "cc@example.com" {
t.Errorf("Cc = %v", msg.Cc)
}
if len(msg.Bcc) != 1 || msg.Bcc[0] != "bcc@example.com" {
t.Errorf("Bcc = %v", msg.Bcc)
}
if msg.Subject != "subject" || msg.Body != "body" || msg.ReplyTo != "reply@example.com" {
t.Errorf("fields mismatch: %+v", msg)
}
if len(msg.Attachments) != 2 {
t.Fatalf("Attachments len = %d, want 2", len(msg.Attachments))
}
if msg.Attachments[0].Name != "file.txt" || msg.Attachments[0].Path != "dir/file.txt" {
t.Errorf("attachment[0] = %+v", msg.Attachments[0])
}
if msg.Attachments[1].Name != "data.txt" || string(msg.Attachments[1].Data) != "hello" {
t.Errorf("attachment[1] = %+v", msg.Attachments[1])
}
}
func TestMessageBuilderHTML(t *testing.T) {
msg := mailx.NewMessage().HTML("<h1>hi</h1>").Build()
if msg.Body != "<h1>hi</h1>" {
t.Errorf("HTML() did not set Body, got %q", msg.Body)
}
}
func TestMessageBuilderAppendTo(t *testing.T) {
b := mailx.NewMessage().To("a@example.com")
b.To("b@example.com") // 追加而非覆盖
msg := b.Build()
if len(msg.To) != 2 {
t.Fatalf("To = %v, want 2 recipients", msg.To)
}
}
func TestMessageValidate(t *testing.T) {
cases := []struct {
name string
msg *mailx.Message
wantErr string
}{
{"valid", &mailx.Message{To: []string{"a@b.com"}, Subject: "s"}, ""},
{"no recipients", &mailx.Message{Subject: "s"}, "recipient"},
{"no subject", &mailx.Message{To: []string{"a@b.com"}}, "subject"},
{"empty", &mailx.Message{}, "recipient"},
{"too many recipients", &mailx.Message{To: manyValidAddresses(mailx.MaxRecipients + 1), Subject: "s"}, "too many recipients"},
{"invalid recipient", &mailx.Message{To: []string{"not-an-email"}, Subject: "s"}, "invalid recipient"},
{"too many attachments", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Attachments: make([]mailx.Attachment, mailx.MaxAttachments+1)}, "too many attachments"},
{"too many headers", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Headers: manyHeaders(mailx.MaxHeaderCount + 1)}, "too many custom headers"},
{"message too large", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Body: strings.Repeat("x", mailx.MaxMessageSize+1)}, "message too large"},
{"inline without cid", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Inline: []mailx.InlineImage{{Data: []byte{1}}}}, "requires a CID"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.msg.Validate()
if tc.wantErr == "" {
if err != nil {
t.Fatalf("Validate() = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("Validate() = %v, want containing %q", err, tc.wantErr)
}
})
}
}
+274
View File
@@ -0,0 +1,274 @@
package mailx_test
import (
"encoding/json"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
func TestAddressHelpers(t *testing.T) {
// 纯地址
if !mailx.IsValidAddress("a@example.com") {
t.Error("IsValidAddress(a@example.com) = false")
}
// 显示名
if !mailx.IsValidAddress(`"张三" <a@example.com>`) {
t.Error("IsValidAddress(with display name) = false")
}
// 非法
if mailx.IsValidAddress("not-an-email") {
t.Error("IsValidAddress(not-an-email) = true")
}
if mailx.IsValidAddress("") {
t.Error("IsValidAddress(empty) = true")
}
// ExtractEmail 提取纯地址
addr, err := mailx.ExtractEmail(`"张三" <a@example.com>`)
if err != nil {
t.Fatal(err)
}
if addr != "a@example.com" {
t.Errorf("ExtractEmail = %q, want a@example.com", addr)
}
// ExtractEmail 纯地址原样返回
addr, err = mailx.ExtractEmail("a@example.com")
if err != nil || addr != "a@example.com" {
t.Errorf("ExtractEmail(plain) = %q, %v", addr, err)
}
}
func TestAddressList(t *testing.T) {
addrs, err := mailx.AddressList(`"A" <a@e.com>, b@e.com; c@e.com`)
if err != nil {
t.Fatal(err)
}
if len(addrs) != 3 {
t.Fatalf("AddressList len = %d, want 3", len(addrs))
}
if addrs[0].Address != "a@e.com" || addrs[1].Address != "b@e.com" || addrs[2].Address != "c@e.com" {
t.Errorf("unexpected addresses: %+v", addrs)
}
}
func TestFromBytes(t *testing.T) {
m := mailx.NewMessage().
From("a@e.com").
To("b@e.com").
Subject("s").
Text("t").
HTML("<p>h</p>").
Build()
data, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
got, err := mailx.FromBytes(data)
if err != nil {
t.Fatal(err)
}
if got.From != "a@e.com" || got.Subject != "s" || got.Body != "<p>h</p>" {
t.Errorf("FromBytes mismatch: %+v", got)
}
}
func TestFromBytesInvalid(t *testing.T) {
if _, err := mailx.FromBytes([]byte("not json")); err == nil {
t.Fatal("FromBytes(invalid) should error")
}
}
func TestFromMap(t *testing.T) {
m := map[string]any{
"from": "a@e.com",
"to": "b@e.com, c@e.com",
"cc": []string{"d@e.com"},
"subject": "hello",
"text": "plain",
"html": "<b>hi</b>",
"replyto": "r@e.com",
}
msg, err := mailx.FromMap(m)
if err != nil {
t.Fatal(err)
}
if msg.From != "a@e.com" || msg.Subject != "hello" {
t.Errorf("FromMap fields: %+v", msg)
}
if len(msg.To) != 2 || msg.To[0] != "b@e.com" || msg.To[1] != "c@e.com" {
t.Errorf("FromMap To: %v", msg.To)
}
if len(msg.Cc) != 1 || msg.Cc[0] != "d@e.com" {
t.Errorf("FromMap Cc: %v", msg.Cc)
}
if msg.TextBody != "plain" || msg.Body != "<b>hi</b>" {
t.Errorf("FromMap bodies: text=%q html=%q", msg.TextBody, msg.Body)
}
if msg.ReplyTo != "r@e.com" {
t.Errorf("FromMap ReplyTo: %q", msg.ReplyTo)
}
}
func TestFromMapNil(t *testing.T) {
if _, err := mailx.FromMap(nil); err == nil {
t.Fatal("FromMap(nil) should error")
}
}
func TestFromMapAttachments(t *testing.T) {
m := map[string]any{
"to": "a@e.com",
"attachments": []any{
map[string]any{"name": "x.txt", "path": "tmp/x.txt"},
},
}
msg, err := mailx.FromMap(m)
if err != nil {
t.Fatal(err)
}
if len(msg.Attachments) != 1 || msg.Attachments[0].Name != "x.txt" {
t.Errorf("FromMap attachments: %+v", msg.Attachments)
}
}
// TestFromMapAttachmentNoName 验证附件 map 缺 name 键时不 panic,且 name 从 path 推导
func TestFromMapAttachmentNoName(t *testing.T) {
m := map[string]any{
"to": "a@e.com",
"attachments": []any{
map[string]any{"path": "tmp/report.pdf"}, // 无 name
},
}
msg, err := mailx.FromMap(m)
if err != nil {
t.Fatal(err)
}
if len(msg.Attachments) != 1 {
t.Fatalf("attachments = %+v, want 1", msg.Attachments)
}
if msg.Attachments[0].Name != "report.pdf" {
t.Errorf("Name = %q, want derived report.pdf", msg.Attachments[0].Name)
}
}
// TestFromMapAttachmentTypeError 验证 attachments 类型非法时返回错误而非 panic
func TestFromMapAttachmentTypeError(t *testing.T) {
m := map[string]any{
"to": "a@e.com",
"attachments": "not-a-list", // 非法类型
}
if _, err := mailx.FromMap(m); err == nil {
t.Fatal("FromMap with invalid attachments type should error")
}
}
func TestIsHTML(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"<p>hi</p>", true},
{"<h1>hello</h1>", true},
{"<div class=\"a\">x</div>", true},
{"a < b > c", false}, // 普通比较,不应误判
{"plain text", false}, // 纯文本
{"<123>", false}, // 无有效标签名
{"<!-- comment -->", false}, // 注释
{"", false},
}
for _, c := range cases {
if got := mailx.IsHTML(c.in); got != c.want {
t.Errorf("IsHTML(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestFromMapAttachmentPaths 验证 attachments 为 []string(路径列表)
func TestFromMapAttachmentPaths(t *testing.T) {
m := map[string]any{
"to": "a@e.com",
"attachments": []string{"tmp/a.txt", "tmp/b.txt"},
}
msg, err := mailx.FromMap(m)
if err != nil {
t.Fatal(err)
}
if len(msg.Attachments) != 2 {
t.Fatalf("attachments = %+v, want 2", msg.Attachments)
}
if msg.Attachments[0].Name != "a.txt" || msg.Attachments[0].Path != "tmp/a.txt" {
t.Errorf("attachments[0] = %+v", msg.Attachments[0])
}
}
// TestFromMapToListVariants 验证收件人多种输入形式
func TestFromMapToListVariants(t *testing.T) {
// []any 形式
m := map[string]any{"to": []any{"a@e.com", "b@e.com"}}
msg, err := mailx.FromMap(m)
if err != nil {
t.Fatal(err)
}
if len(msg.To) != 2 || msg.To[0] != "a@e.com" {
t.Errorf("To = %v", msg.To)
}
// 非法类型
m = map[string]any{"to": 123}
if _, err := mailx.FromMap(m); err == nil {
t.Fatal("To with invalid type should error")
}
}
// TestFromMapBodyFallback 验证 html 优先于 body
func TestFromMapBodyFallback(t *testing.T) {
m := map[string]any{
"to": "a@e.com",
"body": "plain body",
"html": "<b>html body</b>",
}
msg, err := mailx.FromMap(m)
if err != nil {
t.Fatal(err)
}
if msg.Body != "<b>html body</b>" {
t.Errorf("Body = %q, want html preferred", msg.Body)
}
}
// TestFromBytesAllFields 验证 JSON 反序列化完整字段
func TestFromBytesAllFields(t *testing.T) {
data := []byte(`{
"from": "a@e.com",
"to": ["b@e.com"],
"cc": ["c@e.com"],
"bcc": ["d@e.com"],
"subject": "s",
"textbody": "t",
"body": "<p>h</p>",
"replyto": "r@e.com",
"attachments": [{"name": "a.txt", "data": "aGk="}],
"inline": [{"cid": "cid1", "name": "i.png", "data": "aGk="}]
}`)
msg, err := mailx.FromBytes(data)
if err != nil {
t.Fatal(err)
}
if msg.From != "a@e.com" || len(msg.To) != 1 || msg.To[0] != "b@e.com" {
t.Errorf("fields: %+v", msg)
}
if len(msg.Cc) != 1 || len(msg.Bcc) != 1 {
t.Errorf("cc/bcc: %+v", msg)
}
if msg.TextBody != "t" || msg.Body != "<p>h</p>" || msg.ReplyTo != "r@e.com" {
t.Errorf("body fields: %+v", msg)
}
if len(msg.Attachments) != 1 || msg.Attachments[0].Name != "a.txt" {
t.Errorf("attachments: %+v", msg.Attachments)
}
if len(msg.Inline) != 1 || msg.Inline[0].CID != "cid1" {
t.Errorf("inline: %+v", msg.Inline)
}
}
+49
View File
@@ -0,0 +1,49 @@
package mailx_test
import (
"context"
"sync"
mailx "code.yun.ink/pkg/mailx"
)
// mockSender 记录收到的消息与注入的 logger,用于测试 Manager,不产生真实网络请求
type mockSender struct {
name string
err error // 非 nil 时 Send 返回该错误
mu sync.Mutex
msgs []*mailx.Message
logger mailx.Logger
}
func newMockSender(name string) *mockSender {
return &mockSender{name: name}
}
func (m *mockSender) Name() string { return m.name }
func (m *mockSender) Send(ctx context.Context, msg *mailx.Message) error {
m.mu.Lock()
defer m.mu.Unlock()
m.logger = mailx.LoggerFromContext(ctx)
if m.err != nil {
return m.err
}
m.msgs = append(m.msgs, msg)
return nil
}
func (m *mockSender) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.msgs)
}
func (m *mockSender) lastLogger() mailx.Logger {
m.mu.Lock()
defer m.mu.Unlock()
return m.logger
}
var _ mailx.Sender = (*mockSender)(nil)
-1
View File
@@ -1 +0,0 @@
hhhhhhhhhhh
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

-77
View File
@@ -1,77 +0,0 @@
package mailx
import (
"bytes"
"github.com/PuerkitoBio/goquery"
)
// 解析HTML资源,响应资源链接
func ParseHtmlResource(html string) ([]string, error) {
resp := []string{}
b := bytes.NewBufferString(html)
doc, err := goquery.NewDocumentFromReader(b)
if err != nil {
return nil, err
}
// 找到所有的 css 标签,并且打印它们的 href 属性
doc.Find("link").Each(func(i int, s *goquery.Selection) {
// 忽略dns预请求
r, ok := s.Attr("rel")
if ok && r == "dns-prefetch" {
return
}
href, ok := s.Attr("href")
if ok && href != "" {
resp = append(resp, href)
}
})
// 找到所有的 script 标签,并且打印它们的 src 属性
doc.Find("script").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
})
// 找到所有的 img 标签,并且打印它们的 src 属性
doc.Find("img").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
data_src, ok := s.Attr("data-src")
if ok && data_src != "" {
resp = append(resp, data_src)
}
})
// 找到所有的 video 标签,并且打印它们的 src 属性
doc.Find("video").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
data_src, ok := s.Attr("data-src")
if ok && data_src != "" {
resp = append(resp, data_src)
}
})
// 找到所有的 audio 标签,并且打印它们的 src 属性
doc.Find("audio").Each(func(i int, s *goquery.Selection) {
src, ok := s.Attr("src")
if ok && src != "" {
resp = append(resp, src)
}
})
return resp, nil
}
-60
View File
@@ -1,60 +0,0 @@
package mailx_test
import (
"testing"
"code.yun.ink/pkg/mailx"
)
func TestParseHtmlResource(t *testing.T) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<link rel="stylesheet" href="./assets/css/index.css" />
</head>
<body>
<header class="header flex-center">
<img src="./assets/img/ab-pay-logo.png" alt="" class="logo" />
</header>
<main class="code-main">
<div class="exception-title">您的收款方式审核失败</div>
<div class="exception-content">
尊敬的ABpay用户,您好!
您的收款方式审核失败,失败原因人脸识别失败,请检查您的面部是否被遮挡或处于模糊状态,并再次尝试。
如非本人操作,请立即修改密码或联系客服。
</div>
<div class="exception-ignore">这是一条自动发送的消息,请勿回复</div>
<li class="exception-team-tips">ABpay开发者团队服务</li>
<section class="contact-us">
<div class="contact-us-info">
本邮件由系统自动发出请勿回复,如需要了解更多服务,欢迎访问ABpay官方网
还可以通以下方式联系我们
</div>
<img src="./assets/img/contact-us-qrcode.png" alt="" class="qr-code" />
<div class="contact-tel">
客服电话:<span class="contact-tel-number">400-278-2890</span>
</div>
<div class="contact-created-version">2024 ABpay.com.cn . All rightes reserved</div>
</section>
</main>
</body>
</html>
`
res, err := mailx.ParseHtmlResource(html)
if err != nil {
t.Fatal(err)
}
t.Log(res)
}
-202
View File
@@ -1,202 +0,0 @@
package mailx
import (
"bytes"
"encoding/base64"
"fmt"
"net/smtp"
"os"
"path"
"strings"
"time"
)
// 邮件发送的封装
// 1. 支持文本
// 2. 支持文件
type Mailx struct {
user string
password string
host string
port string
auth smtp.Auth
}
type Attachment struct {
Name string
ContentType string
WithFile bool
}
type Message struct {
Form string
To []string
Cc []string
Bcc []string
Subject string
Body string
// ContentType string
ReplyTo string
Attachment []Attachment
}
func NewMailx(user, password, host, port string) *Mailx {
m := &Mailx{
user: user,
password: password,
host: host,
port: port,
}
m.auth = smtp.PlainAuth("", user, password, host)
return m
}
func (m *Mailx) Send(message Message) error {
// .Auth()
buffer := bytes.NewBuffer(nil)
boundary := "YunBoundaryYun"
Header := make(map[string]string)
// Header["From"] = "BOP<" + message.Form + ">"
Header["From"] = m.user
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"
m.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 += "--" + boundary + "--\r\n\r\n"
buffer.WriteString(body)
for _, value := range message.Attachment {
newBuf := bytes.NewBuffer(nil)
err := m.writeFile(newBuf, value.Name)
if err != nil {
fmt.Println("file err:", err)
continue
}
f_name := path.Base(value.Name)
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")
}
if message.Form == "" {
message.Form = m.user
}
imgBuf := bytes.NewBuffer(nil)
err := m.writeFile(imgBuf, "./asset/余额宝.png")
if err != nil {
return fmt.Errorf("file err: %w", err)
}
f_name:= "f_name"
attachment := "--" + boundary + "\r\n"
attachment += "Content-Transfer-Encoding:base64\r\n"
attachment += "Content-ID:myimage \r\n"
attachment += "Content-Disposition:inline;filename=" + f_name + ".png \r\n"
attachment += "Content-Type:image/png \r\n"
buffer.WriteString(attachment)
buffer.WriteString(imgBuf.String())
buffer.WriteString("\r\n--" + boundary + "--\r\n")
b := buffer.Bytes()
err = smtp.SendMail(m.host+":"+m.port, m.auth, message.Form, message.To, b)
fmt.Println("发送结束:", err)
fmt.Println(string(b))
return err
}
// 格式化header
func (m *Mailx) 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 (m *Mailx) writeFile(buffer *bytes.Buffer, fileName string) error {
file, err := os.ReadFile(fileName)
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")
}
}
return nil
}
-64
View File
@@ -1,64 +0,0 @@
package mailx_test
import (
"fmt"
"testing"
mailx "code.yun.ink/pkg/mailx/old"
)
func TestMail(t *testing.T) {
mail := mailx.NewMailx("support@email.blueoceanpay.com", "SupporT2017", "smtpdm-ap-southeast-1.aliyun.com", "80")
msg := mailx.Message{
// Form: "support@email.blueoceanpay.com",
To: []string{"huangxinyun520@gmail.com", "995116474@qq.com"},
// Cc: []string{"287852692@qq.com"},
// Bcc: []string{"1362716835@qq.com"},
Subject: "test mail",
Body: "<img src=\"cid:myimage\">dasdsadsasda<br>dasdsadsadsa",
Attachment: []mailx.Attachment{
{
Name: "asset/hhh.txt",
ContentType: "",
WithFile: true,
},
// {
// Name: "/code/statistic/origin.xlsx",
// ContentType: "",
// WithFile: true,
// },
// {
// Name: "/code/statistic/out2.xlsx",
// ContentType: "",
// WithFile: true,
// },
},
}
err := mail.Send(msg)
fmt.Println(err)
}
func TestQQ(t *testing.T) {
// 发件人邮箱
from := "995116474@qq.com"
// 授权码,而非密码
authCode := "xxxxxxxxxxxxxxxxxxxxxx"
// 收件人邮箱,可以是多个收件人
to := []string{"yun@yun.ink"}
// 邮件服务器信息
smtpHost := "smtp.qq.com"
smtpPort := "587" // 或使用465,根据你的SMTP服务器要求设置
mail := mailx.NewMailx(from, authCode, smtpHost, smtpPort)
msg := mailx.Message{
To: to,
Subject: "test mail",
Body: "测试",
}
err := mail.Send(msg)
fmt.Println(err)
}
+311 -2
View File
@@ -1,6 +1,315 @@
# mailx
# 需优化事项
多通道邮件发送库,统一接口、简单易用,支持 SMTP / 阿里云 / AWS SES / Mailgun 等多种通道,可自由扩展。
1. 需要优化日志输出
## 特性
- **多通道接入**`smtp``aliyun``aws``mailgun` 开箱即用,实现 `mailx.Sender` 接口即可扩展新通道
- **链式消息构建**`mailx.NewMessage()` 流式拼接邮件内容
- **通道管理器**:注册多个通道,发送时按名称路由或临时指定配置
- **发送时指定配置**:无需提前初始化,可在调用 `Send` 时传入任意通道配置
- **日志可插拔**:定义轻量 `Logger` 接口,可适配 loggerx / zap / logrus 等任意日志库
## 安装
```bash
go get code.yun.ink/pkg/mailx
```
## 快速开始
> 第一次使用?推荐先跑通最简示例 `examples/quickstart`(逐行注释),
> 再阅读下面的 API 说明,最后看 `examples/` 里的进阶示例。
```bash
# 克隆仓库后直接运行最简示例(需把其中 SMTP 配置换成你的)
cd examples/quickstart && go run main.go
```
### 方式一:单个通道直接发送
```go
package main
import (
"context"
"fmt"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/smtp"
)
func main() {
ctx := context.Background()
err := smtp.New(smtp.Config{
Host: "smtp.qq.com",
Port: 587,
User: "sender@qq.com",
Password: "authorization-code",
From: "sender@qq.com", // 可选,默认取 User
}).Send(ctx, mailx.NewMessage().
To("receiver@example.com").
Subject("Hello").
HTML("<h1>Hello world</h1>").
Build())
fmt.Println(err)
}
```
### 方式二:多通道管理器
注册多个通道,发送时按名称切换,适合业务侧多通道路由/降级。
```go
mgr := mailx.NewManager()
mgr.Register(smtp.New(smtp.Config{Host: "smtp.qq.com", Port: 587, User: "u", Password: "p"}))
mgr.Register(aliyun.New(aliyun.Config{
AccessKeyID: "ak", AccessKeySecret: "sk", AccountName: "noreply@example.com",
}))
mgr.Register(aws.New(aws.Config{
AccessKeyID: "ak", AccessKeySecret: "sk", Region: "ap-northeast-1", Sender: "noreply@example.com",
}))
mgr.Register(mailgun.New(mailgun.Config{APIKey: "key", Domain: "mg.example.com", Sender: "noreply@example.com"}))
// 可选:指定默认通道,不设置则使用第一个注册的通道
mgr.SetDefault("aliyun")
msg := mailx.NewMessage().
From("noreply@example.com").
To("user@example.com").
Subject("Hello").
Body("hi").
Build()
mgr.Send(ctx, msg) // 使用默认通道
mgr.SendWith(ctx, "aws", msg) // 使用指定通道
```
**同一通道类型注册多份不同配置**:用 `RegisterNamed` 指定实例名,即可注册多个 smtp 实例(如主备切换)。
```go
mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "main"}))
mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "backup"}))
mgr.SendWith(ctx, "smtp-main", msg) // 用主 SMTP
mgr.SendWith(ctx, "smtp-backup", msg) // 用备用 SMTP
```
> 说明:`Register(s)` 以通道类型名(`s.Name()`)作为实例名,同一类型仅能注册一个;需要多配置时用 `RegisterNamed(name, s)`。
### 方式三:发送时临时指定配置
通道无需提前注册,发送时直接传入带配置的通道实例。
```go
err := mgr.SendBy(ctx, mailgun.New(mailgun.Config{
APIKey: "another-key", Domain: "mg2.example.com", Sender: "noreply@example.com",
}), msg)
```
## 消息构建 API
| 方法 | 说明 |
| --- | --- |
| `From(addr)` | 发件人(可含显示名,如 `"张三" <a@b.com>` |
| `To(addr...)` / `Cc(...)` / `Bcc(...)` | 收件人 / 抄送 / 密送(可含显示名) |
| `Subject(s)` | 主题 |
| `Text(s)` | 纯文本正文(可选,推荐配合 HTML 使用) |
| `Body(s)` / `HTML(s)` | 正文(HTML |
| `ReplyTo(addr)` | 回复地址 |
| `Attach(path)` / `AttachBytes(name, data)` | 普通附件(路径 / 内存字节) |
| `InlineImage(cid, path)` / `InlineImageBytes(cid, name, data)` | 内嵌图片,HTML 中用 `<img src="cid:<cid>">` 引用 |
| `Header(key, value)` | 自定义邮件头(如 `List-Unsubscribe``X-Mailer`),标准头不可覆盖 |
| `Build()` | 生成 `*Message` |
### 内嵌图片示例
```go
msg := mailx.NewMessage().
To("user@example.com").
Subject("Welcome").
HTML(`<h1>Hi</h1><img src="cid:logo1">`).
InlineImageBytes("logo1", "logo.png", pngBytes). // 或 InlineImage("logo1", "logo.png")
Build()
```
> 说明:SMTP 与 Mailgun 支持内嵌图片;AWS SES / 阿里云 DirectMail 的 SendEmail 不支持内嵌图片,若使用会返回明确错误。
### 从配置/DTO 快捷构建
```go
// 从 map 构建(便于接入配置/HTTP 请求体)
msg, _ := mailx.FromMap(map[string]any{
"from": "a@e.com", "to": "b@e.com, c@e.com",
"subject": "hi", "text": "plain", "html": "<b>hi</b>",
})
// 从 JSON 构建
msg, _ := mailx.FromBytes(jsonData)
```
### 地址工具
```go
mailx.IsValidAddress(`"张三" <a@b.com>`) // true,支持显示名
mailx.ExtractEmail(`"张三" <a@b.com>`) // "a@b.com"SMTP 命令需纯地址
mailx.AddressList("a@e.com, b@e.com; c@e.com") // 兼容逗号/分号分隔
```
## 错误处理
所有错误均可通过 `errors.Is` 精确判断类型:
```go
err := mgr.SendWith(ctx, "nope", msg)
switch {
case errors.Is(err, mailx.ErrSenderNotFound):
// 通道未注册,尝试其他通道
case errors.Is(err, mailx.ErrInvalidConfig):
// 配置缺失/非法
case errors.Is(err, mailx.ErrInvalidMessage):
// 消息校验失败
case errors.Is(err, mailx.ErrSendFailed):
// 发送过程中通道返回错误
}
```
## 扩展新通道
实现 `mailx.Sender` 接口即可:
```go
type Sender interface {
Name() string // 通道唯一名称
Send(ctx context.Context, msg *mailx.Message) error
}
```
```go
package mychannel
type MyChannel struct{ cfg Config }
func New(cfg Config) *MyChannel { return &MyChannel{cfg: cfg} }
func (c *MyChannel) Name() string { return "mychannel" }
func (c *MyChannel) Send(ctx context.Context, msg *mailx.Message) error {
// 实现发送逻辑
return nil
}
```
## 日志
通道默认不输出日志;可通过两种方式注入:
```go
// 1. 管理器全局注入
mgr.SetLogger(myLogger) // 实现 mailx.Logger 接口
// 2. 通过 context 注入
ctx = mailx.WithLogger(ctx, myLogger)
```
## 通道配置一览
| 通道 | 配置结构 | 必填 |
| --- | --- | --- |
| smtp | `smtp.Config{Host, Port, User, Password, From, ReplyTo, Encryption, Timeout}` | Host/Port/User/Password |
| aliyun | `aliyun.Config{AccessKeyID, AccessKeySecret, Endpoint, AccountName, ReplyAddress, Timeout}` | AccessKeyID/AccessKeySecret/AccountName |
| aws | `aws.Config{AccessKeyID, AccessKeySecret, Region, Sender, Timeout}` | SenderAWS 需预先验证发件地址) |
| mailgun | `mailgun.Config{APIKey, Domain, Sender, Timeout}` | APIKey/Domain |
### SMTP 加密
`smtp.Config.Encryption` 支持以下模式,默认 `auto`(按端口自动选择):
| 模式 | 说明 |
| --- | --- |
| `auto` | 端口 465 走 SSL,其余端口走 STARTTLS |
| `ssl` | 隐式 TLS(端口 465 |
| `tls` | STARTTLS 升级加密(端口 587/25 |
| `none` | 明文(仅内网/测试,不推荐) |
## 超时控制
每个通道都支持 `Timeout` 配置,防止发送阻塞,避免 goroutine 泄漏:
- 默认超时 30s
- 若调用方通过 `context.WithTimeout/WithDeadline` 传入更早的 deadline,则以更早者为准
- 各通道具体实现:SMTP 为连接与投递的整体 deadline;阿里云/AWS/Mailgun 通过 API 超时 + ctx 取消兜底
```go
client := smtp.New(smtp.Config{
Host: "smtp.qq.com", Port: 465, User: "u", Password: "p",
Timeout: 10 * time.Second, // 单次发送 10s 超时
})
```
## Manager 实例管理
```go
// 注册(实例名 = 通道类型名)与命名注册(可同类型多份)
mgr.Register(s) // 实例名取 s.Name()
mgr.RegisterNamed("smtp-backup", s) // 自定义实例名
// 注销与默认实例
mgr.Unregister("smtp-backup") // 注销后自动回退默认实例
mgr.SetDefault("smtp-main") // 设置默认实例
// 遍历与查询
mgr.Registered("smtp-backup") // 实例是否已注册
mgr.Names() // 所有实例名
mgr.Senders() // []SenderInfo{Name 实例名, Type 通道类型}
mgr.Default() // 当前默认实例名
```
遍历示例:
```go
for _, info := range mgr.Senders() {
fmt.Println(info.Name, info.Type) // 如 "smtp-main" "smtp"
}
```
## 生产建议
- **复用通道实例**:各通道内部惰性初始化并复用 SDK 客户端/连接池,业务侧应复用 `New` 出来的单例,避免每次新建
- **消息校验**`Message.Validate` 自动限制收件人总数(To+Cc+Bcc)≤50、附件数 ≤20、自定义头 ≤20、整封邮件大小 ≤25MB(`MaxMessageSize`),防止滥用配额
- **并发安全**`Manager` 与所有通道实例均线程安全,可安全地在多个 goroutine 中共享
- **日志**:生产环境建议实现并注入 `Logger`。通过 `Manager` 发送时,框架会自动记录通道名、收件人、发送耗时与失败原因
- **自定义邮件头**:营销邮件常用 `List-Unsubscribe` 退订头、`X-Mailer` 标识等,用 `Header(key, value)` 设置(标准头不可覆盖)
## 示例代码
仓库提供由浅入深的可运行示例,位于 `examples/` 目录(详见 [`examples/README.md`](./examples/README.md)):
| 示例 | 难度 | 说明 |
| --- | --- | --- |
| `examples/quickstart` | 入门 | 最简 SMTP 发送,逐行注释,适合第一次接触 |
| `examples/basic` | 入门 | 单通道完整用法:HTML+附件+内嵌图片+显示名 |
| `examples/manager` | 入门 | 多通道管理器:注册路由、主备切换、临时指定配置 |
| `examples/with_env` | 进阶 | 用环境变量管理多通道凭据,避免密钥写死 |
| `examples/advanced` | 进阶 | 主备切换、实例遍历、日志、超时、错误分类 |
| `examples/custom_sender` | 进阶 | 实现自定义通道 + 接入自定义 Logger |
```bash
# 5 分钟上手(最简示例)
cd examples/quickstart && go run main.go
# 其余示例(在项目根目录执行)
go run ./examples/basic
go run ./examples/manager
go run ./examples/with_env # 需先配置 examples/with_env/.env
go run ./examples/advanced
go run ./examples/custom_sender
```
核心 API 的用法示例(`Example*` 测试)也会在 `go test` 中自动验证,可通过 `go doc` 查看。
## 其他能力
- `ParseHTMLResource(html)`:解析 HTML 中引用的 css/js/img 等静态资源地址
- `aliyun.SyncStatus(ctx)`:阿里云通道回传发送状态记录
+17
View File
@@ -0,0 +1,17 @@
package mailx
import "context"
// Sender 发送通道接口,所有通道都实现该接口。
// 实现该接口即可接入任意发送通道(SMTP/阿里云/AWS/Mailgun/自定义网关等),
// 并复用 Manager 的注册路由、默认通道、日志注入与消息校验能力。
type Sender interface {
// Name 返回通道类型名(如 "smtp" / "aliyun" / "aws" / "mailgun")。
// 注意:Name 表示通道类型,不代表管理器中的唯一实例;
// 同一类型可用 RegisterNamed 注册多份不同配置的实例。
Name() string
// Send 发送一封邮件。ctx 用于超时与取消控制;
// 可通过 LoggerFromContext(ctx) 获取调用方注入的日志器。
Send(ctx context.Context, msg *Message) error
}
+600 -152
View File
@@ -1,185 +1,633 @@
// 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"
"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/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
}
+330
View File
@@ -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 签名 base640x89 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
View File
@@ -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
}
+22
View File
@@ -0,0 +1,22 @@
package mailx
// EmailSendStatus 邮件发送状态
type EmailSendStatus int
const (
EmailSendStatusUnknown EmailSendStatus = 0
EmailSendStatusSuccess EmailSendStatus = 1
EmailSendStatusInvalidAddress EmailSendStatus = 2
EmailSendStatusSpam EmailSendStatus = 3
EmailSendStatusFailed EmailSendStatus = 4
)
// EmailSendRecord 邮件发送记录(用于通道回传发送状态)
type EmailSendRecord struct {
AccountName string // 发件人
UpdateTime int64 // 毫秒时间戳
Status EmailSendStatus // 状态
ToUser string // 收件人
Subject string // 邮件主题
ErrorMessage string // 错误信息
}
+36
View File
@@ -0,0 +1,36 @@
package mailx
import (
"regexp"
"strings"
)
// htmlTagPattern 匹配 HTML 开标签:< 标签名 [属性...]>
// 不匹配注释、DTD、结束标签(</)、处理指令(<?),避免普通文本 "a < b > c" 被误判。
var htmlTagPattern = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^>]*)?>`)
// IsHTML 判断文本是否为 HTML:要求包含至少一个完整的 HTML 开标签。
func IsHTML(s string) bool {
return htmlTagPattern.MatchString(s)
}
// EscapeHTML 将纯文本转义为 HTML 安全的文本。
func EscapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
// fileBaseName 取路径中的文件名,同时兼容 / 与 \ 分隔符。
// 用于从文件路径推导附件/内嵌图片的默认名称。
func fileBaseName(p string) string {
if i := strings.LastIndexAny(p, `/\`); i >= 0 {
p = p[i+1:]
}
if p == "" || p == "." {
return "attachment"
}
return p
}