This commit is contained in:
yun
2026-08-15 01:38:05 +08:00
parent 139330aee2
commit eb8f660ab5
57 changed files with 5401 additions and 1438 deletions
+140 -36
View File
@@ -1,58 +1,162 @@
// Package mailgun 提供 Mailgun 邮件发送通道。
package mailgun
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"sync"
"time"
"code.yun.ink/pkg/mailx/interfaces"
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 {
interfaces.DefaultEmail
// params *interfaces.EmialConfigDataMailgun
mg *mailgun.MailgunImpl
// logger loggerx.LoggerInterface
cfg Config
initOnce sync.Once
client mgClient
}
func NewMailGun() *MailGun {
mailgun := &MailGun{}
mailgun.Options = interfaces.DefaultOptions()
mailgun.EmailType = interfaces.EmailTypeMailgun
return mailgun
// New 创建 Mailgun 通道
func New(cfg Config) *MailGun {
return &MailGun{cfg: cfg}
}
func (l *MailGun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
// Name 返回通道名称
func (g *MailGun) Name() string { return "mailgun" }
for _, o := range opt {
o(&l.Options)
// 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)
}
if l.Options.Mailgun == nil {
return nil, errors.New("not mailgun")
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)
}
l.Options.Logger.Infof(ctx, "Mailgun:%+v", l.Options.Mailgun)
mg := mailgun.NewMailgun(l.Options.Mailgun.Domain, l.Options.Mailgun.ApiKey)
l.mg = mg
return l, nil
// 超时兜底: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
}
func (l *MailGun) Send(ctx context.Context, params interfaces.Message) error {
if l.Options.Mailgun == nil {
return errors.New("not init")
// getClient 惰性创建并复用 Mailgun 客户端(线程安全)。
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
func (g *MailGun) getClient() mgClient {
if g.client != nil {
return g.client
}
message := l.mg.NewMessage(l.Options.Mailgun.Sender, params.Subject, params.Body, params.To...)
resp, id, err := l.mg.Send(ctx, message)
if err != nil {
l.Options.Logger.Errorf(ctx, "Could not send email: %v, resp message: %s, id: %s", err, resp, id)
return err
}
return err
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 }
+28
View File
@@ -0,0 +1,28 @@
package mailgun
import (
"context"
"testing"
mailx "code.yun.ink/pkg/mailx"
)
var _ mailx.Sender = (*MailGun)(nil)
func TestName(t *testing.T) {
if got := New(Config{}).Name(); got != "mailgun" {
t.Errorf("Name() = %q, want mailgun", got)
}
}
func TestSendWithoutSender(t *testing.T) {
// 缺少 Sender 时应立即报错,不调用 mailgun SDK
g := New(Config{})
err := g.Send(context.Background(), mailx.NewMessage().
To("a@b.com").
Subject("s").
Build())
if err == nil {
t.Fatal("Send without sender should error")
}
}
+189
View File
@@ -0,0 +1,189 @@
package mailgun
import (
"context"
"errors"
"testing"
mailx "code.yun.ink/pkg/mailx"
"github.com/mailgun/mailgun-go/v4"
)
// mockMG 实现 mgClient 接口,记录 NewMessage 的参数与 Send 调用
type mockMG struct {
sendErr error
// NewMessage 捕获
from, subject, text string
to []string
// Send 捕获
msgSent bool
sentMsg *mailgun.Message
}
func (m *mockMG) NewMessage(from, subject, text string, to ...string) *mailgun.Message {
m.from, m.subject, m.text = from, subject, text
m.to = to
return mailgun.NewMessage(from, subject, text, to...)
}
func (m *mockMG) Send(_ context.Context, msg *mailgun.Message) (string, string, error) {
m.msgSent = true
m.sentMsg = msg
if m.sendErr != nil {
return "", "", m.sendErr
}
return "mock-id", "queued", nil
}
// TestSendSuccess 验证完整发送流程与消息构造参数
func TestSendSuccess(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "noreply@example.com"}, client: 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").
AttachBytes("a.txt", []byte("data")).
InlineImageBytes("cid1", "a.png", []byte{1}).
Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if !mock.msgSent {
t.Fatal("message not sent")
}
if mock.from != "noreply@example.com" {
t.Errorf("From = %q", mock.from)
}
if mock.subject != "hi" {
t.Errorf("Subject = %q", mock.subject)
}
if mock.text != "plain" {
t.Errorf("Text = %q", mock.text)
}
if len(mock.to) != 2 || mock.to[0] != "a@example.com" || mock.to[1] != "b@example.com" {
t.Errorf("To = %v", mock.to)
}
}
// TestSendFromFallback 验证 msg.From 为空时回退 cfg.Sender
func TestSendFromFallback(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "cfg@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if mock.from != "cfg@example.com" {
t.Errorf("From = %q, want cfg fallback", mock.from)
}
}
// TestSendTextFallback 验证 Body 为纯文本时作为 text 传递
func TestSendTextFallback(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Body("plain body no html").Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if mock.text != "plain body no html" {
t.Errorf("Text = %q, want fallback to Body", mock.text)
}
}
// TestSendHTMLOnly 验证 Body 为 HTML 时不作为 text 传递
func TestSendHTMLOnly(t *testing.T) {
mock := &mockMG{}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").HTML("<b>html</b>").Build()
if err := g.Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if mock.text != "" {
t.Errorf("Text = %q, want empty for html body", mock.text)
}
}
// TestSendFail 验证发送失败包装为 ErrSendFailed
func TestSendFail(t *testing.T) {
mock := &mockMG{sendErr: errors.New("mailgun down")}
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := g.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
t.Fatalf("err = %v, want ErrSendFailed", err)
}
}
// TestSendNoSender 验证缺少 Sender 报错
func TestSendNoSender(t *testing.T) {
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com"}, client: &mockMG{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := g.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendNoDomain 验证缺少 Domain/APIKey 报错
func TestSendNoDomain(t *testing.T) {
g := &MailGun{cfg: Config{Sender: "s@example.com"}, client: &mockMG{}}
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
err := g.Send(context.Background(), msg)
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
// TestSendAttachmentErrors 验证附件边界情况
func TestSendAttachmentErrors(t *testing.T) {
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: &mockMG{}}
// 附件既无 path 也无 data
msg := mailx.NewMessage().To("a@example.com").Subject("s").
AttachBytes("x.txt", []byte{1}).
Build()
msg.Attachments[0].Data = nil // 清空 data
if err := g.Send(context.Background(), msg); err == nil {
t.Fatal("attachment without path/data should error")
}
// 附件路径不存在
msg = mailx.NewMessage().To("a@example.com").Subject("s").
Attach("no-such-file.txt").
Build()
if err := g.Send(context.Background(), msg); err == nil {
t.Fatal("missing attachment file should error")
}
// 内嵌图片路径不存在
msg = mailx.NewMessage().To("a@example.com").Subject("s").
InlineImage("cid1", "no-such-img.png").
Build()
if err := g.Send(context.Background(), msg); err == nil {
t.Fatal("missing inline file should error")
}
}
// TestGetClient 验证 getClient 惰性复用
func TestGetClient(t *testing.T) {
g := New(Config{APIKey: "key", Domain: "mg.example.com"})
c1 := g.getClient()
c2 := g.getClient()
if c1 != c2 {
t.Error("client should be reused")
}
}
+13 -16
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"code.yun.ink/pkg/mailx/interfaces"
"code.yun.ink/pkg/mailx"
"code.yun.ink/pkg/mailx/mailgun"
)
@@ -15,26 +15,23 @@ var (
)
func TestSendEmail(t *testing.T) {
gun := mailgun.NewMailGun()
ctx := context.Background()
ini, err := gun.SetOption(ctx, interfaces.SetMailgun(&interfaces.EmialConfigDataMailgun{
ApiKey: apikey,
if testing.Short() {
t.Skip("skip real send in short mode")
}
client := mailgun.New(mailgun.Config{
APIKey: apikey,
Domain: domain,
Sender: sender,
}))
if err != nil {
t.Fatal(err)
}
err = ini.Send(ctx, interfaces.Message{
To: []string{"995116474@qq.com"},
Subject: "test mail",
Body: "Hello",
})
ctx := context.Background()
err := client.Send(ctx, mailx.NewMessage().
To("995116474@qq.com").
Subject("test mail").
Body("Hello").
Build())
if err != nil {
t.Fatal(err)
}
t.Log("send success")
}