From b3a87d0b54a37bbbed81f28150cc7f69915a679f Mon Sep 17 00:00:00 2001 From: Yun Date: Fri, 21 Nov 2025 14:01:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=B1=E5=8C=96smtp=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aliyun/aliyun.go | 199 +++++++++++--------- aws/aws.go | 191 +++++++++++++++---- aws/aws_old.go | 90 +++++++++ enhanced_test.go | 267 ++++++++++++++++++++++++++ html_email_example.go | 357 +++++++++++++++++++++++++++++++++++ html_test.go | 2 +- interfaces/consts.go | 44 +++-- interfaces/interfaces.go | 2 +- interfaces/options.go | 18 +- mailgun/mailgun.go | 123 ++++++++++-- performance_test.go | 370 +++++++++++++++++++++++++++++++++++++ smtp/example_usage.go | 8 +- smtp/html_email_test.go | 366 ++++++++++++++++++++++++++++++++++++ smtp/smtp.go | 230 +++++++++++++++++++---- smtp/smtp_enhanced_test.go | 6 +- smtp/smtp_test.go | 2 +- 16 files changed, 2075 insertions(+), 200 deletions(-) create mode 100644 aws/aws_old.go create mode 100644 enhanced_test.go create mode 100644 html_email_example.go create mode 100644 performance_test.go create mode 100644 smtp/html_email_test.go diff --git a/aliyun/aliyun.go b/aliyun/aliyun.go index c9063c2..7f87794 100644 --- a/aliyun/aliyun.go +++ b/aliyun/aliyun.go @@ -2,7 +2,6 @@ package aliyun import ( "context" - "encoding/json" "errors" "fmt" "strings" @@ -15,6 +14,12 @@ import ( "github.com/alibabacloud-go/tea/tea" ) +const ( + MaxRecipients = 100 + MaxRetries = 3 + DefaultEndpoint = "dm.aliyuncs.com" +) + type Aliyun struct { interfaces.DefaultEmail client *dm20151123.Client @@ -30,118 +35,70 @@ func NewAliyun() *Aliyun { } func (l *Aliyun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) { - for _, o := range opt { o(&l.Options) } - l.Options.Logger.Infof(ctx, "Aliyun:%+v", l.Options.Aliyun) - if l.Options.Aliyun == nil { - return nil, errors.New("not aliyun") + return nil, fmt.Errorf("Aliyun configuration is required") } - // 工程代码泄露可能会导致 AccessKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考。 - // 建议使用更安全的 STS 方式,更多鉴权访问方式请参见:https://help.aliyun.com/document_detail/378661.html。 + // 验证配置 + if err := l.validateConfig(); err != nil { + return nil, fmt.Errorf("invalid Aliyun config: %w", err) + } + + // 安全日志输出 + l.Options.Logger.Infof(ctx, "Aliyun configured - Endpoint:%s AccountName:%s", + l.Options.Aliyun.Endpoint, l.Options.Aliyun.AccountName) + config := &openapi.Config{ - // 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID。 - AccessKeyId: tea.String(l.Options.Aliyun.AccessId), - // 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_SECRET。 + AccessKeyId: tea.String(l.Options.Aliyun.AccessId), AccessKeySecret: tea.String(l.Options.Aliyun.AccessKey), } - if l.Options.Aliyun.Endpoint == "" { - l.Options.Aliyun.Endpoint = "dm.aliyuncs.com" - } - // Endpoint 请参考 https://api.aliyun.com/product/Dm + if l.Options.Aliyun.Endpoint == "" { + l.Options.Aliyun.Endpoint = DefaultEndpoint + } config.Endpoint = tea.String(l.Options.Aliyun.Endpoint) - result, err := dm20151123.NewClient(config) + client, err := dm20151123.NewClient(config) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create Aliyun client: %w", err) } - return &Aliyun{ - client: result, - }, nil + l.client = client + return l, nil } func (l *Aliyun) Send(ctx context.Context, params interfaces.Message) error { if l.client == nil { - return errors.New("client no init") - } - if len(params.To) > 100 { - return errors.New("最多 100 个地址") - } - if l.Options.Aliyun.AccountName == "" { - return errors.New("AccountName 必填") + return fmt.Errorf("Aliyun client not initialized") } - toAddress := strings.Join(params.To, ",") - - singleSendMailRequest := &dm20151123.SingleSendMailRequest{} - - singleSendMailRequest.AccountName = tea.String(l.Options.Aliyun.AccountName) - singleSendMailRequest.ToAddress = tea.String(toAddress) // 目标地址,多个 email 地址可以用逗号分隔,最多 100 个地址(支持邮件组)。 - singleSendMailRequest.Subject = tea.String(params.Subject) - singleSendMailRequest.HtmlBody = tea.String(params.Body) - singleSendMailRequest.AddressType = tea.Int32(0) // 地址类型。取值:0:为随机账号1:为发信地址 - - if params.ReplyTo != "" { - singleSendMailRequest.ReplyToAddress = tea.Bool(false) - singleSendMailRequest.ReplyAddress = tea.String(params.ReplyTo) - } else { - singleSendMailRequest.ReplyToAddress = tea.Bool(true) + // 验证消息 + if err := l.validateMessage(params); err != nil { + return fmt.Errorf("invalid message: %w", err) } - runtime := &util.RuntimeOptions{} - tryErr := func() (_e error) { - defer func() { - if r := tea.Recover(recover()); r != nil { - _e = r - } - }() - // 复制代码运行请自行打印 API 的返回值 - resp, err := l.client.SingleSendMailWithOptions(singleSendMailRequest, runtime) - by, _ := json.Marshal(resp) - fmt.Printf("resp:%+v err:%+v", string(by), err) - l.Options.Logger.Infof(ctx, "resp:%+v err:%+v", resp, err) - - if err != nil { - return err + // 重试机制 + var lastErr error + for i := 0; i < MaxRetries; i++ { + if i > 0 { + l.Options.Logger.Infof(ctx, "Retrying Aliyun email send, attempt %d/%d", i+1, MaxRetries) + time.Sleep(time.Duration(i) * time.Second) } - return nil - }() - if tryErr != nil { - l.Options.Logger.Errorf(ctx, "err:%+v", tryErr) - return tryErr + lastErr = l.sendEmail(ctx, params) + if lastErr == nil { + l.Options.Logger.Infof(ctx, "Aliyun email sent successfully to %v", params.To) + return nil + } - // var error = &tea.SDKError{} - // if _t, ok := tryErr.(*tea.SDKError); ok { - // error = _t - // } else { - // error.Message = tea.String(tryErr.Error()) - // } - // // 此处仅做打印展示,请谨慎对待异常处理,在工程项目中切勿直接忽略异常。 - // // 错误 message - // fmt.Println(tea.StringValue(error.Message)) - // // 诊断地址 - // var data interface{} - // d := json.NewDecoder(strings.NewReader(tea.StringValue(error.Data))) - // d.Decode(&data) - // if m, ok := data.(map[string]interface{}); ok { - // recommend, _ := m["Recommend"] - // fmt.Println("recommend", recommend) - // } - // _, _err := util.AssertAsString(error.Message) - // if _err != nil { - // return _err - // } + l.Options.Logger.Errorf(ctx, "Aliyun send attempt %d failed: %v", i+1, lastErr) } - return nil // 实现具体的 Aliyun 发送方法 - // 如:return aliyunSDK.SendMail(params) + return fmt.Errorf("failed to send Aliyun email after %d attempts: %w", MaxRetries, lastErr) } // 同步状态 @@ -258,3 +215,77 @@ func (l *Aliyun) getSendStatus(ctx context.Context, start string) (list []*dm201 } return list, nextStart, nil } + +// 验证配置 +func (l *Aliyun) validateConfig() error { + if l.Options.Aliyun.AccessId == "" { + return errors.New("AccessId is required") + } + if l.Options.Aliyun.AccessKey == "" { + return errors.New("AccessKey is required") + } + if l.Options.Aliyun.AccountName == "" { + return errors.New("AccountName is required") + } + return nil +} + +// 验证消息 +func (l *Aliyun) validateMessage(params interfaces.Message) error { + if len(params.To) == 0 { + return errors.New("at least one recipient is required") + } + if len(params.To) > MaxRecipients { + return fmt.Errorf("too many recipients: %d (max: %d)", len(params.To), MaxRecipients) + } + if params.Subject == "" { + return errors.New("subject is required") + } + if params.Body == "" { + return errors.New("body is required") + } + return nil +} + +// 发送邮件核心逻辑 +func (l *Aliyun) sendEmail(ctx context.Context, params interfaces.Message) error { + toAddress := strings.Join(params.To, ",") + + request := &dm20151123.SingleSendMailRequest{ + AccountName: tea.String(l.Options.Aliyun.AccountName), + ToAddress: tea.String(toAddress), + Subject: tea.String(params.Subject), + HtmlBody: tea.String(params.Body), + AddressType: tea.Int32(0), // 随机账号 + } + + // 设置回复地址 + if params.ReplyTo != "" { + request.ReplyToAddress = tea.Bool(false) + request.ReplyAddress = tea.String(params.ReplyTo) + } else if l.Options.Aliyun.ReplyAddress != "" { + request.ReplyToAddress = tea.Bool(false) + request.ReplyAddress = tea.String(l.Options.Aliyun.ReplyAddress) + } else { + request.ReplyToAddress = tea.Bool(true) + } + + // 设置发件人名称 + if params.Form != "" { + request.FromAlias = tea.String(params.Form) + } + + runtime := &util.RuntimeOptions{} + resp, err := l.client.SingleSendMailWithOptions(request, runtime) + if err != nil { + return fmt.Errorf("Aliyun API call failed: %w", err) + } + + // 记录响应信息(不包含敏感数据) + if resp != nil && resp.Body != nil { + l.Options.Logger.Infof(ctx, "Aliyun email sent - RequestId:%s EnvId:%s", + tea.StringValue(resp.Body.RequestId), tea.StringValue(resp.Body.EnvId)) + } + + return nil +} diff --git a/aws/aws.go b/aws/aws.go index 108ebcf..dea1b9f 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -3,6 +3,8 @@ package aws import ( "context" "errors" + "fmt" + "time" "code.yun.ink/pkg/mailx/interfaces" "github.com/aws/aws-sdk-go/aws" @@ -11,11 +13,15 @@ import ( "github.com/aws/aws-sdk-go/service/ses" ) -// 不支持变更发信人(必须配置好) +const ( + MaxRetries = 3 + DefaultRegion = "us-east-1" + MaxRecipients = 50 // AWS SES limit +) type Aws struct { interfaces.DefaultEmail - // params *interfaces.EmailConfigDataAws + sesClient *ses.SES } func NewAws() *Aws { @@ -26,65 +32,176 @@ func NewAws() *Aws { } func (l *Aws) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) { - for _, o := range opt { o(&l.Options) } - l.Options.Logger.Infof(ctx, "Aws:%+v", l.Options.Aws) if l.Options.Aws == nil { - return nil, errors.New("not aws") + return nil, fmt.Errorf("AWS configuration is required") + } + + // 验证配置 + if err := l.validateConfig(); err != nil { + return nil, fmt.Errorf("invalid AWS config: %w", err) } if l.Options.Aws.Region == "" { - l.Options.Aws.Region = "ap-northeast-1" + l.Options.Aws.Region = DefaultRegion } + // 初始化SES客户端 + if err := l.initSESClient(); err != nil { + return nil, fmt.Errorf("failed to initialize SES client: %w", err) + } + + // 安全日志输出 + l.Options.Logger.Infof(ctx, "AWS SES configured - Region:%s Sender:%s", + l.Options.Aws.Region, l.Options.Aws.Sender) + return l, nil } func (l *Aws) Send(ctx context.Context, params interfaces.Message) error { - if l.Options.Aws == nil { - return errors.New("not init") + if l.sesClient == nil { + return fmt.Errorf("AWS SES client not initialized") } - // 配置AWS认证信息 + + // 验证消息 + if err := l.validateMessage(params); err != nil { + return fmt.Errorf("invalid message: %w", err) + } + + // 重试机制 + var lastErr error + for i := 0; i < MaxRetries; i++ { + if i > 0 { + l.Options.Logger.Infof(ctx, "Retrying AWS SES email send, attempt %d/%d", i+1, MaxRetries) + time.Sleep(time.Duration(i) * time.Second) + } + + lastErr = l.sendEmail(ctx, params) + if lastErr == nil { + l.Options.Logger.Infof(ctx, "AWS SES email sent successfully to %v", params.To) + return nil + } + + l.Options.Logger.Errorf(ctx, "AWS SES send attempt %d failed: %v", i+1, lastErr) + } + + return fmt.Errorf("failed to send AWS SES email after %d attempts: %w", MaxRetries, lastErr) +} + +// 验证配置 +func (l *Aws) validateConfig() error { + if l.Options.Aws.AccessId == "" { + return errors.New("AccessId is required") + } + if l.Options.Aws.AccessSecret == "" { + return errors.New("AccessSecret is required") + } + if l.Options.Aws.Sender == "" { + return errors.New("Sender is required") + } + return nil +} + +// 验证消息 +func (l *Aws) validateMessage(params interfaces.Message) error { + if len(params.To) == 0 { + return errors.New("at least one recipient is required") + } + if len(params.To) > MaxRecipients { + return fmt.Errorf("too many recipients: %d (max: %d)", len(params.To), MaxRecipients) + } + if params.Subject == "" { + return errors.New("subject is required") + } + if params.Body == "" { + return errors.New("body is required") + } + return nil +} + +// 初始化SES客户端 +func (l *Aws) initSESClient() error { config := aws.Config{ - Region: aws.String(l.Options.Aws.Region), // 设置你的AWS区域 + Region: aws.String(l.Options.Aws.Region), Credentials: credentials.NewStaticCredentials(l.Options.Aws.AccessId, l.Options.Aws.AccessSecret, ""), } - // 创建AWS会话 - sess := session.Must(session.NewSession(&config)) - - // 创建SES客户端 - svc := ses.New(sess) - - toAddress := []*string{} - for _, val := range params.To { - toAddress = append(toAddress, aws.String(val)) + sess, err := session.NewSession(&config) + if err != nil { + return fmt.Errorf("failed to create AWS session: %w", err) } - // 使用SES服务发送邮件 - _, err := svc.SendEmail(&ses.SendEmailInput{ - Destination: &ses.Destination{ - ToAddresses: toAddress, + l.sesClient = ses.New(sess) + return nil +} + +// 发送邮件核心逻辑 +func (l *Aws) sendEmail(ctx context.Context, params interfaces.Message) error { + // 构建收件人列表 + destination := &ses.Destination{} + + if len(params.To) > 0 { + toAddresses := make([]*string, len(params.To)) + for i, addr := range params.To { + toAddresses[i] = aws.String(addr) + } + destination.ToAddresses = toAddresses + } + + if len(params.Cc) > 0 { + ccAddresses := make([]*string, len(params.Cc)) + for i, addr := range params.Cc { + ccAddresses[i] = aws.String(addr) + } + destination.CcAddresses = ccAddresses + } + + if len(params.Bcc) > 0 { + bccAddresses := make([]*string, len(params.Bcc)) + for i, addr := range params.Bcc { + bccAddresses[i] = aws.String(addr) + } + destination.BccAddresses = bccAddresses + } + + // 构建邮件内容 + message := &ses.Message{ + Subject: &ses.Content{ + Data: aws.String(params.Subject), + Charset: aws.String("UTF-8"), }, - Message: &ses.Message{ - Body: &ses.Body{ - Html: &ses.Content{ - Data: aws.String(params.Body), - Charset: aws.String("UTF-8"), - }, - }, - Subject: &ses.Content{ - Data: aws.String(params.Subject), + Body: &ses.Body{ + Html: &ses.Content{ + Data: aws.String(params.Body), Charset: aws.String("UTF-8"), }, }, - Source: aws.String(l.Options.Aws.Sender), - }) + } - // svc.SendRawEmail() + // 发送邮件 + input := &ses.SendEmailInput{ + Destination: destination, + Message: message, + Source: aws.String(l.Options.Aws.Sender), + } - return err -} + // 设置回复地址 + if params.ReplyTo != "" { + input.ReplyToAddresses = []*string{aws.String(params.ReplyTo)} + } + + resp, err := l.sesClient.SendEmailWithContext(ctx, input) + if err != nil { + return fmt.Errorf("AWS SES API call failed: %w", err) + } + + // 记录响应信息 + if resp != nil && resp.MessageId != nil { + l.Options.Logger.Infof(ctx, "AWS SES email sent - MessageId:%s", aws.StringValue(resp.MessageId)) + } + + return nil +} \ No newline at end of file diff --git a/aws/aws_old.go b/aws/aws_old.go new file mode 100644 index 0000000..70debad --- /dev/null +++ b/aws/aws_old.go @@ -0,0 +1,90 @@ +package aws + +import ( + "context" + "errors" + + "code.yun.ink/pkg/mailx/interfaces" + "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" +) + +// 不支持变更发信人(必须配置好) + +type AwsOld struct { + interfaces.DefaultEmail + // params *interfaces.EmailConfigDataAws +} + +func NewAwsOld() *AwsOld { + aws := &AwsOld{} + aws.Options = interfaces.DefaultOptions() + aws.EmailType = interfaces.EmailTypeAws + return aws +} + +func (l *AwsOld) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) { + + for _, o := range opt { + o(&l.Options) + } + + l.Options.Logger.Infof(ctx, "Aws:%+v", l.Options.Aws) + if l.Options.Aws == nil { + return nil, errors.New("not aws") + } + + if l.Options.Aws.Region == "" { + l.Options.Aws.Region = "ap-northeast-1" + } + + return l, nil +} + +func (l *AwsOld) Send(ctx context.Context, params interfaces.Message) error { + if l.Options.Aws == nil { + return errors.New("not init") + } + // 配置AWS认证信息 + config := aws.Config{ + Region: aws.String(l.Options.Aws.Region), // 设置你的AWS区域 + Credentials: credentials.NewStaticCredentials(l.Options.Aws.AccessId, l.Options.Aws.AccessSecret, ""), + } + + // 创建AWS会话 + sess := session.Must(session.NewSession(&config)) + + // 创建SES客户端 + svc := ses.New(sess) + + toAddress := []*string{} + for _, val := range params.To { + toAddress = append(toAddress, aws.String(val)) + } + + // 使用SES服务发送邮件 + _, err := svc.SendEmail(&ses.SendEmailInput{ + Destination: &ses.Destination{ + ToAddresses: toAddress, + }, + Message: &ses.Message{ + Body: &ses.Body{ + Html: &ses.Content{ + Data: aws.String(params.Body), + Charset: aws.String("UTF-8"), + }, + }, + Subject: &ses.Content{ + Data: aws.String(params.Subject), + Charset: aws.String("UTF-8"), + }, + }, + Source: aws.String(l.Options.Aws.Sender), + }) + + // svc.SendRawEmail() + + return err +} diff --git a/enhanced_test.go b/enhanced_test.go new file mode 100644 index 0000000..d3b6a68 --- /dev/null +++ b/enhanced_test.go @@ -0,0 +1,267 @@ +package mailx + +import ( + "context" + "fmt" + "testing" + "time" + + "code.yun.ink/pkg/mailx/aliyun" + "code.yun.ink/pkg/mailx/aws" + "code.yun.ink/pkg/mailx/interfaces" + "code.yun.ink/pkg/mailx/mailgun" + "code.yun.ink/pkg/mailx/smtp" +) + +// 测试SMTP增强功能 +func TestSMTPEnhanced(t *testing.T) { + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + // 测试配置验证 + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Errorf("SMTP configuration failed: %v", err) + } + + // 测试消息验证 + message := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "Test Email", + Body: "

Test Content

", + } + + err = smtpClient.Send(ctx, message) + if err != nil { + t.Errorf("SMTP send failed: %v", err) + } + // 注意:这里不会真正发送邮件,只是测试验证逻辑 + // 在实际测试中需要mock或使用测试邮件服务器 + t.Logf("SMTP client configured successfully") +} + +// 测试阿里云增强功能 +func TestAliyunEnhanced(t *testing.T) { + aliyunClient := aliyun.NewAliyun() + ctx := context.Background() + + // 测试配置验证 + _, err := aliyunClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Aliyun = &interfaces.EmialConfigDataAliyun{ + AccessId: "test-access-id", + AccessKey: "test-access-key", + AccountName: "test@example.com", + Endpoint: "dm.aliyuncs.com", + } + }) + if err != nil { + t.Errorf("Aliyun configuration failed: %v", err) + } + + t.Logf("Aliyun client configured successfully") +} + +// 测试AWS增强功能 +func TestAWSEnhanced(t *testing.T) { + awsClient := aws.NewAws() + ctx := context.Background() + + // 测试配置验证 + _, err := awsClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Aws = &interfaces.EmailConfigDataAws{ + AccessId: "test-access-id", + AccessSecret: "test-access-secret", + Region: "us-east-1", + Sender: "test@example.com", + } + }) + if err != nil { + t.Errorf("AWS configuration failed: %v", err) + } + + t.Logf("AWS client configured successfully") +} + +// 测试Mailgun增强功能 +func TestMailgunEnhanced(t *testing.T) { + mailgunClient := mailgun.NewMailGun() + ctx := context.Background() + + // 测试配置验证 + _, err := mailgunClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Mailgun = &interfaces.EmialConfigDataMailgun{ + ApiKey: "test-api-key", + Domain: "example.com", + Sender: "test@example.com", + } + }) + if err != nil { + t.Errorf("Mailgun configuration failed: %v", err) + } + + t.Logf("Mailgun client configured successfully") +} + +// 测试消息验证 +func TestMessageValidation(t *testing.T) { + tests := []struct { + name string + message interfaces.Message + wantErr bool + }{ + { + name: "valid message", + message: interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Test Subject", + Body: "Test Body", + }, + wantErr: false, + }, + { + name: "empty recipients", + message: interfaces.Message{ + Subject: "Test Subject", + Body: "Test Body", + }, + wantErr: true, + }, + { + name: "empty subject", + message: interfaces.Message{ + To: []string{"test@example.com"}, + Body: "Test Body", + }, + wantErr: true, + }, + { + name: "empty body", + message: interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Test Subject", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 这里可以测试各个实现的消息验证逻辑 + // 由于验证逻辑在各个实现中,这里只是示例 + if len(tt.message.To) == 0 && !tt.wantErr { + t.Error("Expected error for empty recipients") + } + }) + } +} + +// 测试重试机制 +func TestRetryMechanism(t *testing.T) { + // 模拟重试逻辑 + maxRetries := 3 + attempts := 0 + + for i := 0; i < maxRetries; i++ { + attempts++ + if i > 0 { + time.Sleep(time.Duration(i) * time.Millisecond) // 快速测试 + } + + // 模拟第3次成功 + if attempts == 3 { + break + } + } + + if attempts != 3 { + t.Errorf("Expected 3 attempts, got %d", attempts) + } +} + +// 测试配置安全性 +func TestConfigSecurity(t *testing.T) { + // 测试敏感信息不应该出现在日志中 + config := &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Username: "user@example.com", + Password: "secret-password", + } + + // 模拟安全日志输出 + safeLog := fmt.Sprintf("Host:%s Username:%s Password:***", + config.Host, config.Username) + + if contains(safeLog, "secret-password") { + t.Error("Password should not appear in logs") + } +} + +// 辅助函数 +func contains(s, substr string) bool { + return len(s) >= len(substr) && s[len(s)-len(substr):] == substr || + len(s) > len(substr) && s[:len(substr)] == substr || + len(s) > len(substr) && s[len(s)/2-len(substr)/2:len(s)/2+len(substr)/2] == substr +} + +// 基准测试 +func BenchmarkEmailSend(b *testing.B) { + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + + message := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "Benchmark Test", + Body: "Benchmark test content", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // 注意:这里不会真正发送邮件 + // 在实际基准测试中需要mock发送逻辑 + _ = message + } +} + +// 集成测试示例 +func TestEmailFactory(t *testing.T) { + factory := interfaces.NewDefaultEmailFactory() + + // 注册所有邮件服务 + factory.Register(smtp.NewSmtp()) + factory.Register(aliyun.NewAliyun()) + factory.Register(aws.NewAws()) + factory.Register(mailgun.NewMailGun()) + + // 测试获取不同类型的邮件服务 + smtpEmail, err := factory.GetEmail(interfaces.EmailTypeSmtp) + if err != nil { + t.Errorf("Failed to get SMTP email: %v", err) + } + if smtpEmail.GetEmailType() != interfaces.EmailTypeSmtp { + t.Error("Wrong email type returned") + } + + aliyunEmail, err := factory.GetEmail(interfaces.EmailTypeAliyun) + if err != nil { + t.Errorf("Failed to get Aliyun email: %v", err) + } + if aliyunEmail.GetEmailType() != interfaces.EmailTypeAliyun { + t.Error("Wrong email type returned") + } +} diff --git a/html_email_example.go b/html_email_example.go new file mode 100644 index 0000000..ae144ef --- /dev/null +++ b/html_email_example.go @@ -0,0 +1,357 @@ +package mailx + +import ( + "context" + "fmt" + "log" + + "code.yun.ink/pkg/mailx/interfaces" + "code.yun.ink/pkg/mailx/smtp" +) + +// HTML邮件发送示例 +func ExampleHTMLEmail() { + ctx := context.Background() + smtpClient := smtp.NewSmtp() + + // 配置SMTP + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.gmail.com", + Port: "587", + Username: "your-email@gmail.com", + Password: "your-app-password", + } + }) + if err != nil { + log.Fatalf("SMTP配置失败: %v", err) + } + + // 创建HTML邮件 + message := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "HTML邮件测试 - 包含图片和附件", + BodyType: interfaces.ContentTypeHTML, + Body: ` + + + + HTML邮件测试 + + +

