更新
This commit is contained in:
+171
-213
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 < 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")
|
||||
}
|
||||
}
|
||||
+30
-51
@@ -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)
|
||||
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user