From eb8f660ab522556531b06f357fb4d3c408b091da Mon Sep 17 00:00:00 2001 From: Yun Date: Sat, 15 Aug 2026 01:38:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- address.go | 38 ++ aliyun/aliyun.go | 384 +++++++--------- aliyun/aliyun_internal_test.go | 58 +++ aliyun/aliyun_mock_test.go | 290 ++++++++++++ aliyun/aliyun_test.go | 81 ++-- aws/aws.go | 169 ++++--- aws/aws_internal_test.go | 40 ++ aws/aws_mock_test.go | 183 ++++++++ aws/aws_test.go | 45 +- cmd/main.go | 105 ++++- errors.go | 15 + example_test.go | 73 +++ examples/README.md | 50 ++ examples/advanced/main.go | 111 +++++ examples/basic/main.go | 67 +++ examples/custom_sender/main.go | 80 ++++ examples/manager/main.go | 92 ++++ examples/quickstart/main.go | 51 +++ examples/with_env/.env.example | 31 ++ examples/with_env/main.go | 127 ++++++ from.go | 144 ++++++ go.mod | 22 +- go.sum | 65 +-- html.go | 94 ++-- html_resource_test.go | 71 +++ html_test.go | 5 +- interfaces/consts.go | 76 ---- interfaces/interfaces.go | 85 ---- interfaces/options.go | 56 --- internal_test.go | 20 + logger.go | 67 +++ logger_test.go | 44 ++ mailgun/mailgun.go | 176 ++++++-- mailgun/mailgun_internal_test.go | 28 ++ mailgun/mailgun_mock_test.go | 189 ++++++++ mailgun/mailgun_test.go | 29 +- mailx.go | 56 +-- manager.go | 218 +++++++++ manager_test.go | 486 ++++++++++++++++++++ message.go | 218 +++++++++ message_test.go | 117 +++++ message_util_test.go | 274 +++++++++++ mock_test.go | 49 ++ old/asset/hhh.txt | 1 - old/asset/余额宝.png | Bin 39128 -> 0 bytes old/html.go | 77 ---- old/html_test.go | 60 --- old/mailx.go | 202 --------- old/mailx_test.go | 64 --- readme.md | 313 ++++++++++++- sender.go | 17 + smtp/smtp.go | 752 ++++++++++++++++++++++++------- smtp/smtp_internal_test.go | 330 ++++++++++++++ smtp/smtp_test.go | 282 +++++++++--- status.go | 22 + util.go | 36 ++ 57 files changed, 5401 insertions(+), 1438 deletions(-) create mode 100644 address.go create mode 100644 aliyun/aliyun_internal_test.go create mode 100644 aliyun/aliyun_mock_test.go create mode 100644 aws/aws_internal_test.go create mode 100644 aws/aws_mock_test.go create mode 100644 errors.go create mode 100644 example_test.go create mode 100644 examples/README.md create mode 100644 examples/advanced/main.go create mode 100644 examples/basic/main.go create mode 100644 examples/custom_sender/main.go create mode 100644 examples/manager/main.go create mode 100644 examples/quickstart/main.go create mode 100644 examples/with_env/.env.example create mode 100644 examples/with_env/main.go create mode 100644 from.go create mode 100644 html_resource_test.go delete mode 100644 interfaces/consts.go delete mode 100644 interfaces/interfaces.go delete mode 100644 interfaces/options.go create mode 100644 internal_test.go create mode 100644 logger.go create mode 100644 logger_test.go create mode 100644 mailgun/mailgun_internal_test.go create mode 100644 mailgun/mailgun_mock_test.go create mode 100644 manager.go create mode 100644 manager_test.go create mode 100644 message.go create mode 100644 message_test.go create mode 100644 message_util_test.go create mode 100644 mock_test.go delete mode 100644 old/asset/hhh.txt delete mode 100644 old/asset/余额宝.png delete mode 100644 old/html.go delete mode 100644 old/html_test.go delete mode 100644 old/mailx.go delete mode 100644 old/mailx_test.go create mode 100644 sender.go create mode 100644 smtp/smtp_internal_test.go create mode 100644 status.go create mode 100644 util.go 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 43c8e4aaca2d557149ae76faf402a94464827ab8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39128 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 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 +}