欢迎使用HTML邮件

+

这是一封包含HTML格式的邮件。

+ +

内嵌图片示例

+

下面是一张内嵌图片:

+ Logo + +

表格示例

+ + + + + + + + + + + +
姓名邮箱状态
张三zhangsan@example.com已激活
+ +

如果您无法查看此邮件,请点击这里

+ + + `, + TextBody: ` + 欢迎使用HTML邮件 + + 这是一封包含HTML格式的邮件。 + + 内嵌图片示例 + 下面是一张内嵌图片:[图片: Logo] + + 表格示例 + 姓名 邮箱 状态 + 张三 zhangsan@example.com 已激活 + + 如果您无法查看此邮件,请访问: https://example.com + `, + InlineImage: []interfaces.Attachment{ + { + Content: "/path/to/logo.png", + ContentType: "image/png", + Name: "logo.png", + CID: "logo", // 对应HTML中的cid:logo + Type: interfaces.AttachmentTypeInline, + }, + }, + Attachment: []interfaces.Attachment{ + { + Content: "/path/to/document.pdf", + ContentType: "application/pdf", + Name: "重要文档.pdf", + Type: interfaces.AttachmentTypeFile, + }, + }, + } + + // 发送邮件 + if err := smtpClient.Send(ctx, message); err != nil { + log.Fatalf("邮件发送失败: %v", err) + } + + fmt.Println("HTML邮件发送成功!") +} + +// 纯文本邮件示例 +func ExampleTextEmail() { + ctx := context.Background() + smtpClient := smtp.NewSmtp() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.gmail.com", + Port: "587", + Username: "your-email@gmail.com", + Password: "your-app-password", + } + }) + if err != nil { + log.Fatalf("SMTP配置失败: %v", err) + } + + message := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "纯文本邮件测试", + BodyType: interfaces.ContentTypeText, + Body: ` + 欢迎使用邮件服务 + + 这是一封纯文本邮件,不包含任何HTML格式。 + + 主要特点: + - 简洁明了 + - 兼容性好 + - 加载速度快 + + 如有疑问,请回复此邮件。 + `, + Attachment: []interfaces.Attachment{ + { + Content: "/path/to/readme.txt", + Name: "说明文档.txt", + Type: interfaces.AttachmentTypeFile, + }, + }, + } + + if err := smtpClient.Send(ctx, message); err != nil { + log.Fatalf("邮件发送失败: %v", err) + } + + fmt.Println("纯文本邮件发送成功!") +} + +// 自动检测内容类型示例 +func ExampleAutoDetectContentType() { + ctx := context.Background() + smtpClient := smtp.NewSmtp() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.gmail.com", + Port: "587", + Username: "your-email@gmail.com", + Password: "your-app-password", + } + }) + if err != nil { + log.Fatalf("SMTP配置失败: %v", err) + } + + // 自动检测为HTML + htmlMessage := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "自动检测HTML邮件", + BodyType: interfaces.ContentTypeAuto, // 自动检测 + Body: "

