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
}