diff --git a/.gitignore b/.gitignore index bf0824e..09b0580 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -*.log \ No newline at end of file +*.log +.env +examples/with_env/.env diff --git a/address.go b/address.go new file mode 100644 index 0000000..f643ee9 --- /dev/null +++ b/address.go @@ -0,0 +1,38 @@ +package mailx + +import ( + "net/mail" + "strings" +) + +// AddressList 解析逗号/分号分隔的地址串,返回规范化后的 []mail.Address。 +// 兼容纯地址与 "显示名 " 形式。 +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 +} diff --git a/aliyun/aliyun.go b/aliyun/aliyun.go index c9063c2..ab2bf00 100644 --- a/aliyun/aliyun.go +++ b/aliyun/aliyun.go @@ -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 } diff --git a/aliyun/aliyun_internal_test.go b/aliyun/aliyun_internal_test.go new file mode 100644 index 0000000..cb49e00 --- /dev/null +++ b/aliyun/aliyun_internal_test.go @@ -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") + } +} diff --git a/aliyun/aliyun_mock_test.go b/aliyun/aliyun_mock_test.go new file mode 100644 index 0000000..98e5b1d --- /dev/null +++ b/aliyun/aliyun_mock_test.go @@ -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("html"). + 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) != "html" { + 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 < b & 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") + } +} diff --git a/aliyun/aliyun_test.go b/aliyun/aliyun_test.go index 2949eb4..d78b5c3 100644 --- a/aliyun/aliyun_test.go +++ b/aliyun/aliyun_test.go @@ -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) - // } diff --git a/aws/aws.go b/aws/aws.go index 108ebcf..97564f4 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -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 } diff --git a/aws/aws_internal_test.go b/aws/aws_internal_test.go new file mode 100644 index 0000000..87bdd56 --- /dev/null +++ b/aws/aws_internal_test.go @@ -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") + } +} diff --git a/aws/aws_mock_test.go b/aws/aws_mock_test.go new file mode 100644 index 0000000..d0c4e4a --- /dev/null +++ b/aws/aws_mock_test.go @@ -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("html"). + 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: "hi"}) + 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: "hi", 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() +} diff --git a/aws/aws_test.go b/aws/aws_test.go index d6300ee..3189eee 100644 --- a/aws/aws_test.go +++ b/aws/aws_test.go @@ -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) } diff --git a/cmd/main.go b/cmd/main.go index 4983197..deac27f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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 端口走 SSL,587/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(`"蓝海支付" `). // 发件人可带显示名 + To("995116474@qq.com"). + Subject("Test Email"). + Text("纯文本正文"). + HTML(`

Hello

This is a test email.

`). + 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("

Hello

"). + 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") + } + } } diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..1fd54c4 --- /dev/null +++ b/errors.go @@ -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") +) diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..4bfb644 --- /dev/null +++ b/example_test.go @@ -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("

Hello

"). + 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: +} + +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: +} + +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: +} + +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 +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..736845f --- /dev/null +++ b/examples/README.md @@ -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 diff --git a/examples/advanced/main.go b/examples/advanced/main.go new file mode 100644 index 0000000..a3281b3 --- /dev/null +++ b/examples/advanced/main.go @@ -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(`"通知中心" `). // 发件人带显示名 + To("user@example.com"). + Subject("生产环境告警"). + Text("这是一封告警邮件,请及时处理。"). + HTML(`

磁盘使用率超过 90%

`). + InlineImageBytes("chart1", "chart.png", []byte{0x89, 0x50, 0x4e, 0x47}). // 内嵌图片,cid:chart1 对应 + Header("X-Mailer", "mailx"). // 自定义邮件头 + Header("List-Unsubscribe", ""). // 退订头(营销邮件合规) + 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": "welcome", + }) + if err == nil { + _ = mgr.Send(ctx, m) + } +} diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..4d5b351 --- /dev/null +++ b/examples/basic/main.go @@ -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=SSL,587/25=STARTTLS + User: "sender@qq.com", // 账号 + Password: "your-auth-code", // 授权码(非邮箱密码) + From: "sender@qq.com", // 默认发件人(可选,Message.From 未设置时使用) + ReplyTo: "sender@qq.com", // 默认回复地址(可选) + }) + + // ===== 第 2 步:构建消息 ===== + // 地址都可带显示名,如 "张三" ;收件人/抄送/密送可传多个 + msg := mailx.NewMessage(). + From(`"客服中心" `). // 发件人(带显示名) + To("receiver@example.com"). // 收件人 + Cc("manager@example.com"). // 抄送(可选) + Bcc("leader@example.com"). // 密送(可选) + Subject("hello from mailx"). + Text("如果邮件客户端不支持 HTML,会显示这段纯文本。"). // 纯文本正文(推荐) + HTML(`

Hello

This email is sent by mailx.

