更新
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user