这会被自动识别为HTML

因为包含HTML标签。

", + } + + // 自动检测为纯文本 + textMessage := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "自动检测纯文本邮件", + BodyType: interfaces.ContentTypeAuto, // 自动检测 + Body: "这会被自动识别为纯文本,因为不包含HTML标签。", + } + + if err := smtpClient.Send(ctx, htmlMessage); err != nil { + log.Printf("HTML邮件发送失败: %v", err) + } else { + fmt.Println("自动检测HTML邮件发送成功!") + } + + if err := smtpClient.Send(ctx, textMessage); err != nil { + log.Printf("文本邮件发送失败: %v", err) + } else { + fmt.Println("自动检测文本邮件发送成功!") + } +} + +// 使用字节数据作为附件示例 +func ExampleByteDataAttachment() { + ctx := context.Background() + smtpClient := smtp.NewSmtp() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.gmail.com", + Port: "587", + Username: "your-email@gmail.com", + Password: "your-app-password", + } + }) + if err != nil { + log.Fatalf("SMTP配置失败: %v", err) + } + + // 动态生成的CSV数据 + csvData := `姓名,邮箱,部门 +张三,zhangsan@example.com,技术部 +李四,lisi@example.com,市场部 +王五,wangwu@example.com,人事部` + + message := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "动态生成的附件", + BodyType: interfaces.ContentTypeHTML, + Body: "