`). + 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, + } +} diff --git a/examples/custom_sender/main.go b/examples/custom_sender/main.go new file mode 100644 index 0000000..6179977 --- /dev/null +++ b/examples/custom_sender/main.go @@ -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("from custom sender"). + Build() + + // 通过 Manager 统一发送 + if err := mgr.Send(ctx, msg); err != nil { + fmt.Println("send failed:", err) + return + } + fmt.Println("send ok") +} diff --git a/examples/manager/main.go b/examples/manager/main.go new file mode 100644 index 0000000..804ae2a --- /dev/null +++ b/examples/manager/main.go @@ -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("

Hello

"). + 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()) +} diff --git a/examples/quickstart/main.go b/examples/quickstart/main.go new file mode 100644 index 0000000..e65f5d6 --- /dev/null +++ b/examples/quickstart/main.go @@ -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 走 SSL,587 走 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"). // 发件人(可带显示名,如 "张三" ) + 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") +} diff --git a/examples/with_env/.env.example b/examples/with_env/.env.example new file mode 100644 index 0000000..c37793e --- /dev/null +++ b/examples/with_env/.env.example @@ -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 diff --git a/examples/with_env/main.go b/examples/with_env/main.go new file mode 100644 index 0000000..d6f858f --- /dev/null +++ b/examples/with_env/main.go @@ -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 "" +} diff --git a/from.go b/from.go new file mode 100644 index 0000000..3c0c1b3 --- /dev/null +++ b/from.go @@ -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 "" +} diff --git a/go.mod b/go.mod index 9544eb7..98eab65 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index ed95ca9..7213ff8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/html.go b/html.go index bf62411..0a23068 100644 --- a/html.go +++ b/html.go @@ -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) } diff --git a/html_resource_test.go b/html_resource_test.go new file mode 100644 index 0000000..302e681 --- /dev/null +++ b/html_resource_test.go @@ -0,0 +1,71 @@ +package mailx_test + +import ( + "reflect" + "testing" + + mailx "code.yun.ink/pkg/mailx" +) + +func TestParseHtmlResourceAll(t *testing.T) { + 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("plain") + 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(` + + + + `) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("got %v, want empty", got) + } +} diff --git a/html_test.go b/html_test.go index 012ed50..506df62 100644 --- a/html_test.go +++ b/html_test.go @@ -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) { ` - res, err := mailx.ParseHtmlResource(html) + res, err := mailx.ParseHTMLResource(html) if err != nil { t.Fatal(err) } diff --git a/interfaces/consts.go b/interfaces/consts.go deleted file mode 100644 index 2a106bb..0000000 --- a/interfaces/consts.go +++ /dev/null @@ -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 -) diff --git a/interfaces/interfaces.go b/interfaces/interfaces.go deleted file mode 100644 index ca4cae1..0000000 --- a/interfaces/interfaces.go +++ /dev/null @@ -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) -} diff --git a/interfaces/options.go b/interfaces/options.go deleted file mode 100644 index 247a121..0000000 --- a/interfaces/options.go +++ /dev/null @@ -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 - } -} diff --git a/internal_test.go b/internal_test.go new file mode 100644 index 0000000..652a2f9 --- /dev/null +++ b/internal_test.go @@ -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) + } + } +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..a9fbc7a --- /dev/null +++ b/logger.go @@ -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() +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..175729f --- /dev/null +++ b/logger_test.go @@ -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{}) +} diff --git a/mailgun/mailgun.go b/mailgun/mailgun.go index 8a7ebd4..be2252a 100644 --- a/mailgun/mailgun.go +++ b/mailgun/mailgun.go @@ -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 中用 ,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.ReadCloser(Close 为空操作) +type nopCloser struct { + io.Reader +} + +func (nopCloser) Close() error { return nil } diff --git a/mailgun/mailgun_internal_test.go b/mailgun/mailgun_internal_test.go new file mode 100644 index 0000000..9d60d83 --- /dev/null +++ b/mailgun/mailgun_internal_test.go @@ -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") + } +} diff --git a/mailgun/mailgun_mock_test.go b/mailgun/mailgun_mock_test.go new file mode 100644 index 0000000..982a758 --- /dev/null +++ b/mailgun/mailgun_mock_test.go @@ -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("html"). + 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("html").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") + } +} diff --git a/mailgun/mailgun_test.go b/mailgun/mailgun_test.go index 98d9400..7a2d2d3 100644 --- a/mailgun/mailgun_test.go +++ b/mailgun/mailgun_test.go @@ -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") - } diff --git a/mailx.go b/mailx.go index dada30b..e9fac0a 100644 --- a/mailx.go +++ b/mailx.go @@ -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()) - -} diff --git a/manager.go b/manager.go new file mode 100644 index 0000000..d41bdd1 --- /dev/null +++ b/manager.go @@ -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 +} diff --git a/manager_test.go b/manager_test.go new file mode 100644 index 0000000..faa9c5b --- /dev/null +++ b/manager_test.go @@ -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()) + } +} diff --git a/message.go b/message.go new file mode 100644 index 0000000..f720d97 --- /dev/null +++ b/message.go @@ -0,0 +1,218 @@ +package mailx + +import "fmt" + +// Message 邮件消息 +type Message struct { + From string // 发件人(可含显示名,如 "张三" ) + 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 中用 引用) +} + +// Attachment 附件(普通附件,下载式) +type Attachment struct { + Name string // 附件名称(可选,默认取 Path 的文件名) + Path string // 附件文件路径(Path 与 Data 二选一) + Data []byte // 附件内容(Path 与 Data 二选一) +} + +// InlineImage 内嵌图片(HTML 邮件正文中引用的图片,显示在正文内) +type InlineImage struct { + CID string // Content-ID,HTML 中用 src="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 设置发件人(可含显示名,如 "张三" ) +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 中用 +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 中用 +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 +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..3a57822 --- /dev/null +++ b/message_test.go @@ -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("

hi

").Build() + if msg.Body != "

hi

" { + 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) + } + }) + } +} + + diff --git a/message_util_test.go b/message_util_test.go new file mode 100644 index 0000000..cac87f6 --- /dev/null +++ b/message_util_test.go @@ -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(`"张三" `) { + 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(`"张三" `) + 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" , 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("

h

"). + 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 != "

h

" { + 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": "hi", + "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 != "hi" { + 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 + }{ + {"

hi

", true}, + {"

hello

", true}, + {"
x
", true}, + {"a < b > c", false}, // 普通比较,不应误判 + {"plain text", false}, // 纯文本 + {"<123>", false}, // 无有效标签名 + {"", 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": "html body", + } + msg, err := mailx.FromMap(m) + if err != nil { + t.Fatal(err) + } + if msg.Body != "html body" { + 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": "

h

", + "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 != "

h

" || 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) + } +} diff --git a/mock_test.go b/mock_test.go new file mode 100644 index 0000000..1bc6bfe --- /dev/null +++ b/mock_test.go @@ -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) diff --git a/old/asset/hhh.txt b/old/asset/hhh.txt deleted file mode 100644 index 1e4d056..0000000 --- a/old/asset/hhh.txt +++ /dev/null @@ -1 +0,0 @@ -hhhhhhhhhhh \ No newline at end of file diff --git a/old/asset/余额宝.png b/old/asset/余额宝.png deleted file mode 100644 index 43c8e4a..0000000 Binary files a/old/asset/余额宝.png and /dev/null differ diff --git a/old/html.go b/old/html.go deleted file mode 100644 index b40298a..0000000 --- a/old/html.go +++ /dev/null @@ -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 - -} diff --git a/old/html_test.go b/old/html_test.go deleted file mode 100644 index 80c6a6f..0000000 --- a/old/html_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package mailx_test - -import ( - "testing" - - "code.yun.ink/pkg/mailx" -) - -func TestParseHtmlResource(t *testing.T) { - html := ` - - - - - Document - - - -
- -
- -
-
您的收款方式审核失败
- -
- 尊敬的ABpay用户,您好! - 您的收款方式审核失败,失败原因人脸识别失败,请检查您的面部是否被遮挡或处于模糊状态,并再次尝试。 - 如非本人操作,请立即修改密码或联系客服。 -
- -
这是一条自动发送的消息,请勿回复
- -
  • ABpay开发者团队服务
  • - -
    -
    - 本邮件由系统自动发出请勿回复,如需要了解更多服务,欢迎访问ABpay官方网 - 还可以通以下方式联系我们 -
    - - -
    - 客服电话:400-278-2890 -
    -
    2024 ABpay.com.cn . All rightes reserved
    -
    -
    - - - ` - - res, err := mailx.ParseHtmlResource(html) - if err != nil { - t.Fatal(err) - } - - t.Log(res) - -} diff --git a/old/mailx.go b/old/mailx.go deleted file mode 100644 index f507cb9..0000000 --- a/old/mailx.go +++ /dev/null @@ -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 += "

    huang

    xin

    \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 := 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 -} diff --git a/old/mailx_test.go b/old/mailx_test.go deleted file mode 100644 index 00d6d06..0000000 --- a/old/mailx_test.go +++ /dev/null @@ -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: "dasdsadsasda
    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) -} diff --git a/readme.md b/readme.md index b9b8eaf..6bc78ba 100644 --- a/readme.md +++ b/readme.md @@ -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("

    Hello world

    "). + 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)` | 发件人(可含显示名,如 `"张三" `) | +| `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 中用 `` 引用 | +| `Header(key, value)` | 自定义邮件头(如 `List-Unsubscribe`、`X-Mailer`),标准头不可覆盖 | +| `Build()` | 生成 `*Message` | + +### 内嵌图片示例 + +```go +msg := mailx.NewMessage(). + To("user@example.com"). + Subject("Welcome"). + HTML(`

    Hi

    `). + 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": "hi", +}) + +// 从 JSON 构建 +msg, _ := mailx.FromBytes(jsonData) +``` + +### 地址工具 + +```go +mailx.IsValidAddress(`"张三" `) // true,支持显示名 +mailx.ExtractEmail(`"张三" `) // "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}` | Sender(AWS 需预先验证发件地址) | +| 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)`:阿里云通道回传发送状态记录 diff --git a/sender.go b/sender.go new file mode 100644 index 0000000..144d1b4 --- /dev/null +++ b/sender.go @@ -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 +} diff --git a/smtp/smtp.go b/smtp/smtp.go index aa71910..514a737 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -1,185 +1,633 @@ +// Package smtp 提供基于 SMTP 协议的邮件发送通道。 +// +// 支持三种连接模式: +// - SSL/TLS 加密(如端口 465,Encryption=EncryptionSSL) +// - STARTTLS 加密(默认,如端口 587/25) +// - 明文(Encryption=EncryptionNone,不推荐) package smtp import ( "bytes" "context" + "crypto/tls" "encoding/base64" "errors" "fmt" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net" + "net/mail" "net/smtp" + "net/textproto" "os" - "path" + "sort" "strings" "time" - "code.yun.ink/pkg/mailx/interfaces" + mailx "code.yun.ink/pkg/mailx" ) -// 邮件发送的封装 -// 1. 支持文本 -// 2. 支持文件 +// Encryption 连接加密模式 +type Encryption string +const ( + EncryptionAuto Encryption = "auto" // 根据端口自动选择:465 走 SSL,其余走 STARTTLS + EncryptionSSL Encryption = "ssl" // 隐式 TLS(端口 465) + EncryptionTLS Encryption = "tls" // STARTTLS 升级加密(端口 587/25) + EncryptionNone Encryption = "none" // 明文,不加密 +) + +// Config SMTP 通道配置 +type Config struct { + Host string // SMTP 服务器地址,如 smtp.qq.com + Port int // SMTP 端口,如 465 / 587 / 25 + User string // 账号 + Password string // 密码或授权码 + From string // 默认发件人(可选,Message.From 优先) + ReplyTo string // 默认回复地址(可选) + Encryption Encryption // 连接加密模式,默认 EncryptionAuto + TLSSkipVerify bool // 是否跳过 TLS 证书校验(仅测试环境使用,勿在生产开启) + Timeout time.Duration // 单次发送的超时(含建连与投递),默认 30s;ctx 已带 deadline 时取较小者 +} + +// defaultTimeout 单次 SMTP 发送的默认超时 +const defaultTimeout = 30 * time.Second + +// Smtp SMTP 发送通道 type Smtp struct { - interfaces.DefaultEmail - // params *interfaces.EmailConfigDataSmtp - auth smtp.Auth - // logger loggerx.LoggerInterface + cfg Config } -func NewSmtp() *Smtp { - smtp := &Smtp{} - smtp.Options = interfaces.DefaultOptions() - smtp.EmailType = interfaces.EmailTypeSmtp - return smtp +// New 创建 SMTP 通道 +func New(cfg Config) *Smtp { + if cfg.Encryption == "" { + cfg.Encryption = EncryptionAuto + } + return &Smtp{cfg: cfg} } -func (l *Smtp) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) { +// Name 返回通道名称 +func (s *Smtp) Name() string { return "smtp" } - for _, o := range opt { - o(&l.Options) +// Send 发送一封邮件 +func (s *Smtp) Send(ctx context.Context, msg *mailx.Message) error { + logger := mailx.LoggerFromContext(ctx) + + if s.cfg.Host == "" || s.cfg.Port == 0 { + return mailx.ErrInvalidConfig } - l.Options.Logger.Infof(ctx, "params:%+v", l.Options.Smtp) - - if l.Options.Smtp == nil { - return nil, errors.New("not smtp") + from := s.firstNonEmpty(msg.From, s.cfg.From, s.cfg.User) + if from == "" { + return fmt.Errorf("%w: smtp sender is empty", mailx.ErrInvalidConfig) } - l.auth = smtp.PlainAuth("", l.Options.Smtp.Username, l.Options.Smtp.Password, l.Options.Smtp.Host) - return l, nil -} - -func (l *Smtp) Send(ctx context.Context, message interfaces.Message) error { - if l.Options.Smtp == nil { - return errors.New("not init") - } - // .Auth() - buffer := bytes.NewBuffer(nil) - boundary := "YunBoundaryYun" - - Header := make(map[string]string) - // Header["From"] = "BOP<" + message.Form + ">" - - if message.Form != "" { - Header["From"] = message.Form - } else { - Header["From"] = l.Options.Smtp.Username - } - - if len(message.To) > 0 { - str := "" - for _, val := range message.To { - name := "" - s := strings.Split(val, "@") - if len(s) > 0 { - name = s[0] - } - str = str + "," + name + "<" + val + ">" - } - Header["To"] = strings.Trim(str, ",") - // Header["To"] = strings.Join(message.To, ",") - } - if len(message.Cc) > 0 { - str := "" - for _, val := range message.Cc { - name := "" - s := strings.Split(val, "@") - if len(s) > 0 { - name = s[0] - } - str = str + "," + name + "<" + val + ">" - } - Header["Cc"] = strings.Trim(str, ",") - // Header["Cc"] = strings.Join(message.Cc, ",") - } - if len(message.Bcc) > 0 { - str := "" - for _, val := range message.Bcc { - name := "" - s := strings.Split(val, "@") - if len(s) > 0 { - name = s[0] - } - str = str + "," + name + "<" + val + ">" - } - Header["Bcc"] = strings.Trim(str, ",") - // Header["Bcc"] = strings.Join(message.Bcc, ",") - } - - Header["Subject"] = message.Subject - Header["Content-Type"] = "multipart/mixed; charset=UTF-8; boundary=" + boundary - Header["Date"] = time.Now().String() - Header["Reply-To"] = message.ReplyTo - - Header["X-Priority"] = "3" - l.writeHeader(buffer, Header) - - body := "--" + boundary + "\r\n" - // body += "Content-Type: text/plain; charset=UTF-8 \r\n" - body += "Content-Type: text/html;charset=utf-8\r\n" - body += "Content-Transfer-Encoding:quoted-printable\r\n\r\n" - // body += "

    huang

    xin

    \r\n" - - // body += "" + message.Body + "\r\n" - - body += message.Body + "\r\n" - - // body += "--" + boundary + "--\r\n\r\n" - buffer.WriteString(body) - - for _, value := range message.Attachment { - newBuf := bytes.NewBuffer(nil) - err := l.writeFile(newBuf, value.Content) - if err != nil { - fmt.Println("file err:", err) - continue - } - - f_name := path.Base(value.Content) - attachment := "--" + boundary + "\r\n" - attachment += "Content-Transfer-Encoding:base64\r\n" - attachment += "Content-Disposition:attachment;filename=" + f_name + "\r\n" - attachment += "Content-Type: application/octet-stream;charset=utf-8;name=" + f_name + "\r\n" - // attachment += "Contment-Type:" + message.attachment.contentType + ";name=\"" + message.attachment.name + "\"\r\n" - buffer.WriteString(attachment) - - buffer.WriteString(newBuf.String()) - } - - buffer.WriteString("\r\n--" + boundary + "--\r\n") - b := buffer.Bytes() - err := smtp.SendMail(l.Options.Smtp.Host+":"+l.Options.Smtp.Port, l.auth, l.Options.Smtp.Username, message.To, b) - return err -} - -// 格式化header -func (l *Smtp) writeHeader(buffer *bytes.Buffer, Header map[string]string) string { - header := "" - // header := "Content-Type: multipart/mixed;charset=UTF-8;boundary=\"YunBoundaryYun\" \r\n" - for key, value := range Header { - if value != "" { - header += key + ": " + value + "\r\n" - } - } - header += "\r\n" - buffer.WriteString(header) - return header -} - -// 格式化文件 -func (l *Smtp) writeFile(buffer *bytes.Buffer, fileName string) error { - file, err := os.ReadFile(fileName) + data, err := s.buildMIME(msg, from) if err != nil { return err } - payload := make([]byte, base64.StdEncoding.EncodedLen(len(file))) - base64.StdEncoding.Encode(payload, file) - buffer.WriteString("\r\n") - for index, line := 0, len(payload); index < line; index++ { - buffer.WriteByte(payload[index]) - if (index+1)%76 == 0 { - buffer.WriteString("\r\n") + + if err := s.deliver(ctx, from, msg.To, data); err != nil { + logger.Errorf(ctx, "mailx/smtp: send to %v failed: %v", msg.To, err) + return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err) + } + logger.Infof(ctx, "mailx/smtp: sent to %v subject=%q", msg.To, msg.Subject) + return nil +} + +// deliver 按配置的加密模式发送邮件 +func (s *Smtp) deliver(ctx context.Context, from string, to []string, data []byte) error { + switch s.effectiveEncryption() { + case EncryptionSSL: + return s.sendOverSSL(ctx, from, to, data) + case EncryptionNone: + return s.sendOverPlain(ctx, from, to, data) + default: // EncryptionTLS / EncryptionAuto(非465) + return s.sendOverStartTLS(ctx, from, to, data) + } +} + +func (s *Smtp) effectiveEncryption() Encryption { + switch s.cfg.Encryption { + case EncryptionSSL, EncryptionTLS, EncryptionNone: + return s.cfg.Encryption + default: // auto + if s.cfg.Port == 465 { + return EncryptionSSL } + return EncryptionTLS + } +} + +func (s *Smtp) tlsConfig() *tls.Config { + return &tls.Config{ + ServerName: s.cfg.Host, + InsecureSkipVerify: s.cfg.TLSSkipVerify, //nolint:gosec // 仅测试环境开启 + } +} + +// dialContext 建立 TCP 连接并应用 ctx 超时与 deadline +func (s *Smtp) dialContext(ctx context.Context) (net.Conn, error) { + dialer := &net.Dialer{Timeout: s.singleTimeout(ctx)} + conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)) + if err != nil { + return nil, err + } + // 建连后设置整体投递 deadline,保证连接上的所有 I/O 不会无限阻塞 + if err := applyDeadline(conn, s.singleTimeout(ctx)); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +// sendOverSSL 直接建立 TLS 连接(端口 465) +func (s *Smtp) sendOverSSL(ctx context.Context, from string, to []string, data []byte) error { + raw, err := s.dialContext(ctx) + if err != nil { + return err + } + tlsConn := tls.Client(raw, s.tlsConfig()) + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = raw.Close() + return err + } + client, err := smtp.NewClient(tlsConn, s.cfg.Host) + if err != nil { + _ = raw.Close() + return err + } + // 由 sendWithClient 统一负责 Close + return s.sendWithClient(ctx, client, from, to, data) +} + +// sendOverStartTLS 建立明文连接后用 STARTTLS 升级 +func (s *Smtp) sendOverStartTLS(ctx context.Context, from string, to []string, data []byte) error { + conn, err := s.dialContext(ctx) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, s.cfg.Host) + if err != nil { + _ = conn.Close() + return err + } + if err := client.StartTLS(s.tlsConfig()); err != nil { + _ = client.Close() + return fmt.Errorf("starttls: %w", err) + } + return s.sendWithClient(ctx, client, from, to, data) +} + +// sendOverPlain 明文发送(不加密,仅用于内网/测试) +func (s *Smtp) sendOverPlain(ctx context.Context, from string, to []string, data []byte) error { + conn, err := s.dialContext(ctx) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, s.cfg.Host) + if err != nil { + _ = conn.Close() + return err + } + return s.sendWithClient(ctx, client, from, to, data) +} + +// sendWithClient 基于已建立的 SMTP 客户端完成认证与投递 +// 底层连接的 deadline 已在 dialContext 阶段设置,覆盖整个投递流程。 +func (s *Smtp) sendWithClient(_ context.Context, client *smtp.Client, from string, to []string, data []byte) error { + defer client.Close() + + if s.cfg.User != "" { + auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host) + if ok, _ := client.Extension("AUTH"); ok { + if err := client.Auth(auth); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + } + + // SMTP 命令要求纯邮箱地址(不含显示名),这里统一提取 + senderAddr, err := mailx.ExtractEmail(from) + if err != nil { + return fmt.Errorf("%w: invalid from address %q: %v", mailx.ErrInvalidConfig, from, err) + } + if err := client.Mail(senderAddr); err != nil { + return err + } + for _, addr := range to { + recipient, rerr := mailx.ExtractEmail(addr) + if rerr != nil { + return fmt.Errorf("%w: invalid recipient %q: %v", mailx.ErrInvalidMessage, addr, rerr) + } + if err := client.Rcpt(recipient); err != nil { + return err + } + } + w, err := client.Data() + if err != nil { + return err + } + if _, err := w.Write(data); err != nil { + _ = w.Close() + return err + } + if err := w.Close(); err != nil { + return err + } + if err := client.Quit(); err != nil { + return err } return nil } + +// singleTimeout 计算单次发送的 I/O 超时:取 cfg.Timeout 与 ctx deadline 中较小者 +func (s *Smtp) singleTimeout(ctx context.Context) time.Duration { + timeout := s.cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + if dl, ok := ctx.Deadline(); ok { + if remain := time.Until(dl); remain < timeout { + timeout = remain + } + } + return timeout +} + +// applyDeadline 为 net.Conn 设置整体 deadline +func applyDeadline(conn net.Conn, d time.Duration) error { + if d <= 0 { + return nil + } + return conn.SetDeadline(time.Now().Add(d)) +} + +// buildMIME 使用标准库构造邮件体,按内容自动选择 MIME 结构: +// - 仅正文:multipart/alternative(text/plain + text/html) +// - 正文 + 内嵌图片:multipart/related,内含 alternative + inline 图片 +// - 有普通附件:multipart/mixed(可再内含 related) +func (s *Smtp) buildMIME(msg *mailx.Message, from string) ([]byte, error) { + buf := bytes.NewBuffer(nil) + + replyTo := s.firstNonEmpty(msg.ReplyTo, s.cfg.ReplyTo) + hasBody := msg.Body != "" || msg.TextBody != "" + hasAttachments := len(msg.Attachments) > 0 + hasInline := len(msg.Inline) > 0 + + // ---- 顶部 Header ---- + header := textproto.MIMEHeader{} + header.Set("From", formatAddressHeader(from)) + header.Set("To", formatAddressList(msg.To)) + if len(msg.Cc) > 0 { + header.Set("Cc", formatAddressList(msg.Cc)) + } + if len(msg.Bcc) > 0 { + header.Set("Bcc", formatAddressList(msg.Bcc)) + } + header.Set("Subject", encodeHeader(msg.Subject)) + header.Set("Date", time.Now().Format(time.RFC1123Z)) + header.Set("MIME-Version", "1.0") + if replyTo != "" { + header.Set("Reply-To", formatAddressHeader(replyTo)) + } + // 自定义头(不覆盖标准头) + for k, v := range msg.Headers { + if k != "" && v != "" && !hasStdHeader(k) { + header.Set(k, encodeHeader(v)) + } + } + + // ---- 正文 alternative 部分(先独立生成,便于在各结构中复用) ---- + var altBytes []byte + var altBoundary string + if hasBody { + altBuf := bytes.NewBuffer(nil) + altMP := multipart.NewWriter(altBuf) + if err := writeAlternative(altMP, msg); err != nil { + return nil, err + } + altBytes = altBuf.Bytes() + altBoundary = altMP.Boundary() + } + + // ---- related 层(正文 + 内嵌图片) ---- + var related *multipart.Writer + var relatedBytes []byte + if hasInline { + rbuf := bytes.NewBuffer(nil) + related = multipart.NewWriter(rbuf) + // 先写 alternative 作为 related 的第一个 part + if hasBody { + altHdr := textproto.MIMEHeader{} + altHdr.Set("Content-Type", "multipart/alternative; boundary="+altBoundary) + altPart, err := related.CreatePart(altHdr) + if err != nil { + return nil, fmt.Errorf("mailx/smtp: create related alternative: %w", err) + } + if _, err := altPart.Write(altBytes); err != nil { + return nil, fmt.Errorf("mailx/smtp: write related alternative: %w", err) + } + } + for _, inl := range msg.Inline { + name, data, mtype, err := s.readInline(inl) + if err != nil { + return nil, err + } + inlHdr := textproto.MIMEHeader{} + inlHdr.Set("Content-Type", mtype+"; name="+encodeHeader(name)) + inlHdr.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", name)) + inlHdr.Set("Content-ID", "<"+inl.CID+">") + inlHdr.Set("Content-Transfer-Encoding", "base64") + part, err := related.CreatePart(inlHdr) + if err != nil { + return nil, fmt.Errorf("mailx/smtp: create inline part: %w", err) + } + bw := base64.NewEncoder(base64.StdEncoding, part) + if _, err := bw.Write(data); err != nil { + return nil, fmt.Errorf("mailx/smtp: write inline: %w", err) + } + if err := bw.Close(); err != nil { + return nil, fmt.Errorf("mailx/smtp: close inline: %w", err) + } + } + if err := related.Close(); err != nil { + return nil, fmt.Errorf("mailx/smtp: close related: %w", err) + } + relatedBytes = rbuf.Bytes() + } + + // ---- 顶层 body(mixed / related / alternative) ---- + var bodyBytes []byte + switch { + case hasAttachments: + mbuf := bytes.NewBuffer(nil) + mixed := multipart.NewWriter(mbuf) + if hasBody || hasInline { + bodyHdr := textproto.MIMEHeader{} + if hasInline { + bodyHdr.Set("Content-Type", mimeTypeWithBoundary("multipart/related", relatedBoundary(relatedBytes))) + } else { + bodyHdr.Set("Content-Type", "multipart/alternative; boundary="+altBoundary) + } + bodyPart, err := mixed.CreatePart(bodyHdr) + if err != nil { + return nil, fmt.Errorf("mailx/smtp: create mixed body part: %w", err) + } + content := relatedBytes + if !hasInline { + content = altBytes + } + if _, err := bodyPart.Write(content); err != nil { + return nil, fmt.Errorf("mailx/smtp: write mixed body: %w", err) + } + } + for _, att := range msg.Attachments { + name, data, err := s.readAttachment(att) + if err != nil { + return nil, err + } + attHdr := textproto.MIMEHeader{} + attHdr.Set("Content-Type", "application/octet-stream; name="+encodeHeader(name)) + attHdr.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) + attHdr.Set("Content-Transfer-Encoding", "base64") + part, err := mixed.CreatePart(attHdr) + if err != nil { + return nil, fmt.Errorf("mailx/smtp: create attachment part: %w", err) + } + bw := base64.NewEncoder(base64.StdEncoding, part) + if _, err := bw.Write(data); err != nil { + return nil, fmt.Errorf("mailx/smtp: write attachment: %w", err) + } + if err := bw.Close(); err != nil { + return nil, fmt.Errorf("mailx/smtp: close attachment: %w", err) + } + } + if err := mixed.Close(); err != nil { + return nil, fmt.Errorf("mailx/smtp: close mixed: %w", err) + } + bodyBytes = mbuf.Bytes() + header.Set("Content-Type", "multipart/mixed; boundary="+mixed.Boundary()) + case hasInline: + header.Set("Content-Type", mimeTypeWithBoundary("multipart/related", relatedBoundary(relatedBytes))) + bodyBytes = relatedBytes + case hasBody: + header.Set("Content-Type", "multipart/alternative; boundary="+altBoundary) + bodyBytes = altBytes + } + + // 统一先写 headers,再写 body + writeHeaders(buf, header) + buf.Write(bodyBytes) + return buf.Bytes(), nil +} + +// mimeTypeWithBoundary 生成带 boundary 的 Content-Type +func mimeTypeWithBoundary(mediaType, boundary string) string { + return mediaType + "; boundary=" + boundary +} + +// relatedBoundary 从 related 内容中提取 boundary +func relatedBoundary(relatedBytes []byte) string { + for _, line := range bytes.Split(relatedBytes, []byte("\r\n")) { + if after, ok := bytes.CutPrefix(line, []byte("Content-Type: multipart/related; boundary=")); ok { + return string(after) + } + } + return "" +} + +// formatAddressList 将地址列表编码为头部字符串(显示名 + 地址) +func formatAddressList(addrs []string) string { + parts := make([]string, 0, len(addrs)) + for _, a := range addrs { + if s := formatAddressHeader(a); s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, ", ") +} + +// formatAddressHeader 将单个地址(可含显示名)编码为合法头部值 +func formatAddressHeader(s string) string { + a, err := mail.ParseAddress(s) + if err != nil { + // 无法解析时回退为原始值(可能带非 ASCII,做 RFC2047 编码) + return encodeHeader(s) + } + if a.Name == "" { + return a.Address + } + return encodeHeader(a.Name) + " <" + a.Address + ">" +} + +// hasStdHeader 判断是否为邮件标准头(这些由框架统一生成,不允许被自定义头覆盖) +func hasStdHeader(k string) bool { + switch strings.ToLower(k) { + case "from", "to", "cc", "bcc", "subject", "date", "reply-to", "mime-version", "content-type": + return true + } + return false +} + +// writeAlternative 将正文以 text/plain + text/html 写入 alternative 结构 +func writeAlternative(mp *multipart.Writer, msg *mailx.Message) error { + // 纯文本部分 + if msg.TextBody != "" { + th := textproto.MIMEHeader{} + th.Set("Content-Type", "text/plain; charset=UTF-8") + th.Set("Content-Transfer-Encoding", "quoted-printable") + part, err := mp.CreatePart(th) + if err != nil { + return fmt.Errorf("mailx/smtp: create text part: %w", err) + } + qp := quotedprintable.NewWriter(part) + if _, err := qp.Write([]byte(msg.TextBody)); err != nil { + return fmt.Errorf("mailx/smtp: write text: %w", err) + } + if err := qp.Close(); err != nil { + return fmt.Errorf("mailx/smtp: close text: %w", err) + } + } + // HTML 部分 + if msg.Body != "" { + hh := textproto.MIMEHeader{} + hh.Set("Content-Type", "text/html; charset=UTF-8") + hh.Set("Content-Transfer-Encoding", "quoted-printable") + part, err := mp.CreatePart(hh) + if err != nil { + return fmt.Errorf("mailx/smtp: create html part: %w", err) + } + qp := quotedprintable.NewWriter(part) + if _, err := qp.Write([]byte(msg.Body)); err != nil { + return fmt.Errorf("mailx/smtp: write html: %w", err) + } + if err := qp.Close(); err != nil { + return fmt.Errorf("mailx/smtp: close html: %w", err) + } + } + if err := mp.Close(); err != nil { + return fmt.Errorf("mailx/smtp: close alternative: %w", err) + } + return nil +} + +func writeHeaders(buf *bytes.Buffer, hdr textproto.MIMEHeader) { + // 固定顺序输出标准头,保证可读性 + std := []string{"Date", "From", "To", "Cc", "Bcc", "Subject", "Reply-To", "MIME-Version", "Content-Type"} + written := make(map[string]bool, len(std)) + for _, k := range std { + if v := hdr.Values(k); len(v) > 0 { + buf.WriteString(k + ": " + v[0] + "\r\n") + written[k] = true + } + } + // 剩余的自定义头(按 key 排序保证稳定输出) + var extra []string + for k := range hdr { + if !written[k] && !hasStdHeader(k) { + extra = append(extra, k) + } + } + sort.Strings(extra) + for _, k := range extra { + if v := hdr.Values(k); len(v) > 0 { + buf.WriteString(k + ": " + v[0] + "\r\n") + } + } + buf.WriteString("\r\n") +} + +// readAttachment 解析附件(路径或内存字节) +func (s *Smtp) readAttachment(att mailx.Attachment) (string, []byte, error) { + if len(att.Data) > 0 { + name := att.Name + if name == "" { + name = "attachment" + } + return name, att.Data, nil + } + if att.Path != "" { + data, err := os.ReadFile(att.Path) + if err != nil { + return "", nil, fmt.Errorf("mailx/smtp: read attachment %q: %w", att.Path, err) + } + name := att.Name + if name == "" { + name = baseName(att.Path) + } + return name, data, nil + } + return "", nil, errors.New("mailx/smtp: attachment has neither path nor data") +} + +// readInline 解析内嵌图片(路径或内存字节),返回 文件名、数据、MIME 类型 +func (s *Smtp) readInline(inl mailx.InlineImage) (string, []byte, string, error) { + name := inl.Name + mtype := inl.MIMEType + if mtype == "" { + if name != "" { + mtype = mime.TypeByExtension(strings.ToLower(pathExt(name))) + } + if mtype == "" { + mtype = "image/octet-stream" + } + } + + if len(inl.Data) > 0 { + if name == "" { + name = "inline" + } + return name, inl.Data, mtype, nil + } + if inl.Path != "" { + data, err := os.ReadFile(inl.Path) + if err != nil { + return "", nil, "", fmt.Errorf("mailx/smtp: read inline %q: %w", inl.Path, err) + } + if name == "" { + name = baseName(inl.Path) + } + if mtype == "image/octet-stream" { + mtype = mime.TypeByExtension(strings.ToLower(pathExt(inl.Path))) + if mtype == "" { + mtype = "image/octet-stream" + } + } + return name, data, mtype, nil + } + return "", nil, "", errors.New("mailx/smtp: inline image has neither path nor data") +} + +// pathExt 提取路径扩展名(含点),兼容 / 与 \ +func pathExt(p string) string { + p = baseName(p) + if i := strings.LastIndexByte(p, '.'); i >= 0 { + return p[i:] + } + return "" +} + +// firstNonEmpty 返回第一个非空字符串 +func (s *Smtp) firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// encodeHeader 对非 ASCII 的头部字段做 RFC 2047 编码(ASCII 内容原样返回) +func encodeHeader(s string) string { + return mime.QEncoding.Encode("UTF-8", s) +} + +// baseName 取路径中的文件名,兼容 / 与 \ 分隔符 +func baseName(p string) string { + if i := strings.LastIndexAny(p, `/\`); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/smtp/smtp_internal_test.go b/smtp/smtp_internal_test.go new file mode 100644 index 0000000..787a08e --- /dev/null +++ b/smtp/smtp_internal_test.go @@ -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("

    hi

    "). + ReplyTo("msg-reply@example.com"). + AttachBytes("a.txt", []byte("content")). + Build() + + data, err := s.buildMIME(msg, "sender@example.com") + if err != nil { + t.Fatal(err) + } + out := string(data) + + for _, want := range []string{ + "From: sender@example.com", + "To: to@example.com", + "Cc: cc@example.com", + "Reply-To: msg-reply@example.com", + "MIME-Version: 1.0", + "multipart/mixed", + "text/html; charset=UTF-8", + "attachment; filename", + "Y29udGVudA==", // base64("content") + } { + if !strings.Contains(out, want) { + t.Errorf("MIME missing %q, got:\n%s", want, out) + } + } +} + +func TestBuildMIMEWithoutBody(t *testing.T) { + s := New(Config{}) + msg := mailx.NewMessage(). + To("to@example.com"). + Subject("s"). + AttachBytes("a.txt", []byte("x")). + Build() + + data, err := s.buildMIME(msg, "f@example.com") + if err != nil { + t.Fatal(err) + } + out := string(data) + if strings.Contains(out, "text/html") { + t.Errorf("should not contain html part, got:\n%s", out) + } + if !strings.Contains(out, "multipart/mixed") { + t.Errorf("should contain multipart/mixed for attachment, got:\n%s", out) + } +} + +func TestBuildMIMEFromFallback(t *testing.T) { + // Message.From 为空时回退到 Config.From + s := New(Config{From: "cfg-from@example.com"}) + msg := mailx.NewMessage().To("t@e.com").Subject("s").Build() + data, err := s.buildMIME(msg, "cfg-from@example.com") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "From: cfg-from@example.com") { + t.Fatalf("From header missing, got:\n%s", data) + } +} + +func TestReadAttachment(t *testing.T) { + s := New(Config{}) + + // 按路径 + dir := t.TempDir() + p := filepath.Join(dir, "x.txt") + if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil { + t.Fatal(err) + } + name, data, err := s.readAttachment(mailx.Attachment{Path: p}) + if err != nil { + t.Fatal(err) + } + if name != "x.txt" || string(data) != "abc" { + t.Errorf("name=%q data=%q", name, data) + } + + // 内存字节 + name, data, err = s.readAttachment(mailx.Attachment{Name: "m.bin", Data: []byte{1, 2}}) + if err != nil { + t.Fatal(err) + } + if name != "m.bin" || !bytes.Equal(data, []byte{1, 2}) { + t.Errorf("name=%q data=%v", name, data) + } + + // 空附件报错 + if _, _, err := s.readAttachment(mailx.Attachment{}); err == nil { + t.Fatal("empty attachment should error") + } + + // 路径不存在报错 + if _, _, err := s.readAttachment(mailx.Attachment{Path: filepath.Join(dir, "nope.txt")}); err == nil { + t.Fatal("missing file should error") + } +} + +func TestEncodeHeader(t *testing.T) { + if got := encodeHeader("plain"); got != "plain" { + t.Errorf("encodeHeader(plain) = %q", got) + } + got := encodeHeader("主题") + if !strings.HasPrefix(got, "=?UTF-8?q?") || !strings.HasSuffix(got, "?=") { + t.Errorf("encodeHeader(主题) = %q, want RFC 2047 encoded", got) + } +} + +func TestEffectiveEncryption(t *testing.T) { + cases := []struct { + name string + cfg Config + want Encryption + }{ + {"default 587 -> tls", Config{Host: "h", Port: 587}, EncryptionTLS}, + {"default 465 -> ssl", Config{Host: "h", Port: 465}, EncryptionSSL}, + {"default 25 -> tls", Config{Host: "h", Port: 25}, EncryptionTLS}, + {"explicit ssl", Config{Host: "h", Port: 587, Encryption: EncryptionSSL}, EncryptionSSL}, + {"explicit tls", Config{Host: "h", Port: 465, Encryption: EncryptionTLS}, EncryptionTLS}, + {"explicit none", Config{Host: "h", Port: 465, Encryption: EncryptionNone}, EncryptionNone}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := New(c.cfg).effectiveEncryption(); got != c.want { + t.Errorf("effectiveEncryption() = %q, want %q", got, c.want) + } + }) + } +} + +func TestBuildMIMEInlineImage(t *testing.T) { + s := New(Config{}) + msg := mailx.NewMessage(). + From(`"张三" `). + To("to@example.com"). + Subject("s"). + HTML(`

    hi

    `). + InlineImageBytes("logo1", "logo.png", []byte{0x89, 0x50, 0x4e, 0x47}). + Build() + + data, err := s.buildMIME(msg, `"张三" `) + if err != nil { + t.Fatal(err) + } + out := string(data) + + // 显示名应被 RFC2047 编码 + if !strings.Contains(out, "From: =?UTF-8?q?") { + t.Errorf("display name not encoded, got:\n%s", out) + } + // related 结构 + 内嵌图片 + if !strings.Contains(out, "multipart/related") { + t.Errorf("missing multipart/related, got:\n%s", out) + } + // textproto 会将 Content-ID 规范化为 Content-Id(RFC 允许,客户端能正确解析) + if !strings.Contains(out, "Content-Id: ") && !strings.Contains(out, "Content-ID: ") { + t.Errorf("missing Content-ID, got:\n%s", out) + } + if !strings.Contains(out, "inline; filename") { + t.Errorf("missing inline disposition, got:\n%s", out) + } + // png 签名 base64(0x89 0x50 0x4e 0x47 -> iVBORw==) + if !strings.Contains(out, "iVBORw==") { + t.Errorf("png data missing, got:\n%s", out) + } +} + +func TestBuildMIMECustomHeaders(t *testing.T) { + s := New(Config{}) + msg := mailx.NewMessage(). + To("t@e.com"). + Subject("s"). + Header("List-Unsubscribe", ""). // 自定义头 + Header("X-Mailer", "mailx"). + Header("Subject", "should-not-override"). // 标准头,不应覆盖 + Build() + + data, err := s.buildMIME(msg, "f@e.com") + if err != nil { + t.Fatal(err) + } + out := string(data) + + if !strings.Contains(out, "List-Unsubscribe: ") { + t.Errorf("missing custom header List-Unsubscribe, got:\n%s", out) + } + if !strings.Contains(out, "X-Mailer: mailx") { + t.Errorf("missing custom header X-Mailer, got:\n%s", out) + } + // 标准头 Subject 不应被自定义值覆盖 + if strings.Contains(out, "Subject: should-not-override") { + t.Errorf("custom header overrode standard Subject, got:\n%s", out) + } + if !strings.Contains(out, "Subject: s") { + t.Errorf("standard Subject lost, got:\n%s", out) + } +} + +func TestReadInline(t *testing.T) { + s := New(Config{}) + + // 按路径读取,MIME 类型由扩展名推断 + dir := t.TempDir() + p := filepath.Join(dir, "img.png") + if err := os.WriteFile(p, []byte{0x89, 0x50, 0x4e, 0x47}, 0o600); err != nil { + t.Fatal(err) + } + name, data, mtype, err := s.readInline(mailx.InlineImage{CID: "cid1", Path: p}) + if err != nil { + t.Fatal(err) + } + if name != "img.png" || string(data) != string([]byte{0x89, 0x50, 0x4e, 0x47}) { + t.Errorf("path inline: name=%q data=%v", name, data) + } + if mtype != "image/png" { + t.Errorf("mtype = %q, want image/png", mtype) + } + + // 内存字节 + 显式 MIME 类型 + name, data, mtype, err = s.readInline(mailx.InlineImage{CID: "c2", Name: "x.jpg", Data: []byte{1}, MIMEType: "image/jpeg"}) + if err != nil { + t.Fatal(err) + } + if name != "x.jpg" || mtype != "image/jpeg" { + t.Errorf("memory inline: name=%q mtype=%q", name, mtype) + } + + // 空 inline 报错 + if _, _, _, err := s.readInline(mailx.InlineImage{}); err == nil { + t.Fatal("empty inline should error") + } + + // 路径不存在报错 + if _, _, _, err := s.readInline(mailx.InlineImage{CID: "c", Path: filepath.Join(dir, "nope.png")}); err == nil { + t.Fatal("missing inline file should error") + } +} + +func TestFormatAddressHeader(t *testing.T) { + // 纯地址原样 + if got := formatAddressHeader("a@example.com"); got != "a@example.com" { + t.Errorf("plain = %q", got) + } + // 带显示名 + got := formatAddressHeader(`"张三" `) + if !strings.Contains(got, "=?UTF-8?q?") || !strings.Contains(got, "") { + t.Errorf("display name = %q", got) + } + // 非法地址回退为 RFC2047 编码 + if got := formatAddressHeader("not-an-email"); got == "" { + t.Errorf("invalid address should fallback, got empty") + } + // 空字符串 + if got := formatAddressHeader(""); got != "" { + t.Errorf("empty = %q, want empty", got) + } +} + +func TestFormatAddressList(t *testing.T) { + got := formatAddressList([]string{"a@e.com", `"B" `}) + if !strings.Contains(got, "a@e.com") || !strings.Contains(got, "b@e.com") { + t.Errorf("list = %q", got) + } + // 空列表 + if got := formatAddressList(nil); got != "" { + t.Errorf("empty list = %q", got) + } +} + +func TestBaseNameAndPathExt(t *testing.T) { + if got := baseName("/a/b/c.txt"); got != "c.txt" { + t.Errorf("baseName unix = %q", got) + } + if got := baseName(`C:\dir\f.txt`); got != "f.txt" { + t.Errorf("baseName win = %q", got) + } + if got := pathExt("a.png"); got != ".png" { + t.Errorf("pathExt = %q", got) + } + if got := pathExt("noext"); got != "" { + t.Errorf("pathExt noext = %q", got) + } +} + +func TestBuildMIMEAlternative(t *testing.T) { + s := New(Config{}) + msg := mailx.NewMessage(). + To("t@e.com"). + Subject("s"). + Text("纯文本正文"). + HTML("

    html正文

    "). + Build() + + data, err := s.buildMIME(msg, "f@e.com") + if err != nil { + t.Fatal(err) + } + out := string(data) + + if !strings.Contains(out, "multipart/alternative") { + t.Errorf("should use multipart/alternative, got:\n%s", out) + } + if !strings.Contains(out, "text/plain; charset=UTF-8") { + t.Errorf("missing text/plain part, got:\n%s", out) + } + if !strings.Contains(out, "text/html; charset=UTF-8") { + t.Errorf("missing text/html part, got:\n%s", out) + } +} diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go index b71d060..d4c907e 100644 --- a/smtp/smtp_test.go +++ b/smtp/smtp_test.go @@ -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("

    Hello

    "). + 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 .") + 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 +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..48fb9c2 --- /dev/null +++ b/status.go @@ -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 // 错误信息 +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..6711349 --- /dev/null +++ b/util.go @@ -0,0 +1,36 @@ +package mailx + +import ( + "regexp" + "strings" +) + +// htmlTagPattern 匹配 HTML 开标签:< 标签名 [属性...]> +// 不匹配注释、DTD、结束标签( 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, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + 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 +}