Files
mailx/mailgun/mailgun.go
T

163 lines
4.4 KiB
Go
Raw Normal View History

2026-08-15 01:38:05 +08:00
// Package mailgun 提供 Mailgun 邮件发送通道。
2024-11-20 19:42:07 +08:00
package mailgun
import (
2026-08-15 01:38:05 +08:00
"bytes"
2024-11-20 19:42:07 +08:00
"context"
2026-08-15 01:38:05 +08:00
"fmt"
"io"
"os"
"sync"
"time"
2024-11-20 19:42:07 +08:00
2026-08-15 01:38:05 +08:00
mailx "code.yun.ink/pkg/mailx"
2024-11-20 19:42:07 +08:00
"github.com/mailgun/mailgun-go/v4"
)
2026-08-15 01:38:05 +08:00
// 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 发送通道
2024-11-20 19:42:07 +08:00
type MailGun struct {
2026-08-15 01:38:05 +08:00
cfg Config
initOnce sync.Once
client mgClient
2024-11-20 19:42:07 +08:00
}
2026-08-15 01:38:05 +08:00
// New 创建 Mailgun 通道
func New(cfg Config) *MailGun {
return &MailGun{cfg: cfg}
2025-08-10 21:17:10 +08:00
}
2024-11-20 19:42:07 +08:00
2026-08-15 01:38:05 +08:00
// Name 返回通道名称
func (g *MailGun) Name() string { return "mailgun" }
2025-08-10 21:17:10 +08:00
2026-08-15 01:38:05 +08:00
// 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)
2025-08-10 21:17:10 +08:00
}
2026-08-15 01:38:05 +08:00
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)
2024-11-20 19:42:07 +08:00
}
2025-08-10 21:17:10 +08:00
2026-08-15 01:38:05 +08:00
// 超时兜底: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
2024-11-20 19:42:07 +08:00
}
2026-08-15 01:38:05 +08:00
// getClient 惰性创建并复用 Mailgun 客户端(线程安全)。
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
func (g *MailGun) getClient() mgClient {
if g.client != nil {
return g.client
2024-11-20 19:42:07 +08:00
}
2026-08-15 01:38:05 +08:00
g.initOnce.Do(func() {
g.client = mailgun.NewMailgun(g.cfg.Domain, g.cfg.APIKey)
})
return g.client
2024-11-20 19:42:07 +08:00
}
2026-08-15 01:38:05 +08:00
// 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 }