员工名单

请查看附件中的详细信息。

", + Attachment: []interfaces.Attachment{ + { + Data: []byte(csvData), + ContentType: "text/csv", + Name: "员工名单.csv", + Type: interfaces.AttachmentTypeFile, + }, + }, + } + + if err := smtpClient.Send(ctx, message); err != nil { + log.Fatalf("邮件发送失败: %v", err) + } + + fmt.Println("包含动态附件的邮件发送成功!") +} + +// 复杂HTML邮件示例(包含多个内嵌图片) +func ExampleComplexHTMLEmail() { + ctx := context.Background() + smtpClient := smtp.NewSmtp() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.gmail.com", + Port: "587", + Username: "your-email@gmail.com", + Password: "your-app-password", + } + }) + if err != nil { + log.Fatalf("SMTP配置失败: %v", err) + } + + message := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "复杂HTML邮件 - 多图片和附件", + BodyType: interfaces.ContentTypeHTML, + Body: ` + + + + + + +
+ 公司Logo +

月度报告

+
+ +
+

销售数据

+

本月销售情况如下图所示:

+ 销售图表 + +

用户增长

+

用户增长趋势:

+ 用户增长图 +
+ + + + + `, + TextBody: ` + 月度报告 + + 销售数据 + 本月销售情况请查看附件中的图表。 + + 用户增长 + 用户增长趋势请查看附件中的图表。 + + 详细数据请查看附件。如有疑问,请联系我们。 + `, + InlineImage: []interfaces.Attachment{ + { + Content: "/path/to/header_logo.png", + ContentType: "image/png", + Name: "header_logo.png", + CID: "header_logo", + Type: interfaces.AttachmentTypeInline, + }, + { + Content: "/path/to/sales_chart.jpg", + ContentType: "image/jpeg", + Name: "sales_chart.jpg", + CID: "sales_chart", + Type: interfaces.AttachmentTypeInline, + }, + { + Content: "/path/to/user_growth.png", + ContentType: "image/png", + Name: "user_growth.png", + CID: "user_growth", + Type: interfaces.AttachmentTypeInline, + }, + }, + Attachment: []interfaces.Attachment{ + { + Content: "/path/to/detailed_report.xlsx", + ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Name: "详细报告.xlsx", + Type: interfaces.AttachmentTypeFile, + }, + { + Content: "/path/to/summary.pdf", + ContentType: "application/pdf", + Name: "摘要报告.pdf", + Type: interfaces.AttachmentTypeFile, + }, + }, + } + + if err := smtpClient.Send(ctx, message); err != nil { + log.Fatalf("邮件发送失败: %v", err) + } + + fmt.Println("复杂HTML邮件发送成功!") +} \ No newline at end of file diff --git a/html_test.go b/html_test.go index 012ed50..1c207a9 100644 --- a/html_test.go +++ b/html_test.go @@ -2,7 +2,7 @@ package mailx_test import ( "testing" - "wallet-pay-api/pkg/mailx" + "code.yun.ink/pkg/mailx" ) func TestParseHtmlResource(t *testing.T) { diff --git a/interfaces/consts.go b/interfaces/consts.go index 1ed549c..2f29b5c 100644 --- a/interfaces/consts.go +++ b/interfaces/consts.go @@ -41,20 +41,42 @@ type EmialConfigDataAliyun struct { ReplyAddress string `json:"reply_address"` // 邮件回复地址 } +type ContentType string + +const ( + ContentTypeText ContentType = "text" + ContentTypeHTML ContentType = "html" + ContentTypeAuto ContentType = "auto" // 自动检测 +) + +type AttachmentType string + +const ( + AttachmentTypeFile AttachmentType = "file" // 普通附件 + AttachmentTypeInline AttachmentType = "inline" // 内嵌图片 +) + type Message struct { - Form string - To []string - Cc []string - Bcc []string - Subject string - Body string - ReplyTo string - Attachment []MessageAttachment // 附件 + Form string + To []string + Cc []string + Bcc []string + Subject string + Body string // 邮件内容 + BodyType ContentType // 内容类型:text/html/auto + TextBody string // 纯文本版本(可选) + ReplyTo string + Attachment []Attachment // 普通附件 + InlineImage []Attachment // 内嵌图片 } -type MessageAttachment struct { - Content string - ContentType string +type Attachment struct { + Content string // 文件路径或内容 + ContentType string // MIME类型 + Name string // 文件名 + CID string // Content-ID(内嵌图片用) + Type AttachmentType // 附件类型 + Data []byte // 直接提供字节数据(可选) } type EmailSendRecord struct { diff --git a/interfaces/interfaces.go b/interfaces/interfaces.go index 95cce13..86e69fc 100644 --- a/interfaces/interfaces.go +++ b/interfaces/interfaces.go @@ -22,7 +22,7 @@ type EmailFactoryInterface interface { } type DefaultEmail struct { - Options EmailOption + Options Options // 配置信息 EmailType EmailType } diff --git a/interfaces/options.go b/interfaces/options.go index 16594e5..497c5f5 100644 --- a/interfaces/options.go +++ b/interfaces/options.go @@ -6,7 +6,7 @@ import ( "github.com/yuninks/loggerx" ) -type EmailOption struct { +type Options struct { Logger loggerx.LoggerInterface Smtp *EmailConfigDataSmtp `json:"smtp,omitempty"` // smtp @@ -15,42 +15,42 @@ type EmailOption struct { Mailgun *EmialConfigDataMailgun `json:"mailgun,omitempty"` // mailgun } -func DefaultOptions() EmailOption { +func DefaultOptions() Options { ctx := context.Background() - return EmailOption{ + return Options{ Logger: loggerx.NewLogger(ctx), } } -type Option func(*EmailOption) +type Option func(*Options) // 设置日志 func SetLogger(logger loggerx.LoggerInterface) Option { - return func(o *EmailOption) { + return func(o *Options) { o.Logger = logger } } func SetSmtp(smtp *EmailConfigDataSmtp) Option { - return func(o *EmailOption) { + return func(o *Options) { o.Smtp = smtp } } func SetAws(aws *EmailConfigDataAws) Option { - return func(o *EmailOption) { + return func(o *Options) { o.Aws = aws } } func SetAliyun(aliyun *EmialConfigDataAliyun) Option { - return func(o *EmailOption) { + return func(o *Options) { o.Aliyun = aliyun } } func SetMailgun(mailgun *EmialConfigDataMailgun) Option { - return func(o *EmailOption) { + return func(o *Options) { o.Mailgun = mailgun } } diff --git a/mailgun/mailgun.go b/mailgun/mailgun.go index 8a7ebd4..7322641 100644 --- a/mailgun/mailgun.go +++ b/mailgun/mailgun.go @@ -3,11 +3,18 @@ package mailgun import ( "context" "errors" + "fmt" + "time" "code.yun.ink/pkg/mailx/interfaces" "github.com/mailgun/mailgun-go/v4" ) +const ( + MaxRetries = 3 + MaxRecipients = 1000 // Mailgun limit +) + type MailGun struct { interfaces.DefaultEmail // params *interfaces.EmialConfigDataMailgun @@ -23,36 +30,130 @@ func NewMailGun() *MailGun { } func (l *MailGun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) { - for _, o := range opt { o(&l.Options) } if l.Options.Mailgun == nil { - return nil, errors.New("not mailgun") + return nil, fmt.Errorf("Mailgun configuration is required") } - l.Options.Logger.Infof(ctx, "Mailgun:%+v", l.Options.Mailgun) + // 验证配置 + if err := l.validateConfig(); err != nil { + return nil, fmt.Errorf("invalid Mailgun config: %w", err) + } - mg := mailgun.NewMailgun(l.Options.Mailgun.Domain, l.Options.Mailgun.ApiKey) + // 安全日志输出 + l.Options.Logger.Infof(ctx, "Mailgun configured - Domain:%s Sender:%s", + l.Options.Mailgun.Domain, l.Options.Mailgun.Sender) - l.mg = mg + l.mg = mailgun.NewMailgun(l.Options.Mailgun.Domain, l.Options.Mailgun.ApiKey) return l, nil } func (l *MailGun) Send(ctx context.Context, params interfaces.Message) error { - if l.Options.Mailgun == nil { - return errors.New("not init") + if l.mg == nil { + return fmt.Errorf("Mailgun client not initialized") } - message := l.mg.NewMessage(l.Options.Mailgun.Sender, params.Subject, params.Body, params.To...) + // 验证消息 + if err := l.validateMessage(params); err != nil { + return fmt.Errorf("invalid message: %w", err) + } + // 重试机制 + var lastErr error + for i := 0; i < MaxRetries; i++ { + if i > 0 { + l.Options.Logger.Infof(ctx, "Retrying Mailgun email send, attempt %d/%d", i+1, MaxRetries) + time.Sleep(time.Duration(i) * time.Second) + } + + lastErr = l.sendEmail(ctx, params) + if lastErr == nil { + l.Options.Logger.Infof(ctx, "Mailgun email sent successfully to %v", params.To) + return nil + } + + l.Options.Logger.Errorf(ctx, "Mailgun send attempt %d failed: %v", i+1, lastErr) + } + + return fmt.Errorf("failed to send Mailgun email after %d attempts: %w", MaxRetries, lastErr) +} + +// 验证配置 +func (l *MailGun) validateConfig() error { + if l.Options.Mailgun.Domain == "" { + return errors.New("Domain is required") + } + if l.Options.Mailgun.ApiKey == "" { + return errors.New("ApiKey is required") + } + if l.Options.Mailgun.Sender == "" { + return errors.New("Sender is required") + } + return nil +} + +// 验证消息 +func (l *MailGun) validateMessage(params interfaces.Message) error { + if len(params.To) == 0 { + return errors.New("at least one recipient is required") + } + if len(params.To) > MaxRecipients { + return fmt.Errorf("too many recipients: %d (max: %d)", len(params.To), MaxRecipients) + } + if params.Subject == "" { + return errors.New("subject is required") + } + if params.Body == "" { + return errors.New("body is required") + } + return nil +} + +// 发送邮件核心逻辑 +func (l *MailGun) sendEmail(ctx context.Context, params interfaces.Message) error { + // 创建邮件消息 + message := l.mg.NewMessage(l.Options.Mailgun.Sender, params.Subject, "", params.To...) + + // 设置HTML内容 + message.SetHtml(params.Body) + + // 设置Cc收件人 + for _, cc := range params.Cc { + message.AddCC(cc) + } + + // 设置Bcc收件人 + for _, bcc := range params.Bcc { + message.AddBCC(bcc) + } + + // 设置回复地址 + if params.ReplyTo != "" { + message.SetReplyTo(params.ReplyTo) + } + + // 设置发件人名称 + if params.Form != "" { + // + } + + // 添加附件 + for _, attachment := range params.Attachment { + message.AddAttachment(attachment.Content) + } + + // 发送邮件 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 fmt.Errorf("Mailgun API call failed: %w", err) } - return err + // 记录响应信息 + l.Options.Logger.Infof(ctx, "Mailgun email sent - ID:%s Response:%s", id, resp) + + return nil } diff --git a/performance_test.go b/performance_test.go new file mode 100644 index 0000000..e22a926 --- /dev/null +++ b/performance_test.go @@ -0,0 +1,370 @@ +package mailx + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "code.yun.ink/pkg/mailx/interfaces" + "code.yun.ink/pkg/mailx/smtp" +) + +// 并发发送测试 +func TestConcurrentSend(t *testing.T) { + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + // 配置SMTP客户端 + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Fatalf("Failed to configure SMTP: %v", err) + } + + // 并发测试参数 + numGoroutines := 10 + emailsPerGoroutine := 5 + var wg sync.WaitGroup + errors := make(chan error, numGoroutines*emailsPerGoroutine) + + start := time.Now() + + // 启动多个goroutine并发发送邮件 + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(goroutineID int) { + defer wg.Done() + + for j := 0; j < emailsPerGoroutine; j++ { + message := interfaces.Message{ + To: []string{fmt.Sprintf("test%d_%d@example.com", goroutineID, j)}, + Subject: fmt.Sprintf("Concurrent Test %d-%d", goroutineID, j), + Body: fmt.Sprintf("Test email from goroutine %d, email %d", goroutineID, j), + } + + // 注意:在实际测试中,这里应该mock发送逻辑 + err := smtpClient.Send(ctx, message) + if err != nil { + errors <- fmt.Errorf("goroutine %d, email %d: %w", goroutineID, j, err) + } + + // 模拟发送时间 + time.Sleep(10 * time.Millisecond) + } + }(i) + } + + // 等待所有goroutine完成 + wg.Wait() + close(errors) + + duration := time.Since(start) + totalEmails := numGoroutines * emailsPerGoroutine + + // 检查错误 + errorCount := 0 + for err := range errors { + t.Logf("Error: %v", err) + errorCount++ + } + + t.Logf("Concurrent test completed:") + t.Logf("- Total emails: %d", totalEmails) + t.Logf("- Duration: %v", duration) + t.Logf("- Errors: %d", errorCount) + t.Logf("- Success rate: %.2f%%", float64(totalEmails-errorCount)/float64(totalEmails)*100) +} + +// 内存使用测试 +func TestMemoryUsage(t *testing.T) { + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Fatalf("Failed to configure SMTP: %v", err) + } + + // 测试大量邮件对象创建 + numEmails := 1000 + messages := make([]interfaces.Message, numEmails) + + start := time.Now() + for i := 0; i < numEmails; i++ { + messages[i] = interfaces.Message{ + To: []string{fmt.Sprintf("test%d@example.com", i)}, + Subject: fmt.Sprintf("Memory Test %d", i), + Body: fmt.Sprintf("Large email body content for testing memory usage. Email number: %d. %s", i, generateLargeContent(1024)), + } + } + duration := time.Since(start) + + t.Logf("Memory test completed:") + t.Logf("- Created %d email messages", numEmails) + t.Logf("- Duration: %v", duration) + t.Logf("- Average time per message: %v", duration/time.Duration(numEmails)) + + // 清理内存 + messages = nil +} + +// 连接池测试 +func TestConnectionReuse(t *testing.T) { + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Fatalf("Failed to configure SMTP: %v", err) + } + + // 测试多次发送是否复用连接配置 + numSends := 10 + start := time.Now() + + for i := 0; i < numSends; i++ { + message := interfaces.Message{ + To: []string{fmt.Sprintf("test%d@example.com", i)}, + Subject: fmt.Sprintf("Connection Reuse Test %d", i), + Body: "Testing connection reuse", + } + + // 模拟发送 + _ = message + time.Sleep(5 * time.Millisecond) + } + + duration := time.Since(start) + t.Logf("Connection reuse test:") + t.Logf("- Sent %d emails", numSends) + t.Logf("- Total duration: %v", duration) + t.Logf("- Average per email: %v", duration/time.Duration(numSends)) +} + +// 错误恢复测试 +func TestErrorRecovery(t *testing.T) { + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "invalid-smtp-server.com", // 故意使用无效服务器 + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Fatalf("Failed to configure SMTP: %v", err) + } + + message := interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Error Recovery Test", + Body: "Testing error recovery mechanism", + } + + start := time.Now() + + // 这应该失败并触发重试机制 + + err = smtpClient.Send(ctx, message) + if err == nil { + t.Error("Expected error for invalid SMTP server") + } + + duration := time.Since(start) + t.Logf("Error recovery test:") + t.Logf("- Duration: %v", duration) + t.Logf("- Expected failure with retry mechanism") +} + +// 大附件测试 +func TestLargeAttachment(t *testing.T) { + // 测试大附件处理 + largeContent := generateLargeContent(1024 * 1024) // 1MB + + message := interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Large Attachment Test", + Body: "Testing large attachment handling", + Attachment: []interfaces.Attachment{ + { + Content: "/tmp/large_file.txt", // 假设的大文件路径 + ContentType: "text/plain", + }, + }, + } + + // 验证附件大小限制 + maxSize := 25 * 1024 * 1024 // 25MB + if len(largeContent) > maxSize { + t.Logf("Large content size: %d bytes (exceeds %d bytes limit)", len(largeContent), maxSize) + } + // 注意:这里不会真正发送邮件,只是测试验证逻辑 + // 在实际测试中需要mock或使用测试邮件服务器 + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Errorf("Failed to configure SMTP: %v", err) + } + + err = smtpClient.Send(ctx, message) + if err != nil { + t.Errorf("Failed to handle large attachment: %v", err) + } else { + t.Logf("Large attachment handled successfully") + } + + t.Logf("Large attachment test completed") +} + +// 压力测试 +func BenchmarkEmailCreation(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + message := interfaces.Message{ + To: []string{fmt.Sprintf("test%d@example.com", i)}, + Subject: fmt.Sprintf("Benchmark Test %d", i), + Body: "Benchmark test content", + } + _ = message + } +} + +// 并发基准测试 +func BenchmarkConcurrentEmailCreation(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + message := interfaces.Message{ + To: []string{fmt.Sprintf("test%d@example.com", i)}, + Subject: fmt.Sprintf("Concurrent Benchmark Test %d", i), + Body: "Concurrent benchmark test content", + } + _ = message + i++ + } + }) +} + +// 配置创建基准测试 +func BenchmarkSMTPConfiguration(b *testing.B) { + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + smtpClient := smtp.NewSmtp() + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + b.Fatalf("Configuration failed: %v", err) + } + } +} + +// 辅助函数:生成大内容 +func generateLargeContent(size int) string { + content := make([]byte, size) + for i := range content { + content[i] = byte('A' + (i % 26)) + } + return string(content) +} + +// 负载测试 +func TestLoadTesting(t *testing.T) { + if testing.Short() { + t.Skip("Skipping load test in short mode") + } + + smtpClient := smtp.NewSmtp() + ctx := context.Background() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Fatalf("Failed to configure SMTP: %v", err) + } + + // 负载测试参数 + totalEmails := 100 + concurrency := 10 + semaphore := make(chan struct{}, concurrency) + var wg sync.WaitGroup + + start := time.Now() + + for i := 0; i < totalEmails; i++ { + wg.Add(1) + go func(emailID int) { + defer wg.Done() + + // 获取信号量 + semaphore <- struct{}{} + defer func() { <-semaphore }() + + message := interfaces.Message{ + To: []string{fmt.Sprintf("load_test_%d@example.com", emailID)}, + Subject: fmt.Sprintf("Load Test Email %d", emailID), + Body: fmt.Sprintf("Load testing email number %d", emailID), + } + + // 模拟发送 + time.Sleep(50 * time.Millisecond) + _ = message + }(i) + } + + wg.Wait() + duration := time.Since(start) + + t.Logf("Load test completed:") + t.Logf("- Total emails: %d", totalEmails) + t.Logf("- Concurrency: %d", concurrency) + t.Logf("- Duration: %v", duration) + t.Logf("- Throughput: %.2f emails/second", float64(totalEmails)/duration.Seconds()) +} \ No newline at end of file diff --git a/smtp/example_usage.go b/smtp/example_usage.go index d04a01a..c0b3ef8 100644 --- a/smtp/example_usage.go +++ b/smtp/example_usage.go @@ -15,7 +15,7 @@ func ExampleUsage() { smtpClient := NewSmtp() // 配置SMTP设置 - _, err := smtpClient.SetOption(ctx, func(opt *interfaces.EmailOption) { + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { opt.Smtp = &interfaces.EmailConfigDataSmtp{ Host: "smtp.gmail.com", Port: "587", // STARTTLS @@ -37,7 +37,7 @@ func ExampleUsage() { Subject: "测试邮件 - Enhanced SMTP", Body: "

这是一封测试邮件

支持HTML格式和多种功能。

", ReplyTo: "noreply@example.com", - Attachment: []interfaces.MessageAttachment{ + Attachment: []interfaces.Attachment{ {Content: "path/to/attachment.pdf"}, }, } @@ -56,7 +56,7 @@ func ExampleSSLUsage() { smtpClient := NewSmtp() - _, err := smtpClient.SetOption(ctx, func(opt *interfaces.EmailOption) { + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { opt.Smtp = &interfaces.EmailConfigDataSmtp{ Host: "smtp.gmail.com", Port: "465", // SSL @@ -87,7 +87,7 @@ func ExampleEnterpriseEmail() { smtpClient := NewSmtp() // 企业邮箱通常使用587端口和STARTTLS - _, err := smtpClient.SetOption(ctx, func(opt *interfaces.EmailOption) { + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { opt.Smtp = &interfaces.EmailConfigDataSmtp{ Host: "smtp.exmail.qq.com", // 腾讯企业邮箱 Port: "587", diff --git a/smtp/html_email_test.go b/smtp/html_email_test.go new file mode 100644 index 0000000..bd8479f --- /dev/null +++ b/smtp/html_email_test.go @@ -0,0 +1,366 @@ +package smtp + +import ( + "context" + "strings" + "testing" + + "code.yun.ink/pkg/mailx/interfaces" +) + +// 测试内容类型检测 +func TestContentTypeDetection(t *testing.T) { + smtpClient := NewSmtp() + + tests := []struct { + name string + body string + expected interfaces.ContentType + }{ + { + name: "HTML with html tag", + body: "Test", + expected: interfaces.ContentTypeHTML, + }, + { + name: "HTML with div tag", + body: "
Test content
", + expected: interfaces.ContentTypeHTML, + }, + { + name: "HTML with img tag", + body: "Check this image: ", + expected: interfaces.ContentTypeHTML, + }, + { + name: "Plain text", + body: "This is plain text without any HTML tags.", + expected: interfaces.ContentTypeText, + }, + { + name: "Text with angle brackets", + body: "Price: 100 < 200, Quality: A > B", + expected: interfaces.ContentTypeText, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := smtpClient.detectContentType(tt.body) + if result != tt.expected { + t.Errorf("Expected %s, got %s for body: %s", tt.expected, result, tt.body) + } + }) + } +} + +// 测试邮件头构建 +func TestBuildHeaders(t *testing.T) { + smtpClient := NewSmtp() + smtpClient.SetOption(context.Background(), interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{})) + boundary := "test-boundary" + + tests := []struct { + name string + message interfaces.Message + expected string + }{ + { + name: "HTML with inline images", + message: interfaces.Message{ + Subject: "Test Subject", + InlineImage: []interfaces.Attachment{{Name: "test.jpg"}}, + }, + expected: "multipart/related", + }, + { + name: "HTML with attachments", + message: interfaces.Message{ + Subject: "Test Subject", + Attachment: []interfaces.Attachment{{Name: "test.pdf"}}, + }, + expected: "multipart/mixed", + }, + { + name: "Simple message", + message: interfaces.Message{ + Subject: "Test Subject", + }, + expected: "multipart/alternative", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := smtpClient.buildHeaders(tt.message, boundary) + contentType := headers["Content-Type"] + if !strings.Contains(contentType, tt.expected) { + t.Errorf("Expected Content-Type to contain %s, got %s", tt.expected, contentType) + } + }) + } +} + +// 测试附件类型处理 +func TestAttachmentTypes(t *testing.T) { + tests := []struct { + name string + attachment interfaces.Attachment + expectCID bool + }{ + { + name: "File attachment", + attachment: interfaces.Attachment{ + Name: "document.pdf", + Type: interfaces.AttachmentTypeFile, + }, + expectCID: false, + }, + { + name: "Inline image", + attachment: interfaces.Attachment{ + Name: "image.jpg", + Type: interfaces.AttachmentTypeInline, + CID: "test-image", + }, + expectCID: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.expectCID && tt.attachment.CID == "" { + t.Error("Expected CID for inline attachment") + } + if !tt.expectCID && tt.attachment.Type == interfaces.AttachmentTypeFile { + // File attachments should not have CID + if tt.attachment.CID != "" { + t.Error("File attachment should not have CID") + } + } + }) + } +} + +// 测试消息验证 +func TestMessageValidation2(t *testing.T) { + smtpClient := NewSmtp() + ctx := context.Background() + + // 配置SMTP客户端 + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Fatalf("Failed to configure SMTP: %v", err) + } + + tests := []struct { + name string + message interfaces.Message + wantErr bool + }{ + { + name: "Valid HTML message", + message: interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Test", + Body: "

Test

", + BodyType: interfaces.ContentTypeHTML, + }, + wantErr: false, + }, + { + name: "Valid text message", + message: interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Test", + Body: "Plain text", + BodyType: interfaces.ContentTypeText, + }, + wantErr: false, + }, + { + name: "Message with inline images", + message: interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Test", + Body: "", + InlineImage: []interfaces.Attachment{ + { + Name: "test.jpg", + CID: "test", + Type: interfaces.AttachmentTypeInline, + }, + }, + }, + wantErr: false, + }, + { + name: "Empty recipients", + message: interfaces.Message{ + Subject: "Test", + Body: "Test body", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := smtpClient.validateMessage(tt.message) + if (err != nil) != tt.wantErr { + t.Errorf("validateMessage() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// 测试多部分邮件构建 +func TestMultipartEmailConstruction2(t *testing.T) { + smtpClient := NewSmtp() + ctx := context.Background() + + _, err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + if err != nil { + t.Fatalf("Failed to configure SMTP: %v", err) + } + + message := interfaces.Message{ + To: []string{"recipient@example.com"}, + Subject: "Multipart Test", + BodyType: interfaces.ContentTypeHTML, + Body: "

HTML Content

", + TextBody: "Plain text version", + InlineImage: []interfaces.Attachment{ + { + Data: []byte("fake-image-data"), + ContentType: "image/jpeg", + Name: "test.jpg", + CID: "test-img", + Type: interfaces.AttachmentTypeInline, + }, + }, + Attachment: []interfaces.Attachment{ + { + Data: []byte("fake-pdf-data"), + ContentType: "application/pdf", + Name: "document.pdf", + Type: interfaces.AttachmentTypeFile, + }, + }, + } + + // 测试邮件构建(不实际发送) + boundary := "test-boundary" + headers := smtpClient.buildHeaders(message, boundary) + + // 验证Content-Type + contentType := headers["Content-Type"] + if !strings.Contains(contentType, "multipart/related") { + t.Errorf("Expected multipart/related for message with inline images, got %s", contentType) + } + + // 验证必要的头部字段 + requiredHeaders := []string{"From", "To", "Subject", "Date", "MIME-Version", "Content-Type"} + for _, header := range requiredHeaders { + if _, exists := headers[header]; !exists { + t.Errorf("Missing required header: %s", header) + } + } +} + +// 测试字节数据附件 +func TestByteDataAttachment(t *testing.T) { + attachment := interfaces.Attachment{ + Data: []byte("test file content"), + ContentType: "text/plain", + Name: "test.txt", + Type: interfaces.AttachmentTypeFile, + } + + if len(attachment.Data) == 0 { + t.Error("Attachment data should not be empty") + } + + if attachment.ContentType != "text/plain" { + t.Errorf("Expected content type text/plain, got %s", attachment.ContentType) + } +} + +// 基准测试:HTML邮件构建性能 +func BenchmarkHTMLEmailConstruction(b *testing.B) { + smtpClient := NewSmtp() + ctx := context.Background() + + smtpClient.SetOption(ctx, func(opt *interfaces.Options) { + opt.Smtp = &interfaces.EmailConfigDataSmtp{ + Host: "smtp.example.com", + Port: "587", + Username: "test@example.com", + Password: "password", + } + }) + + message := interfaces.Message{ + To: []string{"test@example.com"}, + Subject: "Benchmark Test", + BodyType: interfaces.ContentTypeHTML, + Body: "

Benchmark

Performance test content

", + TextBody: "Benchmark - Performance test content", + InlineImage: []interfaces.Attachment{ + { + Data: make([]byte, 1024), // 1KB fake image + ContentType: "image/jpeg", + Name: "test.jpg", + CID: "test-img", + Type: interfaces.AttachmentTypeInline, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + boundary := "benchmark-boundary" + headers := smtpClient.buildHeaders(message, boundary) + _ = headers + } +} + +// 测试大附件处理 +func TestLargeAttachmentHandling(t *testing.T) { + // 创建大附件数据(模拟) + largeData := make([]byte, 1024*1024) // 1MB + for i := range largeData { + largeData[i] = byte(i % 256) + } + + attachment := interfaces.Attachment{ + Data: largeData, + ContentType: "application/octet-stream", + Name: "large_file.bin", + Type: interfaces.AttachmentTypeFile, + } + + if len(attachment.Data) != 1024*1024 { + t.Errorf("Expected 1MB data, got %d bytes", len(attachment.Data)) + } + + // 测试是否超过限制(25MB) + maxSize := 25 * 1024 * 1024 + if len(attachment.Data) > maxSize { + t.Errorf("Attachment size %d exceeds limit %d", len(attachment.Data), maxSize) + } +} diff --git a/smtp/smtp.go b/smtp/smtp.go index 8e338ad..0b37c69 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -157,8 +157,8 @@ func (l *Smtp) sendPlain(ctx context.Context, message interfaces.Message) error buffer.WriteString(body) for _, value := range message.Attachment { - if err := l.writeAttachment(buffer, boundary, value.Content, ctx); err != nil { - l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", value.Content, err) + if err := l.writeAttachment(buffer, boundary, value, ctx); err != nil { + l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", value.Name, err) continue } } @@ -182,12 +182,20 @@ func (l *Smtp) sendWithTLS(ctx context.Context, message interfaces.Message) erro l.writeHeader(buffer, headers) // 构建邮件体 - l.writeBody(buffer, boundary, message.Body) + l.writeBody(buffer, boundary, message) - // 处理附件 + // 处理内嵌图片 + for _, inlineImg := range message.InlineImage { + if err := l.writeAttachment(buffer, boundary, inlineImg, ctx); err != nil { + l.Options.Logger.Errorf(ctx, "Failed to process inline image %s: %v", inlineImg.Name, err) + continue + } + } + + // 处理普通附件 for _, attachment := range message.Attachment { - if err := l.writeAttachment(buffer, boundary, attachment.Content, ctx); err != nil { - l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", attachment.Content, err) + if err := l.writeAttachment(buffer, boundary, attachment, ctx); err != nil { + l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", attachment.Name, err) continue } } @@ -255,12 +263,20 @@ func (l *Smtp) sendWithSTARTTLS(ctx context.Context, message interfaces.Message) l.writeHeader(buffer, headers) // 构建邮件体 - l.writeBody(buffer, boundary, message.Body) + l.writeBody(buffer, boundary, message) - // 处理附件 + // 处理内嵌图片 + for _, inlineImg := range message.InlineImage { + if err := l.writeAttachment(buffer, boundary, inlineImg, ctx); err != nil { + l.Options.Logger.Errorf(ctx, "Failed to process inline image %s: %v", inlineImg.Name, err) + continue + } + } + + // 处理普通附件 for _, attachment := range message.Attachment { - if err := l.writeAttachment(buffer, boundary, attachment.Content, ctx); err != nil { - l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", attachment.Content, err) + if err := l.writeAttachment(buffer, boundary, attachment, ctx); err != nil { + l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", attachment.Name, err) continue } } @@ -385,7 +401,15 @@ func (l *Smtp) buildHeaders(message interfaces.Message, boundary string) map[str headers["Subject"] = message.Subject headers["Date"] = time.Now().Format(time.RFC1123Z) headers["MIME-Version"] = "1.0" - headers["Content-Type"] = "multipart/mixed; charset=UTF-8; boundary=" + boundary + + // 根据内容类型和附件情况选择Content-Type + if len(message.InlineImage) > 0 { + headers["Content-Type"] = "multipart/related; charset=UTF-8; boundary=" + boundary + } else if len(message.Attachment) > 0 { + headers["Content-Type"] = "multipart/mixed; charset=UTF-8; boundary=" + boundary + } else { + headers["Content-Type"] = "multipart/alternative; charset=UTF-8; boundary=" + boundary + } if message.ReplyTo != "" { headers["Reply-To"] = message.ReplyTo @@ -405,52 +429,182 @@ func (l *Smtp) writeHeader(buffer *bytes.Buffer, headers map[string]string) { } // 写入邮件体 -func (l *Smtp) writeBody(buffer *bytes.Buffer, boundary, body string) { - buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) - buffer.WriteString("Content-Type: text/html; charset=utf-8\r\n") - buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") - buffer.WriteString(body + "\r\n") +func (l *Smtp) writeBody(buffer *bytes.Buffer, boundary string, message interfaces.Message) { + // 检测内容类型 + bodyType := message.BodyType + if bodyType == interfaces.ContentTypeAuto { + bodyType = l.detectContentType(message.Body) + } + + // 如果有纯文本版本,创建 multipart/alternative + if message.TextBody != "" && bodyType == interfaces.ContentTypeHTML { + altBoundary := "alt-" + boundary + buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buffer.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", altBoundary)) + + // 纯文本版本 + buffer.WriteString(fmt.Sprintf("--%s\r\n", altBoundary)) + buffer.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + buffer.WriteString(message.TextBody + "\r\n") + + // HTML版本 + buffer.WriteString(fmt.Sprintf("--%s\r\n", altBoundary)) + buffer.WriteString("Content-Type: text/html; charset=utf-8\r\n") + buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + buffer.WriteString(message.Body + "\r\n") + + buffer.WriteString(fmt.Sprintf("--%s--\r\n", altBoundary)) + } else { + // 单一内容类型 + buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + if bodyType == interfaces.ContentTypeHTML { + buffer.WriteString("Content-Type: text/html; charset=utf-8\r\n") + } else { + buffer.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + } + buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + buffer.WriteString(message.Body + "\r\n") + } +} + +// 检测内容类型 +func (l *Smtp) detectContentType(body string) interfaces.ContentType { + if strings.Contains(body, "") || strings.Contains(body, "") || + strings.Contains(body, "") || strings.Contains(body, "") || + strings.Contains(body, "
") || strings.Contains(body, "

") || + strings.Contains(body, "
") || strings.Contains(body, " MaxAttachmentSize { - return fmt.Errorf("attachment too large: %d bytes (max: %d)", stat.Size(), MaxAttachmentSize) + return l.writeFileAttachment(buffer, boundary, attachment, ctx) +} + +// 写入普通附件 +func (l *Smtp) writeFileAttachment(buffer *bytes.Buffer, boundary string, attachment interfaces.Attachment, ctx context.Context) error { + var data []byte + var err error + var fileName string + + // 获取数据 + if len(attachment.Data) > 0 { + data = attachment.Data + fileName = attachment.Name + } else { + data, err = l.readAttachmentFile(attachment.Content) + if err != nil { + return err + } + fileName = filepath.Base(attachment.Content) } - file, err := os.Open(fileName) - if err != nil { - return fmt.Errorf("open file failed: %w", err) + if attachment.Name != "" { + fileName = attachment.Name } - defer file.Close() - baseName := filepath.Base(fileName) - mimeType := mime.TypeByExtension(filepath.Ext(fileName)) + // 检测 MIME 类型 + mimeType := attachment.ContentType if mimeType == "" { - mimeType = "application/octet-stream" + mimeType = mime.TypeByExtension(filepath.Ext(fileName)) + if mimeType == "" { + mimeType = "application/octet-stream" + } } buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) buffer.WriteString("Content-Transfer-Encoding: base64\r\n") - buffer.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=%s\r\n", baseName)) - buffer.WriteString(fmt.Sprintf("Content-Type: %s; name=%s\r\n\r\n", mimeType, baseName)) + buffer.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=%s\r\n", fileName)) + buffer.WriteString(fmt.Sprintf("Content-Type: %s; name=%s\r\n\r\n", mimeType, fileName)) - // 流式编码以节省内存 - encoder := base64.NewEncoder(base64.StdEncoding, &lineWrapper{buffer, 0}) - if _, err := io.Copy(encoder, file); err != nil { - return fmt.Errorf("encode file failed: %w", err) + // base64编码 + encoded := base64.StdEncoding.EncodeToString(data) + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + buffer.WriteString(encoded[i:end] + "\r\n") } - encoder.Close() - buffer.WriteString("\r\n") return nil } +// 写入内嵌图片 +func (l *Smtp) writeInlineImage(buffer *bytes.Buffer, boundary string, attachment interfaces.Attachment, ctx context.Context) error { + var data []byte + var err error + var fileName string + + // 获取数据 + if len(attachment.Data) > 0 { + data = attachment.Data + fileName = attachment.Name + } else { + data, err = l.readAttachmentFile(attachment.Content) + if err != nil { + return err + } + fileName = filepath.Base(attachment.Content) + } + + if attachment.Name != "" { + fileName = attachment.Name + } + + // 检测 MIME 类型 + mimeType := attachment.ContentType + if mimeType == "" { + mimeType = mime.TypeByExtension(filepath.Ext(fileName)) + if mimeType == "" { + mimeType = "image/jpeg" // 默认图片类型 + } + } + + // 生成 Content-ID + cid := attachment.CID + if cid == "" { + cid = fmt.Sprintf("img_%d_%s", time.Now().UnixNano(), strings.ReplaceAll(fileName, ".", "_")) + } + + buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buffer.WriteString("Content-Transfer-Encoding: base64\r\n") + buffer.WriteString(fmt.Sprintf("Content-Disposition: inline; filename=%s\r\n", fileName)) + buffer.WriteString(fmt.Sprintf("Content-Type: %s; name=%s\r\n", mimeType, fileName)) + buffer.WriteString(fmt.Sprintf("Content-ID: <%s>\r\n\r\n", cid)) + + // base64编码 + encoded := base64.StdEncoding.EncodeToString(data) + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + buffer.WriteString(encoded[i:end] + "\r\n") + } + + return nil +} + +// 读取附件文件 +func (l *Smtp) readAttachmentFile(fileName string) ([]byte, error) { + stat, err := os.Stat(fileName) + if err != nil { + return nil, fmt.Errorf("file stat failed: %w", err) + } + if stat.Size() > MaxAttachmentSize { + return nil, fmt.Errorf("attachment too large: %d bytes (max: %d)", stat.Size(), MaxAttachmentSize) + } + + return os.ReadFile(fileName) +} + // 行包装器,用于base64编码时的换行 type lineWrapper struct { w io.Writer diff --git a/smtp/smtp_enhanced_test.go b/smtp/smtp_enhanced_test.go index ebfd167..eb43a38 100644 --- a/smtp/smtp_enhanced_test.go +++ b/smtp/smtp_enhanced_test.go @@ -21,7 +21,7 @@ func TestSmtpEnhanced(t *testing.T) { } // 测试不完整配置 - _, err = smtp.SetOption(ctx, func(opt *interfaces.EmailOption) { + _, err = smtp.SetOption(ctx, func(opt *interfaces.Options) { opt.Smtp = &interfaces.EmailConfigDataSmtp{ Host: "smtp.example.com", // 缺少用户名和密码 @@ -32,7 +32,7 @@ func TestSmtpEnhanced(t *testing.T) { } // 测试完整配置 - _, err = smtp.SetOption(ctx, func(opt *interfaces.EmailOption) { + _, err = smtp.SetOption(ctx, func(opt *interfaces.Options) { opt.Smtp = &interfaces.EmailConfigDataSmtp{ Host: "smtp.example.com", Port: "587", @@ -51,7 +51,7 @@ func TestMessageValidation(t *testing.T) { ctx := context.Background() // 设置有效配置 - smtp.SetOption(ctx, func(opt *interfaces.EmailOption) { + smtp.SetOption(ctx, func(opt *interfaces.Options) { opt.Smtp = &interfaces.EmailConfigDataSmtp{ Host: "smtp.example.com", Port: "587", diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go index bc559d7..0b572ea 100644 --- a/smtp/smtp_test.go +++ b/smtp/smtp_test.go @@ -58,7 +58,7 @@ func TestMail(t *testing.T) { ReplyTo: "huangxinyun@dreaminglife.cn", Subject: "测试邮件", Body: "这是测试邮件", //string(by), - Attachment: []interfaces.MessageAttachment{ + Attachment: []interfaces.Attachment{ // { // Name: "/code/statistic/out.xlsx", // ContentType: "",