This commit is contained in:
yun
2026-08-15 01:38:05 +08:00
parent 139330aee2
commit eb8f660ab5
57 changed files with 5401 additions and 1438 deletions
+171 -213
View File
@@ -1,260 +1,218 @@
// Package aliyun 提供阿里云邮件推送(DirectMail)通道。
package aliyun
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"code.yun.ink/pkg/mailx/interfaces"
mailx "code.yun.ink/pkg/mailx"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dm20151123 "github.com/alibabacloud-go/dm-20151123/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// defaultTimeout 单次 API 调用的默认超时
const defaultTimeout = 30 * time.Second
// Config 阿里云邮件推送通道配置
type Config struct {
AccessKeyID string // AccessKey ID
AccessKeySecret string // AccessKey Secret
Endpoint string // Endpoint,默认 dm.aliyuncs.com
AccountName string // 发信地址(必填)
ReplyAddress string // 默认回复地址(可选)
Timeout time.Duration // 单次 API 调用的超时,默认 30s
}
// dmMailer 阿里云 DM 客户端的最小接口(便于测试注入 mock)
type dmMailer interface {
SingleSendMailWithOptions(request *dm20151123.SingleSendMailRequest, runtime *util.RuntimeOptions) (*dm20151123.SingleSendMailResponse, error)
SenderStatisticsDetailByParamWithOptions(request *dm20151123.SenderStatisticsDetailByParamRequest, runtime *util.RuntimeOptions) (*dm20151123.SenderStatisticsDetailByParamResponse, error)
}
// Aliyun 阿里云发送通道
type Aliyun struct {
interfaces.DefaultEmail
client *dm20151123.Client
// params *interfaces.EmialConfigDataAliyun
// logger loggerx.LoggerInterface
cfg Config
initOnce sync.Once
client dmMailer
initErr error
}
func NewAliyun() *Aliyun {
aliyun := &Aliyun{}
aliyun.Options = interfaces.DefaultOptions()
aliyun.EmailType = interfaces.EmailTypeAliyun
return aliyun
// New 创建阿里云通道
func New(cfg Config) *Aliyun {
if cfg.Endpoint == "" {
cfg.Endpoint = "dm.aliyuncs.com"
}
return &Aliyun{cfg: cfg}
}
func (l *Aliyun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
// Name 返回通道名称
func (a *Aliyun) Name() string { return "aliyun" }
for _, o := range opt {
o(&l.Options)
// Send 发送一封邮件
func (a *Aliyun) Send(ctx context.Context, msg *mailx.Message) error {
logger := mailx.LoggerFromContext(ctx)
if len(msg.To) > 100 {
return fmt.Errorf("%w: aliyun up to 100 recipients allowed", mailx.ErrInvalidMessage)
}
if a.cfg.AccountName == "" {
return fmt.Errorf("%w: aliyun AccountName is required", mailx.ErrInvalidConfig)
}
if len(msg.Inline) > 0 {
return fmt.Errorf("%w: aliyun SingleSendMail does not support inline images", mailx.ErrInvalidConfig)
}
l.Options.Logger.Infof(ctx, "Aliyun:%+v", l.Options.Aliyun)
if l.Options.Aliyun == nil {
return nil, errors.New("not aliyun")
}
// 工程代码泄露可能会导致 AccessKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考。
// 建议使用更安全的 STS 方式,更多鉴权访问方式请参见:https://help.aliyun.com/document_detail/378661.html。
config := &openapi.Config{
// 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID。
AccessKeyId: tea.String(l.Options.Aliyun.AccessId),
// 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_SECRET。
AccessKeySecret: tea.String(l.Options.Aliyun.AccessKey),
}
if l.Options.Aliyun.Endpoint == "" {
l.Options.Aliyun.Endpoint = "dm.aliyuncs.com"
}
// Endpoint 请参考 https://api.aliyun.com/product/Dm
config.Endpoint = tea.String(l.Options.Aliyun.Endpoint)
result, err := dm20151123.NewClient(config)
client, err := a.newClient()
if err != nil {
return nil, err
return fmt.Errorf("mailx/aliyun: create client: %w", err)
}
return &Aliyun{
client: result,
}, nil
}
func (l *Aliyun) Send(ctx context.Context, params interfaces.Message) error {
if l.client == nil {
return errors.New("client no init")
// 阿里云 DirectMail 的 HtmlBody 必填;纯文本也需以 HTML 形式传递
htmlBody := msg.Body
if htmlBody == "" {
htmlBody = mailx.EscapeHTML(msg.TextBody)
}
if len(params.To) > 100 {
return errors.New("最多 100 个地址")
}
if l.Options.Aliyun.AccountName == "" {
return errors.New("AccountName 必填")
req := &dm20151123.SingleSendMailRequest{
AccountName: tea.String(a.cfg.AccountName),
ToAddress: tea.String(strings.Join(msg.To, ",")),
Subject: tea.String(msg.Subject),
HtmlBody: tea.String(htmlBody),
AddressType: tea.Int32(0), // 0:随机账号;1:发信地址
}
toAddress := strings.Join(params.To, ",")
singleSendMailRequest := &dm20151123.SingleSendMailRequest{}
singleSendMailRequest.AccountName = tea.String(l.Options.Aliyun.AccountName)
singleSendMailRequest.ToAddress = tea.String(toAddress) // 目标地址,多个 email 地址可以用逗号分隔,最多 100 个地址(支持邮件组)。
singleSendMailRequest.Subject = tea.String(params.Subject)
singleSendMailRequest.HtmlBody = tea.String(params.Body)
singleSendMailRequest.AddressType = tea.Int32(0) // 地址类型。取值:0:为随机账号1:为发信地址
if params.ReplyTo != "" {
singleSendMailRequest.ReplyToAddress = tea.Bool(false)
singleSendMailRequest.ReplyAddress = tea.String(params.ReplyTo)
replyTo := msg.ReplyTo
if replyTo == "" {
replyTo = a.cfg.ReplyAddress
}
if replyTo != "" {
req.ReplyToAddress = tea.Bool(false)
req.ReplyAddress = tea.String(replyTo)
} else {
singleSendMailRequest.ReplyToAddress = tea.Bool(true)
req.ReplyToAddress = tea.Bool(true)
}
runtime := &util.RuntimeOptions{}
tryErr := func() (_e error) {
defer func() {
if r := tea.Recover(recover()); r != nil {
_e = r
}
}()
// 复制代码运行请自行打印 API 的返回值
resp, err := l.client.SingleSendMailWithOptions(singleSendMailRequest, runtime)
by, _ := json.Marshal(resp)
fmt.Printf("resp:%+v err:%+v", string(by), err)
l.Options.Logger.Infof(ctx, "resp:%+v err:%+v", resp, err)
if err != nil {
return err
}
return nil
}()
if tryErr != nil {
l.Options.Logger.Errorf(ctx, "err:%+v", tryErr)
return tryErr
// var error = &tea.SDKError{}
// if _t, ok := tryErr.(*tea.SDKError); ok {
// error = _t
// } else {
// error.Message = tea.String(tryErr.Error())
// }
// // 此处仅做打印展示,请谨慎对待异常处理,在工程项目中切勿直接忽略异常。
// // 错误 message
// fmt.Println(tea.StringValue(error.Message))
// // 诊断地址
// var data interface{}
// d := json.NewDecoder(strings.NewReader(tea.StringValue(error.Data)))
// d.Decode(&data)
// if m, ok := data.(map[string]interface{}); ok {
// recommend, _ := m["Recommend"]
// fmt.Println("recommend", recommend)
// }
// _, _err := util.AssertAsString(error.Message)
// if _err != nil {
// return _err
// }
// 将 ctx 的 deadline 折算为超时,同时应用配置超时
runtime := a.runtimeOptions(ctx)
if _, err := client.SingleSendMailWithOptions(req, runtime); err != nil {
logger.Errorf(ctx, "mailx/aliyun: send failed: %v", err)
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
}
return nil // 实现具体的 Aliyun 发送方法
// 如:return aliyunSDK.SendMail(params)
logger.Infof(ctx, "mailx/aliyun: sent to %v subject=%q", msg.To, msg.Subject)
return nil
}
// 同步状态
func (l *Aliyun) SyncStatus(ctx context.Context) (resp []interfaces.EmailSendRecord, err error) {
start := ""
// 一次同步一天的数据
for {
list, next, err := l.getSendStatus(ctx, start)
l.Options.Logger.Infof(ctx, "list:%+v next:%+v err:%+v", list, next, err)
// newClient 惰性创建并复用阿里云 SDK 客户端(线程安全)。
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
func (a *Aliyun) newClient() (dmMailer, error) {
if a.client != nil {
return a.client, a.initErr
}
a.initOnce.Do(func() {
config := &openapi.Config{
AccessKeyId: tea.String(a.cfg.AccessKeyID),
AccessKeySecret: tea.String(a.cfg.AccessKeySecret),
Endpoint: tea.String(a.cfg.Endpoint),
}
c, err := dm20151123.NewClient(config)
if err != nil {
return nil, err
a.initErr = fmt.Errorf("mailx/aliyun: create client: %w", err)
return
}
a.client = c
})
return a.client, a.initErr
}
// runtimeOptions 根据配置与 ctx 计算超时,返回 RuntimeOptions
func (a *Aliyun) runtimeOptions(ctx context.Context) *util.RuntimeOptions {
timeout := a.cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
if dl, ok := ctx.Deadline(); ok {
if remain := time.Until(dl); remain > 0 && remain < timeout {
timeout = remain
}
}
ro := &util.RuntimeOptions{}
if timeout > 0 {
ms := int(timeout / time.Millisecond)
ro.SetConnectTimeout(ms)
ro.SetReadTimeout(ms)
}
return ro
}
// SyncStatus 同步发送状态(阿里云特有能力,非 Sender 接口的一部分)
func (a *Aliyun) SyncStatus(ctx context.Context) ([]mailx.EmailSendRecord, error) {
logger := mailx.LoggerFromContext(ctx)
client, err := a.newClient()
if err != nil {
return nil, fmt.Errorf("mailx/aliyun: create client: %w", err)
}
runtime := a.runtimeOptions(ctx)
var records []mailx.EmailSendRecord
start := ""
// 分页拉取最近一天的数据
for {
req := &dm20151123.SenderStatisticsDetailByParamRequest{
StartTime: tea.String(time.Now().AddDate(0, 0, -1).Format("2006-01-02 15:04")),
EndTime: tea.String(time.Now().Format("2006-01-02 15:04")),
Length: tea.Int32(100),
}
if start != "" {
req.NextStart = tea.String(start)
}
for _, val := range list {
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(val.LastUpdateTime), time.Local)
// 0:成功 2:无效地址 3:垃圾邮件 4:失败
record := interfaces.EmailSendRecord{
AccountName: tea.StringValue(val.AccountName),
UpdateTime: t.UnixMilli(),
ToUser: tea.StringValue(val.ToAddress),
Subject: tea.StringValue(val.Subject),
ErrorMessage: tea.StringValue(val.Message),
}
switch tea.Int32Value(val.Status) {
case 0:
record.Status = interfaces.EmailSendStatusSuccess
case 2:
record.Status = interfaces.EmailSendStatusInvalidAddress
case 3:
record.Status = interfaces.EmailSendStatusSpam
case 4:
record.Status = interfaces.EmailSendStatusFailed
default:
record.Status = interfaces.EmailSendStatusUnknown
}
resp = append(resp, record)
resp, err := client.SenderStatisticsDetailByParamWithOptions(req, runtime)
if err != nil {
logger.Errorf(ctx, "mailx/aliyun: sync status failed: %v", err)
return nil, fmt.Errorf("mailx/aliyun: sync status: %w", err)
}
if next == nil || len(*next) == 0 {
if resp == nil || resp.Body == nil || resp.Body.Data == nil {
break
}
start = *next
}
return resp, nil
}
func (l *Aliyun) getSendStatus(ctx context.Context, start string) (list []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail, nextStart *string, err error) {
now := time.Now().Local()
senderStatisticsDetailByParamRequest := &dm20151123.SenderStatisticsDetailByParamRequest{
StartTime: tea.String(now.AddDate(0, 0, -1).Format("2006-01-02 15:04")),
EndTime: tea.String(now.Format("2006-01-02 15:04")),
Length: tea.Int32(100),
}
if start != "" {
senderStatisticsDetailByParamRequest.NextStart = tea.String(start)
}
runtime := &util.RuntimeOptions{}
tryErr := func() (_e error) {
defer func() {
if r := tea.Recover(recover()); r != nil {
_e = r
}
}()
// 复制代码运行请自行打印 API 的返回值
resp, _err := l.client.SenderStatisticsDetailByParamWithOptions(senderStatisticsDetailByParamRequest, runtime)
if _err != nil {
l.Options.Logger.Errorf(ctx, "resp:%+v err:%+v", resp, _err)
return _err
for _, d := range resp.Body.Data.MailDetail {
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(d.LastUpdateTime), time.Local)
records = append(records, mailx.EmailSendRecord{
AccountName: tea.StringValue(d.AccountName),
UpdateTime: t.UnixMilli(),
ToUser: tea.StringValue(d.ToAddress),
Subject: tea.StringValue(d.Subject),
ErrorMessage: tea.StringValue(d.Message),
Status: mapSendStatus(tea.Int32Value(d.Status)),
})
}
if resp == nil || resp.Body == nil || resp.Body.Data == nil {
return errors.New("resp.Body.Data is nil")
if resp.Body.NextStart == nil || *resp.Body.NextStart == "" {
break
}
start = *resp.Body.NextStart
}
return records, nil
}
list = resp.Body.Data.MailDetail
nextStart = resp.Body.NextStart
return nil
}()
if tryErr != nil {
l.Options.Logger.Errorf(ctx, "err:%+v", tryErr)
return nil, nil, tryErr
// var error = &tea.SDKError{}
// if _t, ok := tryErr.(*tea.SDKError); ok {
// error = _t
// } else {
// error.Message = tea.String(tryErr.Error())
// }
// // 此处仅做打印展示,请谨慎对待异常处理,在工程项目中切勿直接忽略异常。
// // 错误 message
// fmt.Println(tea.StringValue(error.Message))
// // 诊断地址
// var data interface{}
// d := json.NewDecoder(strings.NewReader(tea.StringValue(error.Data)))
// d.Decode(&data)
// if m, ok := data.(map[string]interface{}); ok {
// recommend, _ := m["Recommend"]
// fmt.Println("recommend:", recommend)
// }
// _, _err := util.AssertAsString(error.Message)
// if _err != nil {
// l.logger.Errorf(ctx, "resp:%+v err:%+v", error.Message, _err)
// return nil, nil, _err
// }
// mapSendStatus 阿里云状态码映射为统一状态
func mapSendStatus(code int32) mailx.EmailSendStatus {
switch code {
case 0:
return mailx.EmailSendStatusSuccess
case 2:
return mailx.EmailSendStatusInvalidAddress
case 3:
return mailx.EmailSendStatusSpam
case 4:
return mailx.EmailSendStatusFailed
default:
return mailx.EmailSendStatusUnknown
}
return list, nextStart, nil
}
+58
View File
@@ -0,0 +1,58 @@
package aliyun
import (
"context"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
var _ mailx.Sender = (*Aliyun)(nil)
func TestConfigDefaults(t *testing.T) {
a := New(Config{})
if a.cfg.Endpoint != "dm.aliyuncs.com" {
t.Errorf("default endpoint = %q, want dm.aliyuncs.com", a.cfg.Endpoint)
}
// 显式指定 Endpoint 时不应被覆盖
a = New(Config{Endpoint: "dm.eu-central-1.aliyuncs.com"})
if a.cfg.Endpoint != "dm.eu-central-1.aliyuncs.com" {
t.Errorf("explicit endpoint overwritten: %q", a.cfg.Endpoint)
}
}
func TestName(t *testing.T) {
if got := New(Config{}).Name(); got != "aliyun" {
t.Errorf("Name() = %q, want aliyun", got)
}
}
func TestMapSendStatus(t *testing.T) {
cases := []struct {
in int32
want mailx.EmailSendStatus
}{
{0, mailx.EmailSendStatusSuccess},
{2, mailx.EmailSendStatusInvalidAddress},
{3, mailx.EmailSendStatusSpam},
{4, mailx.EmailSendStatusFailed},
{9, mailx.EmailSendStatusUnknown},
}
for _, c := range cases {
if got := mapSendStatus(c.in); got != c.want {
t.Errorf("mapSendStatus(%d) = %v, want %v", c.in, got, c.want)
}
}
}
func TestSendWithoutConfig(t *testing.T) {
// 缺少 AccountName 时应在发送前报错,而不是触发网络请求
a := New(Config{AccessKeyID: "ak", AccessKeySecret: "sk"})
err := a.Send(context.Background(), mailx.NewMessage().
To("a@b.com").
Subject("s").
Build())
if err == nil {
t.Fatal("Send without AccountName should error")
}
}
+290
View File
@@ -0,0 +1,290 @@
package aliyun
import (
"context"
"errors"
"testing"
"time"
mailx "code.yun.ink/pkg/mailx"
dm20151123 "github.com/alibabacloud-go/dm-20151123/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// mockDM 实现 dmMailer 接口,记录请求并可按需返回错误
type mockDM struct {
sendErr error
statsErr error
lastSend *dm20151123.SingleSendMailRequest
lastStats *dm20151123.SenderStatisticsDetailByParamRequest
// statsPages 模拟分页返回;nil 时返回单个空页
statsPages []*dm20151123.SenderStatisticsDetailByParamResponse
}
func (m *mockDM) SingleSendMailWithOptions(req *dm20151123.SingleSendMailRequest, _ *util.RuntimeOptions) (*dm20151123.SingleSendMailResponse, error) {
m.lastSend = req
if m.sendErr != nil {
return nil, m.sendErr
}
return &dm20151123.SingleSendMailResponse{}, nil
}
func (m *mockDM) SenderStatisticsDetailByParamWithOptions(req *dm20151123.SenderStatisticsDetailByParamRequest, _ *util.RuntimeOptions) (*dm20151123.SenderStatisticsDetailByParamResponse, error) {
m.lastStats = req
if m.statsErr != nil {
return nil, m.statsErr
}
if len(m.statsPages) > 0 {
resp := m.statsPages[0]
m.statsPages = m.statsPages[1:]
return resp, nil
}
return &dm20151123.SenderStatisticsDetailByParamResponse{Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{}}, nil
}
// TestSendSuccess 验证完整发送流程与请求构造
func TestSendSuccess(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com", ReplyAddress: "r@example.com"}, client: mock}
msg := mailx.NewMessage().
To("a@example.com", "b@example.com").
Subject("hi").
HTML("<b>html</b>").
Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
req := mock.lastSend
if req == nil {
t.Fatal("no request captured")
}
if tea.StringValue(req.AccountName) != "noreply@example.com" {
t.Errorf("AccountName = %q", tea.StringValue(req.AccountName))
}
if tea.StringValue(req.ToAddress) != "a@example.com,b@example.com" {
t.Errorf("ToAddress = %q", tea.StringValue(req.ToAddress))
}
if tea.StringValue(req.Subject) != "hi" {
t.Errorf("Subject = %q", tea.StringValue(req.Subject))
}
if tea.StringValue(req.HtmlBody) != "<b>html</b>" {
t.Errorf("HtmlBody = %q", tea.StringValue(req.HtmlBody))
}
if tea.BoolValue(req.ReplyToAddress) {
t.Errorf("ReplyToAddress = %v, want false (uses custom ReplyAddress)", tea.BoolValue(req.ReplyToAddress))
}
if tea.StringValue(req.ReplyAddress) != "r@example.com" {
t.Errorf("ReplyAddress = %q", tea.StringValue(req.ReplyAddress))
}
}
// TestSendTextOnly 验证纯文本正文被转义为 HTML
func TestSendTextOnly(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Text("a < b & c").Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
got := tea.StringValue(mock.lastSend.HtmlBody)
if got != "a &lt; b &amp; c" {
t.Errorf("escaped HtmlBody = %q", got)
}
}
// TestSendMsgReplyTo 验证 msg.ReplyTo 优先于 cfg.ReplyAddress
func TestSendMsgReplyTo(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com", ReplyAddress: "cfg@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").ReplyTo("msg@example.com").Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if tea.StringValue(mock.lastSend.ReplyAddress) != "msg@example.com" {
t.Errorf("ReplyAddress = %q, want msg@example.com", tea.StringValue(mock.lastSend.ReplyAddress))
}
}
// TestSendNoReplyTo 验证无回复地址时 ReplyToAddress=true
func TestSendNoReplyTo(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
if err := a.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if !tea.BoolValue(mock.lastSend.ReplyToAddress) {
t.Errorf("ReplyToAddress = %v, want true", tea.BoolValue(mock.lastSend.ReplyToAddress))
}
}
// TestSendTooManyRecipients 验证超过 100 收件人报错
func TestSendTooManyRecipients(t *testing.T) {
mock := &mockDM{}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
to := make([]string, 101)
for i := range to {
to[i] = "u@example.com"
}
msg := mailx.NewMessage().To(to...).Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidMessage) {
t.Fatalf("err = %v, want ErrInvalidMessage", err)
}
}
// TestSendNoAccount 验证缺少 AccountName 报错
func TestSendNoAccount(t *testing.T) {
a := &Aliyun{cfg: Config{}, client: &mockDM{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendInlineError 验证内嵌图片报错
func TestSendInlineError(t *testing.T) {
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: &mockDM{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").
InlineImageBytes("cid1", "a.png", []byte{1}).
Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendFail 验证发送失败包装为 ErrSendFailed
func TestSendFail(t *testing.T) {
mock := &mockDM{sendErr: errors.New("aliyun down")}
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := a.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
t.Fatalf("err = %v, want ErrSendFailed", err)
}
}
// TestRuntimeOptions 验证超时计算
func TestRuntimeOptions(t *testing.T) {
// 默认超时 30s
a := &Aliyun{cfg: Config{}}
ro := a.runtimeOptions(context.Background())
if ro == nil {
t.Fatal("runtime options nil")
}
// 配置超时 1s
a = &Aliyun{cfg: Config{Timeout: time.Second}}
ro = a.runtimeOptions(context.Background())
// ctx 已有更早 deadline
early, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
ro = a.runtimeOptions(early)
_ = ro // 只要不 panic 即可
}
// TestSyncStatusSuccess 验证状态同步记录映射
func TestSyncStatusSuccess(t *testing.T) {
mock := &mockDM{
statsPages: []*dm20151123.SenderStatisticsDetailByParamResponse{
{
Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{
Data: &dm20151123.SenderStatisticsDetailByParamResponseBodyData{
MailDetail: []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail{
{
AccountName: tea.String("noreply@example.com"),
ToAddress: tea.String("a@example.com"),
Subject: tea.String("hi"),
Status: tea.Int32(0),
Message: tea.String(""),
LastUpdateTime: tea.String("2026-08-15T10:00Z"),
},
{
AccountName: tea.String("noreply@example.com"),
ToAddress: tea.String("b@example.com"),
Subject: tea.String("bye"),
Status: tea.Int32(4),
Message: tea.String("rejected"),
LastUpdateTime: tea.String("2026-08-15T11:00Z"),
},
},
},
NextStart: tea.String("page2"),
},
},
{
Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{
Data: &dm20151123.SenderStatisticsDetailByParamResponseBodyData{
MailDetail: []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail{
{
ToAddress: tea.String("c@example.com"),
Subject: tea.String("page2 mail"),
Status: tea.Int32(2),
},
},
},
},
},
},
}
a := &Aliyun{cfg: Config{}, client: mock}
records, err := a.SyncStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if len(records) != 3 {
t.Fatalf("records len = %d, want 3 (pagination)", len(records))
}
if records[0].Status != mailx.EmailSendStatusSuccess {
t.Errorf("records[0].Status = %v, want Success", records[0].Status)
}
if records[1].Status != mailx.EmailSendStatusFailed {
t.Errorf("records[1].Status = %v, want Failed", records[1].Status)
}
if records[1].ErrorMessage != "rejected" {
t.Errorf("records[1].ErrorMessage = %q", records[1].ErrorMessage)
}
if records[2].Status != mailx.EmailSendStatusInvalidAddress {
t.Errorf("records[2].Status = %v, want InvalidAddress", records[2].Status)
}
// 验证第二页请求带 NextStart
if mock.lastStats == nil || tea.StringValue(mock.lastStats.NextStart) != "page2" {
t.Errorf("second page NextStart = %v", mock.lastStats)
}
}
// TestSyncStatusFail 验证状态同步失败包装错误
func TestSyncStatusFail(t *testing.T) {
mock := &mockDM{statsErr: errors.New("api error")}
a := &Aliyun{cfg: Config{}, client: mock}
if _, err := a.SyncStatus(context.Background()); err == nil {
t.Fatal("SyncStatus should error")
}
}
// TestNewClientReuse 验证 client 惰性复用(只初始化一次)
func TestNewClientReuse(t *testing.T) {
a := New(Config{AccessKeyID: "ak", AccessKeySecret: "sk", AccountName: "n@e.com"})
c1, err := a.newClient()
if err != nil {
t.Fatal(err)
}
c2, err := a.newClient()
if err != nil {
t.Fatal(err)
}
if c1 != c2 {
t.Error("client should be reused")
}
}
+30 -51
View File
@@ -5,73 +5,52 @@ import (
"os"
"testing"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/aliyun"
"code.yun.ink/pkg/mailx/interfaces"
)
func TestSend(t *testing.T) {
aliyun := aliyun.NewAliyun()
ctx := context.Background()
ali, err := aliyun.SetOption(ctx, interfaces.SetAliyun(&interfaces.EmialConfigDataAliyun{
AccessId: "LTAI5tEQ8L8fmDir8udD3CFr",
AccessKey: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
Endpoint: "dm.aliyuncs.com",
AccountName: "test@email.aisz.org", //"test@email.aisz.org",
ReplyAddress: "287852692@qq.com",
}))
if err != nil {
t.Fatal(err)
if testing.Short() {
t.Skip("skip real send in short mode")
}
by, err := os.ReadFile("./assets/zh_Hant.html")
if err != nil {
t.Fatal(err)
}
t.Log(string(by))
err = ali.Send(ctx, interfaces.Message{
To: []string{"995116474@qq.com"},
Subject: "测试主题",
Body: string(by),
client := aliyun.New(aliyun.Config{
AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
Endpoint: "dm.aliyuncs.com",
AccountName: "test@email.aisz.org",
ReplyAddress: "287852692@qq.com",
})
ctx := context.Background()
body, err := os.ReadFile("./assets/zh_Hant.html")
if err != nil {
t.Fatal(err)
}
t.Log(string(body))
err = client.Send(ctx, mailx.NewMessage().
To("995116474@qq.com").
Subject("测试主题").
Body(string(body)).
Build())
if err != nil {
t.Fatal("resp err", err)
}
t.Log("send success")
}
// func TestSyncStatus(t *testing.T) {
// aliyun := &aliyun.Aliyun{}
// ctx := context.Background()
// global.Logger = loggerx.NewLogger(ctx)
// ali, err := aliyun.InitEmail(ctx, interfaces.EmailConfigData{
// Aliyun: &interfaces.EmialConfigDataAliyun{
// AccessId: "LTAI5tEQ8L8fmDir8udD3CFr",
// AccessKey: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
// Endpoint: "dm.aliyuncs.com",
// AccountName: "test@email.aisz.org",
// ReplyAddress: "287852692@qq.com",
// },
// client := aliyun.New(aliyun.Config{
// AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
// AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
// Endpoint: "dm.aliyuncs.com",
// AccountName: "test@email.aisz.org",
// ReplyAddress: "287852692@qq.com",
// })
// ctx := context.Background()
// list, err := client.SyncStatus(ctx)
// if err != nil {
// t.Fatal(err)
// }
// list, err := ali.SyncStatus(ctx)
// if err != nil {
// t.Fatal(err)
// }
// _ = list
// // global.Logger.Infof(ctx, "status: %v", list)
// t.Log("status:", list)
// }