// Package aws 提供 Amazon SES 邮件发送通道。 package aws import ( "context" "fmt" "sync" "time" 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 { cfg Config initOnce sync.Once svc sesiface.SESAPI initErr error } // New 创建 AWS 通道 func New(cfg Config) *Aws { if cfg.Region == "" { cfg.Region = "ap-northeast-1" } return &Aws{cfg: cfg} } // Name 返回通道名称 func (a *Aws) Name() string { return "aws" } // 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) } svc, err := a.client() if err != nil { return err } input := &ses.SendEmailInput{ Source: aws.String(sender), Destination: &ses.Destination{ ToAddresses: aws.StringSlice(msg.To), CcAddresses: aws.StringSlice(msg.Cc), BccAddresses: aws.StringSlice(msg.Bcc), }, Message: &ses.Message{ Subject: &ses.Content{Data: aws.String(msg.Subject), Charset: aws.String("UTF-8")}, Body: awsBody(msg), }, } if msg.ReplyTo != "" { input.ReplyToAddresses = aws.StringSlice([]string{msg.ReplyTo}) } 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 } // 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 }