更新
This commit is contained in:
+108
-61
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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("<b>html</b>").
|
||||
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: "<b>hi</b>"})
|
||||
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: "<b>hi</b>", 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()
|
||||
}
|
||||
+16
-29
@@ -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: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l",
|
||||
Region: "ap-northeast-1",
|
||||
Sender: "chenlihan@dreaminglife.cn",
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
ini, err := a.SetOption(ctx, interfaces.SetAws(&interfaces.EmailConfigDataAws{
|
||||
AccessId: "AKIAU6GD3MNRHKR4RZG5",
|
||||
AccessSecret: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l",
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user