// 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 中用 ,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.ReadCloser(Close 为空操作) type nopCloser struct { io.Reader } func (nopCloser) Close() error { return nil }