Files
mailx/mailgun/mailgun.go
T
2026-08-15 01:38:05 +08:00

163 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mailgun 提供 Mailgun 邮件发送通道。
package mailgun
import (
"bytes"
"context"
"fmt"
"io"
"os"
"sync"
"time"
mailx "code.yun.ink/pkg/mailx"
"github.com/mailgun/mailgun-go/v4"
)
// defaultTimeout 单次 API 调用的默认超时
const defaultTimeout = 30 * time.Second
// Config Mailgun 通道配置
type Config struct {
APIKey string // Mailgun API Key
Domain string // Mailgun Domain
Sender string // 默认发件人(可选,Message.From 优先)
Timeout time.Duration // 单次 API 调用的超时,默认 30s;0 表示交给 ctx 控制
}
// mgClient Mailgun 客户端的最小接口(便于测试注入 mock)
type mgClient interface {
NewMessage(from, subject, text string, to ...string) *mailgun.Message
Send(ctx context.Context, m *mailgun.Message) (string, string, error)
}
// MailGun Mailgun 发送通道
type MailGun struct {
cfg Config
initOnce sync.Once
client mgClient
}
// New 创建 Mailgun 通道
func New(cfg Config) *MailGun {
return &MailGun{cfg: cfg}
}
// Name 返回通道名称
func (g *MailGun) Name() string { return "mailgun" }
// Send 发送一封邮件
func (g *MailGun) Send(ctx context.Context, msg *mailx.Message) error {
logger := mailx.LoggerFromContext(ctx)
sender := msg.From
if sender == "" {
sender = g.cfg.Sender
}
if sender == "" {
return fmt.Errorf("%w: mailgun sender is required", mailx.ErrInvalidConfig)
}
if g.cfg.Domain == "" || g.cfg.APIKey == "" {
return fmt.Errorf("%w: mailgun domain and api key are required", mailx.ErrInvalidConfig)
}
mg := g.getClient()
text := msg.TextBody
if text == "" && !mailx.IsHTML(msg.Body) {
text = msg.Body
}
m := mg.NewMessage(sender, msg.Subject, text, msg.To...)
if msg.Body != "" {
m.SetHtml(msg.Body)
}
if msg.ReplyTo != "" {
m.SetReplyTo(msg.ReplyTo)
}
for _, cc := range msg.Cc {
m.AddCC(cc)
}
for _, bcc := range msg.Bcc {
m.AddBCC(bcc)
}
for _, att := range msg.Attachments {
if len(att.Data) > 0 {
name := att.Name
if name == "" {
name = "attachment"
}
m.AddBufferAttachment(name, att.Data)
continue
}
if att.Path != "" {
if _, err := os.Stat(att.Path); err != nil {
return fmt.Errorf("mailx/mailgun: attachment %q: %w", att.Path, err)
}
m.AddAttachment(att.Path)
continue
}
return fmt.Errorf("mailx/mailgun: attachment has neither path nor data (name=%q)", att.Name)
}
// 内嵌图片(HTML 中用 <img src="cid:...">mailgun 以 filename 作为 CID
for _, inl := range msg.Inline {
if len(inl.Data) > 0 {
m.AddReaderInline(inl.CID, nopCloser{bytes.NewReader(inl.Data)})
continue
}
if inl.Path != "" {
if _, err := os.Stat(inl.Path); err != nil {
return fmt.Errorf("mailx/mailgun: inline %q: %w", inl.Path, err)
}
// AddInline 以文件路径为参数,其 CID 由文件名推导;
// 因此这里将 CID 写入同名的临时文件不可行,改用 ReaderInline 读取路径
f, err := os.Open(inl.Path)
if err != nil {
return fmt.Errorf("mailx/mailgun: open inline %q: %w", inl.Path, err)
}
m.AddReaderInline(inl.CID, f)
continue
}
return fmt.Errorf("mailx/mailgun: inline image has neither path nor data (cid=%q)", inl.CID)
}
// 超时兜底:mailgun-go 的 Send 已接受 ctx,这里叠加配置超时
ctx, cancel := g.withTimeout(ctx)
defer cancel()
if _, _, err := mg.Send(ctx, m); err != nil {
logger.Errorf(ctx, "mailx/mailgun: send to %v failed: %v", msg.To, err)
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
}
logger.Infof(ctx, "mailx/mailgun: sent to %v subject=%q", msg.To, msg.Subject)
return nil
}
// getClient 惰性创建并复用 Mailgun 客户端(线程安全)。
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
func (g *MailGun) getClient() mgClient {
if g.client != nil {
return g.client
}
g.initOnce.Do(func() {
g.client = mailgun.NewMailgun(g.cfg.Domain, g.cfg.APIKey)
})
return g.client
}
// withTimeout 叠加配置超时到 ctx(已有更早 deadline 时保持不变)
func (g *MailGun) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
if g.cfg.Timeout <= 0 {
return ctx, func() {}
}
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= g.cfg.Timeout {
return ctx, func() {}
}
return context.WithTimeout(ctx, g.cfg.Timeout)
}
// nopCloser 包装 io.Reader 为 io.ReadCloserClose 为空操作)
type nopCloser struct {
io.Reader
}
func (nopCloser) Close() error { return nil }