Compare commits
6
Commits
master
..
eeaa518fc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeaa518fc3 | ||
|
|
04bdd98d6f | ||
|
|
b3a87d0b54 | ||
|
|
f2b8345e93 | ||
|
|
de528fd09b | ||
|
|
e0939d8728 |
+1
-3
@@ -1,3 +1 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
|
||||||
examples/with_env/.env
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/mail"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AddressList 解析逗号/分号分隔的地址串,返回规范化后的 []mail.Address。
|
|
||||||
// 兼容纯地址与 "显示名 <addr>" 形式。
|
|
||||||
func AddressList(s string) ([]*mail.Address, error) {
|
|
||||||
parse := func(v string) ([]*mail.Address, error) {
|
|
||||||
replaced := strings.ReplaceAll(v, ";", ",")
|
|
||||||
return mail.ParseAddressList(replaced)
|
|
||||||
}
|
|
||||||
// 尝试直接解析;若因分号等失败,做简单清理
|
|
||||||
if addrs, err := parse(s); err == nil {
|
|
||||||
return addrs, nil
|
|
||||||
}
|
|
||||||
return parse(strings.TrimSpace(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValidAddress 判断单个地址是否合法(兼容显示名形式)
|
|
||||||
func IsValidAddress(s string) bool {
|
|
||||||
if s == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, err := mail.ParseAddress(s)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractEmail 从地址(可能含显示名)中提取纯邮箱地址,供 SMTP Mail/Rcpt 使用
|
|
||||||
func ExtractEmail(s string) (string, error) {
|
|
||||||
a, err := mail.ParseAddress(s)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return a.Address, nil
|
|
||||||
}
|
|
||||||
+244
-169
@@ -1,218 +1,293 @@
|
|||||||
// Package aliyun 提供阿里云邮件推送(DirectMail)通道。
|
|
||||||
package aliyun
|
package aliyun
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||||
dm20151123 "github.com/alibabacloud-go/dm-20151123/v2/client"
|
dm20151123 "github.com/alibabacloud-go/dm-20151123/v2/client"
|
||||||
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
||||||
"github.com/alibabacloud-go/tea/tea"
|
"github.com/alibabacloud-go/tea/tea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultTimeout 单次 API 调用的默认超时
|
const (
|
||||||
const defaultTimeout = 30 * time.Second
|
MaxRecipients = 100
|
||||||
|
MaxRetries = 3
|
||||||
|
DefaultEndpoint = "dm.aliyuncs.com"
|
||||||
|
)
|
||||||
|
|
||||||
// Config 阿里云邮件推送通道配置
|
|
||||||
type Config struct {
|
|
||||||
AccessKeyID string // AccessKey ID
|
|
||||||
AccessKeySecret string // AccessKey Secret
|
|
||||||
Endpoint string // Endpoint,默认 dm.aliyuncs.com
|
|
||||||
AccountName string // 发信地址(必填)
|
|
||||||
ReplyAddress string // 默认回复地址(可选)
|
|
||||||
Timeout time.Duration // 单次 API 调用的超时,默认 30s
|
|
||||||
}
|
|
||||||
|
|
||||||
// dmMailer 阿里云 DM 客户端的最小接口(便于测试注入 mock)
|
|
||||||
type dmMailer interface {
|
|
||||||
SingleSendMailWithOptions(request *dm20151123.SingleSendMailRequest, runtime *util.RuntimeOptions) (*dm20151123.SingleSendMailResponse, error)
|
|
||||||
SenderStatisticsDetailByParamWithOptions(request *dm20151123.SenderStatisticsDetailByParamRequest, runtime *util.RuntimeOptions) (*dm20151123.SenderStatisticsDetailByParamResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aliyun 阿里云发送通道
|
|
||||||
type Aliyun struct {
|
type Aliyun struct {
|
||||||
cfg Config
|
interfaces.DefaultEmail
|
||||||
|
client *dm20151123.Client
|
||||||
initOnce sync.Once
|
// params *interfaces.EmialConfigDataAliyun
|
||||||
client dmMailer
|
// logger loggerx.LoggerInterface
|
||||||
initErr error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建阿里云通道
|
func NewAliyun() *Aliyun {
|
||||||
func New(cfg Config) *Aliyun {
|
aliyun := &Aliyun{}
|
||||||
if cfg.Endpoint == "" {
|
aliyun.Options = interfaces.DefaultOptions()
|
||||||
cfg.Endpoint = "dm.aliyuncs.com"
|
aliyun.EmailType = interfaces.EmailTypeAliyun
|
||||||
}
|
return aliyun
|
||||||
return &Aliyun{cfg: cfg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name 返回通道名称
|
func (l *Aliyun) SetOption(ctx context.Context, opt ...interfaces.Option) error {
|
||||||
func (a *Aliyun) Name() string { return "aliyun" }
|
for _, o := range opt {
|
||||||
|
o(&l.Options)
|
||||||
// Send 发送一封邮件
|
|
||||||
func (a *Aliyun) Send(ctx context.Context, msg *mailx.Message) error {
|
|
||||||
logger := mailx.LoggerFromContext(ctx)
|
|
||||||
|
|
||||||
if len(msg.To) > 100 {
|
|
||||||
return fmt.Errorf("%w: aliyun up to 100 recipients allowed", mailx.ErrInvalidMessage)
|
|
||||||
}
|
|
||||||
if a.cfg.AccountName == "" {
|
|
||||||
return fmt.Errorf("%w: aliyun AccountName is required", mailx.ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
if len(msg.Inline) > 0 {
|
|
||||||
return fmt.Errorf("%w: aliyun SingleSendMail does not support inline images", mailx.ErrInvalidConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := a.newClient()
|
if l.Options.Aliyun == nil {
|
||||||
|
return fmt.Errorf("Aliyun configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
if err := l.validateConfig(); err != nil {
|
||||||
|
return 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{
|
||||||
|
AccessKeyId: tea.String(l.Options.Aliyun.AccessId),
|
||||||
|
AccessKeySecret: tea.String(l.Options.Aliyun.AccessKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.Options.Aliyun.Endpoint == "" {
|
||||||
|
l.Options.Aliyun.Endpoint = DefaultEndpoint
|
||||||
|
}
|
||||||
|
config.Endpoint = tea.String(l.Options.Aliyun.Endpoint)
|
||||||
|
|
||||||
|
client, err := dm20151123.NewClient(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mailx/aliyun: create client: %w", err)
|
return fmt.Errorf("failed to create Aliyun client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 阿里云 DirectMail 的 HtmlBody 必填;纯文本也需以 HTML 形式传递
|
l.IsSet = true
|
||||||
htmlBody := msg.Body
|
|
||||||
if htmlBody == "" {
|
|
||||||
htmlBody = mailx.EscapeHTML(msg.TextBody)
|
|
||||||
}
|
|
||||||
req := &dm20151123.SingleSendMailRequest{
|
|
||||||
AccountName: tea.String(a.cfg.AccountName),
|
|
||||||
ToAddress: tea.String(strings.Join(msg.To, ",")),
|
|
||||||
Subject: tea.String(msg.Subject),
|
|
||||||
HtmlBody: tea.String(htmlBody),
|
|
||||||
AddressType: tea.Int32(0), // 0:随机账号;1:发信地址
|
|
||||||
}
|
|
||||||
|
|
||||||
replyTo := msg.ReplyTo
|
l.client = client
|
||||||
if replyTo == "" {
|
|
||||||
replyTo = a.cfg.ReplyAddress
|
|
||||||
}
|
|
||||||
if replyTo != "" {
|
|
||||||
req.ReplyToAddress = tea.Bool(false)
|
|
||||||
req.ReplyAddress = tea.String(replyTo)
|
|
||||||
} else {
|
|
||||||
req.ReplyToAddress = tea.Bool(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 ctx 的 deadline 折算为超时,同时应用配置超时
|
|
||||||
runtime := a.runtimeOptions(ctx)
|
|
||||||
if _, err := client.SingleSendMailWithOptions(req, runtime); err != nil {
|
|
||||||
logger.Errorf(ctx, "mailx/aliyun: send failed: %v", err)
|
|
||||||
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
|
|
||||||
}
|
|
||||||
logger.Infof(ctx, "mailx/aliyun: sent to %v subject=%q", msg.To, msg.Subject)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newClient 惰性创建并复用阿里云 SDK 客户端(线程安全)。
|
func (l *Aliyun) Send(ctx context.Context, params interfaces.Message) error {
|
||||||
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
|
if !l.IsSet {
|
||||||
func (a *Aliyun) newClient() (dmMailer, error) {
|
return fmt.Errorf("Aliyun client not initialized")
|
||||||
if a.client != nil {
|
|
||||||
return a.client, a.initErr
|
|
||||||
}
|
}
|
||||||
a.initOnce.Do(func() {
|
|
||||||
config := &openapi.Config{
|
// 验证消息
|
||||||
AccessKeyId: tea.String(a.cfg.AccessKeyID),
|
if err := l.validateMessage(params); err != nil {
|
||||||
AccessKeySecret: tea.String(a.cfg.AccessKeySecret),
|
return fmt.Errorf("invalid message: %w", err)
|
||||||
Endpoint: tea.String(a.cfg.Endpoint),
|
}
|
||||||
|
|
||||||
|
// 重试机制
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
c, err := dm20151123.NewClient(config)
|
|
||||||
if err != nil {
|
lastErr = l.sendEmail(ctx, params)
|
||||||
a.initErr = fmt.Errorf("mailx/aliyun: create client: %w", err)
|
if lastErr == nil {
|
||||||
return
|
l.Options.Logger.Infof(ctx, "Aliyun email sent successfully to %v", params.To)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
a.client = c
|
|
||||||
})
|
l.Options.Logger.Errorf(ctx, "Aliyun send attempt %d failed: %v", i+1, lastErr)
|
||||||
return a.client, a.initErr
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to send Aliyun email after %d attempts: %w", MaxRetries, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runtimeOptions 根据配置与 ctx 计算超时,返回 RuntimeOptions
|
// 同步状态
|
||||||
func (a *Aliyun) runtimeOptions(ctx context.Context) *util.RuntimeOptions {
|
func (l *Aliyun) SyncStatus(ctx context.Context) (resp []interfaces.EmailSendRecord, err error) {
|
||||||
timeout := a.cfg.Timeout
|
|
||||||
if timeout <= 0 {
|
|
||||||
timeout = defaultTimeout
|
|
||||||
}
|
|
||||||
if dl, ok := ctx.Deadline(); ok {
|
|
||||||
if remain := time.Until(dl); remain > 0 && remain < timeout {
|
|
||||||
timeout = remain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ro := &util.RuntimeOptions{}
|
|
||||||
if timeout > 0 {
|
|
||||||
ms := int(timeout / time.Millisecond)
|
|
||||||
ro.SetConnectTimeout(ms)
|
|
||||||
ro.SetReadTimeout(ms)
|
|
||||||
}
|
|
||||||
return ro
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStatus 同步发送状态(阿里云特有能力,非 Sender 接口的一部分)
|
|
||||||
func (a *Aliyun) SyncStatus(ctx context.Context) ([]mailx.EmailSendRecord, error) {
|
|
||||||
logger := mailx.LoggerFromContext(ctx)
|
|
||||||
|
|
||||||
client, err := a.newClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("mailx/aliyun: create client: %w", err)
|
|
||||||
}
|
|
||||||
runtime := a.runtimeOptions(ctx)
|
|
||||||
|
|
||||||
var records []mailx.EmailSendRecord
|
|
||||||
start := ""
|
start := ""
|
||||||
// 分页拉取最近一天的数据
|
|
||||||
|
// 一次同步一天的数据
|
||||||
for {
|
for {
|
||||||
req := &dm20151123.SenderStatisticsDetailByParamRequest{
|
list, next, err := l.getSendStatus(ctx, start)
|
||||||
StartTime: tea.String(time.Now().AddDate(0, 0, -1).Format("2006-01-02 15:04")),
|
l.Options.Logger.Infof(ctx, "list:%+v next:%+v err:%+v", list, next, err)
|
||||||
EndTime: tea.String(time.Now().Format("2006-01-02 15:04")),
|
|
||||||
Length: tea.Int32(100),
|
|
||||||
}
|
|
||||||
if start != "" {
|
|
||||||
req.NextStart = tea.String(start)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.SenderStatisticsDetailByParamWithOptions(req, runtime)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(ctx, "mailx/aliyun: sync status failed: %v", err)
|
return nil, err
|
||||||
return nil, fmt.Errorf("mailx/aliyun: sync status: %w", err)
|
|
||||||
}
|
|
||||||
if resp == nil || resp.Body == nil || resp.Body.Data == nil {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range resp.Body.Data.MailDetail {
|
for _, val := range list {
|
||||||
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(d.LastUpdateTime), time.Local)
|
|
||||||
records = append(records, mailx.EmailSendRecord{
|
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(val.LastUpdateTime), time.Local)
|
||||||
AccountName: tea.StringValue(d.AccountName),
|
|
||||||
|
// 0:成功 2:无效地址 3:垃圾邮件 4:失败
|
||||||
|
record := interfaces.EmailSendRecord{
|
||||||
|
AccountName: tea.StringValue(val.AccountName),
|
||||||
UpdateTime: t.UnixMilli(),
|
UpdateTime: t.UnixMilli(),
|
||||||
ToUser: tea.StringValue(d.ToAddress),
|
ToUser: tea.StringValue(val.ToAddress),
|
||||||
Subject: tea.StringValue(d.Subject),
|
Subject: tea.StringValue(val.Subject),
|
||||||
ErrorMessage: tea.StringValue(d.Message),
|
ErrorMessage: tea.StringValue(val.Message),
|
||||||
Status: mapSendStatus(tea.Int32Value(d.Status)),
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Body.NextStart == nil || *resp.Body.NextStart == "" {
|
switch tea.Int32Value(val.Status) {
|
||||||
|
case 0:
|
||||||
|
record.Status = interfaces.EmailSendStatusSuccess
|
||||||
|
case 2:
|
||||||
|
record.Status = interfaces.EmailSendStatusInvalidAddress
|
||||||
|
case 3:
|
||||||
|
record.Status = interfaces.EmailSendStatusSpam
|
||||||
|
case 4:
|
||||||
|
record.Status = interfaces.EmailSendStatusFailed
|
||||||
|
default:
|
||||||
|
record.Status = interfaces.EmailSendStatusUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = append(resp, record)
|
||||||
|
}
|
||||||
|
if next == nil || len(*next) == 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
start = *resp.Body.NextStart
|
start = *next
|
||||||
}
|
}
|
||||||
return records, nil
|
|
||||||
|
return resp, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapSendStatus 阿里云状态码映射为统一状态
|
func (l *Aliyun) getSendStatus(ctx context.Context, start string) (list []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail, nextStart *string, err error) {
|
||||||
func mapSendStatus(code int32) mailx.EmailSendStatus {
|
now := time.Now().Local()
|
||||||
switch code {
|
senderStatisticsDetailByParamRequest := &dm20151123.SenderStatisticsDetailByParamRequest{
|
||||||
case 0:
|
StartTime: tea.String(now.AddDate(0, 0, -1).Format("2006-01-02 15:04")),
|
||||||
return mailx.EmailSendStatusSuccess
|
EndTime: tea.String(now.Format("2006-01-02 15:04")),
|
||||||
case 2:
|
Length: tea.Int32(100),
|
||||||
return mailx.EmailSendStatusInvalidAddress
|
|
||||||
case 3:
|
|
||||||
return mailx.EmailSendStatusSpam
|
|
||||||
case 4:
|
|
||||||
return mailx.EmailSendStatusFailed
|
|
||||||
default:
|
|
||||||
return mailx.EmailSendStatusUnknown
|
|
||||||
}
|
}
|
||||||
|
if start != "" {
|
||||||
|
senderStatisticsDetailByParamRequest.NextStart = tea.String(start)
|
||||||
|
}
|
||||||
|
runtime := &util.RuntimeOptions{}
|
||||||
|
tryErr := func() (_e error) {
|
||||||
|
defer func() {
|
||||||
|
if r := tea.Recover(recover()); r != nil {
|
||||||
|
_e = r
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// 复制代码运行请自行打印 API 的返回值
|
||||||
|
resp, _err := l.client.SenderStatisticsDetailByParamWithOptions(senderStatisticsDetailByParamRequest, runtime)
|
||||||
|
if _err != nil {
|
||||||
|
l.Options.Logger.Errorf(ctx, "resp:%+v err:%+v", resp, _err)
|
||||||
|
return _err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp == nil || resp.Body == nil || resp.Body.Data == nil {
|
||||||
|
return errors.New("resp.Body.Data is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
list = resp.Body.Data.MailDetail
|
||||||
|
nextStart = resp.Body.NextStart
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
if tryErr != nil {
|
||||||
|
l.Options.Logger.Errorf(ctx, "err:%+v", tryErr)
|
||||||
|
return nil, nil, tryErr
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// l.logger.Errorf(ctx, "resp:%+v err:%+v", error.Message, _err)
|
||||||
|
// return nil, nil, _err
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
package aliyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ mailx.Sender = (*Aliyun)(nil)
|
|
||||||
|
|
||||||
func TestConfigDefaults(t *testing.T) {
|
|
||||||
a := New(Config{})
|
|
||||||
if a.cfg.Endpoint != "dm.aliyuncs.com" {
|
|
||||||
t.Errorf("default endpoint = %q, want dm.aliyuncs.com", a.cfg.Endpoint)
|
|
||||||
}
|
|
||||||
// 显式指定 Endpoint 时不应被覆盖
|
|
||||||
a = New(Config{Endpoint: "dm.eu-central-1.aliyuncs.com"})
|
|
||||||
if a.cfg.Endpoint != "dm.eu-central-1.aliyuncs.com" {
|
|
||||||
t.Errorf("explicit endpoint overwritten: %q", a.cfg.Endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
if got := New(Config{}).Name(); got != "aliyun" {
|
|
||||||
t.Errorf("Name() = %q, want aliyun", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapSendStatus(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
in int32
|
|
||||||
want mailx.EmailSendStatus
|
|
||||||
}{
|
|
||||||
{0, mailx.EmailSendStatusSuccess},
|
|
||||||
{2, mailx.EmailSendStatusInvalidAddress},
|
|
||||||
{3, mailx.EmailSendStatusSpam},
|
|
||||||
{4, mailx.EmailSendStatusFailed},
|
|
||||||
{9, mailx.EmailSendStatusUnknown},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
if got := mapSendStatus(c.in); got != c.want {
|
|
||||||
t.Errorf("mapSendStatus(%d) = %v, want %v", c.in, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendWithoutConfig(t *testing.T) {
|
|
||||||
// 缺少 AccountName 时应在发送前报错,而不是触发网络请求
|
|
||||||
a := New(Config{AccessKeyID: "ak", AccessKeySecret: "sk"})
|
|
||||||
err := a.Send(context.Background(), mailx.NewMessage().
|
|
||||||
To("a@b.com").
|
|
||||||
Subject("s").
|
|
||||||
Build())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Send without AccountName should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
package aliyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
dm20151123 "github.com/alibabacloud-go/dm-20151123/v2/client"
|
|
||||||
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
|
||||||
"github.com/alibabacloud-go/tea/tea"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockDM 实现 dmMailer 接口,记录请求并可按需返回错误
|
|
||||||
type mockDM struct {
|
|
||||||
sendErr error
|
|
||||||
statsErr error
|
|
||||||
lastSend *dm20151123.SingleSendMailRequest
|
|
||||||
lastStats *dm20151123.SenderStatisticsDetailByParamRequest
|
|
||||||
|
|
||||||
// statsPages 模拟分页返回;nil 时返回单个空页
|
|
||||||
statsPages []*dm20151123.SenderStatisticsDetailByParamResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDM) SingleSendMailWithOptions(req *dm20151123.SingleSendMailRequest, _ *util.RuntimeOptions) (*dm20151123.SingleSendMailResponse, error) {
|
|
||||||
m.lastSend = req
|
|
||||||
if m.sendErr != nil {
|
|
||||||
return nil, m.sendErr
|
|
||||||
}
|
|
||||||
return &dm20151123.SingleSendMailResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDM) SenderStatisticsDetailByParamWithOptions(req *dm20151123.SenderStatisticsDetailByParamRequest, _ *util.RuntimeOptions) (*dm20151123.SenderStatisticsDetailByParamResponse, error) {
|
|
||||||
m.lastStats = req
|
|
||||||
if m.statsErr != nil {
|
|
||||||
return nil, m.statsErr
|
|
||||||
}
|
|
||||||
if len(m.statsPages) > 0 {
|
|
||||||
resp := m.statsPages[0]
|
|
||||||
m.statsPages = m.statsPages[1:]
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
return &dm20151123.SenderStatisticsDetailByParamResponse{Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendSuccess 验证完整发送流程与请求构造
|
|
||||||
func TestSendSuccess(t *testing.T) {
|
|
||||||
mock := &mockDM{}
|
|
||||||
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com", ReplyAddress: "r@example.com"}, client: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
To("a@example.com", "b@example.com").
|
|
||||||
Subject("hi").
|
|
||||||
HTML("<b>html</b>").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
if err := a.Send(context.Background(), msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
req := mock.lastSend
|
|
||||||
if req == nil {
|
|
||||||
t.Fatal("no request captured")
|
|
||||||
}
|
|
||||||
if tea.StringValue(req.AccountName) != "noreply@example.com" {
|
|
||||||
t.Errorf("AccountName = %q", tea.StringValue(req.AccountName))
|
|
||||||
}
|
|
||||||
if tea.StringValue(req.ToAddress) != "a@example.com,b@example.com" {
|
|
||||||
t.Errorf("ToAddress = %q", tea.StringValue(req.ToAddress))
|
|
||||||
}
|
|
||||||
if tea.StringValue(req.Subject) != "hi" {
|
|
||||||
t.Errorf("Subject = %q", tea.StringValue(req.Subject))
|
|
||||||
}
|
|
||||||
if tea.StringValue(req.HtmlBody) != "<b>html</b>" {
|
|
||||||
t.Errorf("HtmlBody = %q", tea.StringValue(req.HtmlBody))
|
|
||||||
}
|
|
||||||
if tea.BoolValue(req.ReplyToAddress) {
|
|
||||||
t.Errorf("ReplyToAddress = %v, want false (uses custom ReplyAddress)", tea.BoolValue(req.ReplyToAddress))
|
|
||||||
}
|
|
||||||
if tea.StringValue(req.ReplyAddress) != "r@example.com" {
|
|
||||||
t.Errorf("ReplyAddress = %q", tea.StringValue(req.ReplyAddress))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendTextOnly 验证纯文本正文被转义为 HTML
|
|
||||||
func TestSendTextOnly(t *testing.T) {
|
|
||||||
mock := &mockDM{}
|
|
||||||
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").Text("a < b & c").Build()
|
|
||||||
if err := a.Send(context.Background(), msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := tea.StringValue(mock.lastSend.HtmlBody)
|
|
||||||
if got != "a < b & c" {
|
|
||||||
t.Errorf("escaped HtmlBody = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendMsgReplyTo 验证 msg.ReplyTo 优先于 cfg.ReplyAddress
|
|
||||||
func TestSendMsgReplyTo(t *testing.T) {
|
|
||||||
mock := &mockDM{}
|
|
||||||
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com", ReplyAddress: "cfg@example.com"}, client: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").ReplyTo("msg@example.com").Build()
|
|
||||||
if err := a.Send(context.Background(), msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if tea.StringValue(mock.lastSend.ReplyAddress) != "msg@example.com" {
|
|
||||||
t.Errorf("ReplyAddress = %q, want msg@example.com", tea.StringValue(mock.lastSend.ReplyAddress))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendNoReplyTo 验证无回复地址时 ReplyToAddress=true
|
|
||||||
func TestSendNoReplyTo(t *testing.T) {
|
|
||||||
mock := &mockDM{}
|
|
||||||
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
|
||||||
if err := a.Send(context.Background(), msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !tea.BoolValue(mock.lastSend.ReplyToAddress) {
|
|
||||||
t.Errorf("ReplyToAddress = %v, want true", tea.BoolValue(mock.lastSend.ReplyToAddress))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendTooManyRecipients 验证超过 100 收件人报错
|
|
||||||
func TestSendTooManyRecipients(t *testing.T) {
|
|
||||||
mock := &mockDM{}
|
|
||||||
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
|
|
||||||
|
|
||||||
to := make([]string, 101)
|
|
||||||
for i := range to {
|
|
||||||
to[i] = "u@example.com"
|
|
||||||
}
|
|
||||||
msg := mailx.NewMessage().To(to...).Subject("s").Build()
|
|
||||||
err := a.Send(context.Background(), msg)
|
|
||||||
if err == nil || !errors.Is(err, mailx.ErrInvalidMessage) {
|
|
||||||
t.Fatalf("err = %v, want ErrInvalidMessage", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendNoAccount 验证缺少 AccountName 报错
|
|
||||||
func TestSendNoAccount(t *testing.T) {
|
|
||||||
a := &Aliyun{cfg: Config{}, client: &mockDM{}}
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
|
||||||
err := a.Send(context.Background(), msg)
|
|
||||||
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
|
|
||||||
t.Fatalf("err = %v, want ErrInvalidConfig", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendInlineError 验证内嵌图片报错
|
|
||||||
func TestSendInlineError(t *testing.T) {
|
|
||||||
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: &mockDM{}}
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").
|
|
||||||
InlineImageBytes("cid1", "a.png", []byte{1}).
|
|
||||||
Build()
|
|
||||||
err := a.Send(context.Background(), msg)
|
|
||||||
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
|
|
||||||
t.Fatalf("err = %v, want ErrInvalidConfig", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendFail 验证发送失败包装为 ErrSendFailed
|
|
||||||
func TestSendFail(t *testing.T) {
|
|
||||||
mock := &mockDM{sendErr: errors.New("aliyun down")}
|
|
||||||
a := &Aliyun{cfg: Config{AccountName: "noreply@example.com"}, client: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
|
||||||
err := a.Send(context.Background(), msg)
|
|
||||||
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
|
|
||||||
t.Fatalf("err = %v, want ErrSendFailed", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRuntimeOptions 验证超时计算
|
|
||||||
func TestRuntimeOptions(t *testing.T) {
|
|
||||||
// 默认超时 30s
|
|
||||||
a := &Aliyun{cfg: Config{}}
|
|
||||||
ro := a.runtimeOptions(context.Background())
|
|
||||||
if ro == nil {
|
|
||||||
t.Fatal("runtime options nil")
|
|
||||||
}
|
|
||||||
// 配置超时 1s
|
|
||||||
a = &Aliyun{cfg: Config{Timeout: time.Second}}
|
|
||||||
ro = a.runtimeOptions(context.Background())
|
|
||||||
// ctx 已有更早 deadline
|
|
||||||
early, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
ro = a.runtimeOptions(early)
|
|
||||||
_ = ro // 只要不 panic 即可
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncStatusSuccess 验证状态同步记录映射
|
|
||||||
func TestSyncStatusSuccess(t *testing.T) {
|
|
||||||
mock := &mockDM{
|
|
||||||
statsPages: []*dm20151123.SenderStatisticsDetailByParamResponse{
|
|
||||||
{
|
|
||||||
Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{
|
|
||||||
Data: &dm20151123.SenderStatisticsDetailByParamResponseBodyData{
|
|
||||||
MailDetail: []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail{
|
|
||||||
{
|
|
||||||
AccountName: tea.String("noreply@example.com"),
|
|
||||||
ToAddress: tea.String("a@example.com"),
|
|
||||||
Subject: tea.String("hi"),
|
|
||||||
Status: tea.Int32(0),
|
|
||||||
Message: tea.String(""),
|
|
||||||
LastUpdateTime: tea.String("2026-08-15T10:00Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AccountName: tea.String("noreply@example.com"),
|
|
||||||
ToAddress: tea.String("b@example.com"),
|
|
||||||
Subject: tea.String("bye"),
|
|
||||||
Status: tea.Int32(4),
|
|
||||||
Message: tea.String("rejected"),
|
|
||||||
LastUpdateTime: tea.String("2026-08-15T11:00Z"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NextStart: tea.String("page2"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Body: &dm20151123.SenderStatisticsDetailByParamResponseBody{
|
|
||||||
Data: &dm20151123.SenderStatisticsDetailByParamResponseBodyData{
|
|
||||||
MailDetail: []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail{
|
|
||||||
{
|
|
||||||
ToAddress: tea.String("c@example.com"),
|
|
||||||
Subject: tea.String("page2 mail"),
|
|
||||||
Status: tea.Int32(2),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
a := &Aliyun{cfg: Config{}, client: mock}
|
|
||||||
|
|
||||||
records, err := a.SyncStatus(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(records) != 3 {
|
|
||||||
t.Fatalf("records len = %d, want 3 (pagination)", len(records))
|
|
||||||
}
|
|
||||||
if records[0].Status != mailx.EmailSendStatusSuccess {
|
|
||||||
t.Errorf("records[0].Status = %v, want Success", records[0].Status)
|
|
||||||
}
|
|
||||||
if records[1].Status != mailx.EmailSendStatusFailed {
|
|
||||||
t.Errorf("records[1].Status = %v, want Failed", records[1].Status)
|
|
||||||
}
|
|
||||||
if records[1].ErrorMessage != "rejected" {
|
|
||||||
t.Errorf("records[1].ErrorMessage = %q", records[1].ErrorMessage)
|
|
||||||
}
|
|
||||||
if records[2].Status != mailx.EmailSendStatusInvalidAddress {
|
|
||||||
t.Errorf("records[2].Status = %v, want InvalidAddress", records[2].Status)
|
|
||||||
}
|
|
||||||
// 验证第二页请求带 NextStart
|
|
||||||
if mock.lastStats == nil || tea.StringValue(mock.lastStats.NextStart) != "page2" {
|
|
||||||
t.Errorf("second page NextStart = %v", mock.lastStats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncStatusFail 验证状态同步失败包装错误
|
|
||||||
func TestSyncStatusFail(t *testing.T) {
|
|
||||||
mock := &mockDM{statsErr: errors.New("api error")}
|
|
||||||
a := &Aliyun{cfg: Config{}, client: mock}
|
|
||||||
if _, err := a.SyncStatus(context.Background()); err == nil {
|
|
||||||
t.Fatal("SyncStatus should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewClientReuse 验证 client 惰性复用(只初始化一次)
|
|
||||||
func TestNewClientReuse(t *testing.T) {
|
|
||||||
a := New(Config{AccessKeyID: "ak", AccessKeySecret: "sk", AccountName: "n@e.com"})
|
|
||||||
c1, err := a.newClient()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
c2, err := a.newClient()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if c1 != c2 {
|
|
||||||
t.Error("client should be reused")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+48
-27
@@ -5,52 +5,73 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
"code.yun.ink/pkg/mailx/aliyun"
|
"code.yun.ink/pkg/mailx/aliyun"
|
||||||
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSend(t *testing.T) {
|
func TestSend(t *testing.T) {
|
||||||
if testing.Short() {
|
ali := aliyun.NewAliyun()
|
||||||
t.Skip("skip real send in short mode")
|
|
||||||
}
|
|
||||||
client := aliyun.New(aliyun.Config{
|
|
||||||
AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
|
|
||||||
AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
|
|
||||||
Endpoint: "dm.aliyuncs.com",
|
|
||||||
AccountName: "test@email.aisz.org",
|
|
||||||
ReplyAddress: "287852692@qq.com",
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
body, err := os.ReadFile("./assets/zh_Hant.html")
|
|
||||||
|
err := ali.SetOption(ctx, interfaces.SetAliyun(&interfaces.EmialConfigDataAliyun{
|
||||||
|
AccessId: "LTAI5tEQ8L8fmDir8udD3CFr",
|
||||||
|
AccessKey: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
|
||||||
|
Endpoint: "dm.aliyuncs.com",
|
||||||
|
AccountName: "test@email.aisz.org", //"test@email.aisz.org",
|
||||||
|
ReplyAddress: "287852692@qq.com",
|
||||||
|
}))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Log(string(body))
|
|
||||||
|
|
||||||
err = client.Send(ctx, mailx.NewMessage().
|
by, err := os.ReadFile("./assets/zh_Hant.html")
|
||||||
To("995116474@qq.com").
|
if err != nil {
|
||||||
Subject("测试主题").
|
t.Fatal(err)
|
||||||
Body(string(body)).
|
}
|
||||||
Build())
|
|
||||||
|
t.Log(string(by))
|
||||||
|
|
||||||
|
err = ali.Send(ctx, interfaces.Message{
|
||||||
|
To: []string{"995116474@qq.com"},
|
||||||
|
Subject: "测试主题",
|
||||||
|
Body: string(by),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal("resp err", err)
|
t.Fatal("resp err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Log("send success")
|
t.Log("send success")
|
||||||
}
|
}
|
||||||
|
|
||||||
// func TestSyncStatus(t *testing.T) {
|
// func TestSyncStatus(t *testing.T) {
|
||||||
// client := aliyun.New(aliyun.Config{
|
// aliyun := &aliyun.Aliyun{}
|
||||||
// AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
|
|
||||||
// AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
|
|
||||||
// Endpoint: "dm.aliyuncs.com",
|
|
||||||
// AccountName: "test@email.aisz.org",
|
|
||||||
// ReplyAddress: "287852692@qq.com",
|
|
||||||
// })
|
|
||||||
// ctx := context.Background()
|
// ctx := context.Background()
|
||||||
// list, err := client.SyncStatus(ctx)
|
|
||||||
|
// global.Logger = loggerx.NewLogger(ctx)
|
||||||
|
|
||||||
|
// ali, err := aliyun.InitEmail(ctx, interfaces.EmailConfigData{
|
||||||
|
// Aliyun: &interfaces.EmialConfigDataAliyun{
|
||||||
|
// AccessId: "LTAI5tEQ8L8fmDir8udD3CFr",
|
||||||
|
// AccessKey: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
|
||||||
|
// Endpoint: "dm.aliyuncs.com",
|
||||||
|
// AccountName: "test@email.aisz.org",
|
||||||
|
// ReplyAddress: "287852692@qq.com",
|
||||||
|
// },
|
||||||
|
// })
|
||||||
// if err != nil {
|
// if err != nil {
|
||||||
// t.Fatal(err)
|
// t.Fatal(err)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
// list, err := ali.SyncStatus(ctx)
|
||||||
|
// if err != nil {
|
||||||
|
// t.Fatal(err)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// _ = list
|
||||||
|
|
||||||
|
// // global.Logger.Infof(ctx, "status: %v", list)
|
||||||
|
|
||||||
// t.Log("status:", list)
|
// t.Log("status:", list)
|
||||||
|
|
||||||
// }
|
// }
|
||||||
|
|||||||
+169
-97
@@ -1,137 +1,209 @@
|
|||||||
// Package aws 提供 Amazon SES 邮件发送通道。
|
|
||||||
package aws
|
package aws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||||
"github.com/aws/aws-sdk-go/aws/session"
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
"github.com/aws/aws-sdk-go/service/ses"
|
"github.com/aws/aws-sdk-go/service/ses"
|
||||||
"github.com/aws/aws-sdk-go/service/ses/sesiface"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultTimeout 单次 API 调用的默认超时
|
const (
|
||||||
const defaultTimeout = 30 * time.Second
|
MaxRetries = 3
|
||||||
|
DefaultRegion = "us-east-1"
|
||||||
|
MaxRecipients = 50 // AWS SES limit
|
||||||
|
)
|
||||||
|
|
||||||
// Config AWS SES 通道配置
|
|
||||||
type Config struct {
|
|
||||||
AccessKeyID string // AccessKey ID
|
|
||||||
AccessKeySecret string // AccessKey Secret
|
|
||||||
Region string // Region,默认 ap-northeast-1
|
|
||||||
Sender string // 默认发件人(必填,AWS 需要预先验证发件地址)
|
|
||||||
Timeout time.Duration // 单次 API 调用的超时,默认 30s;0 表示交给 ctx 控制
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aws AWS SES 发送通道
|
|
||||||
type Aws struct {
|
type Aws struct {
|
||||||
cfg Config
|
interfaces.DefaultEmail
|
||||||
|
sesClient *ses.SES
|
||||||
initOnce sync.Once
|
|
||||||
svc sesiface.SESAPI
|
|
||||||
initErr error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建 AWS 通道
|
func NewAws() *Aws {
|
||||||
func New(cfg Config) *Aws {
|
aws := &Aws{}
|
||||||
if cfg.Region == "" {
|
aws.Options = interfaces.DefaultOptions()
|
||||||
cfg.Region = "ap-northeast-1"
|
aws.EmailType = interfaces.EmailTypeAws
|
||||||
}
|
return aws
|
||||||
return &Aws{cfg: cfg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name 返回通道名称
|
func (l *Aws) SetOption(ctx context.Context, opt ...interfaces.Option) error {
|
||||||
func (a *Aws) Name() string { return "aws" }
|
for _, o := range opt {
|
||||||
|
o(&l.Options)
|
||||||
// Send 发送一封邮件
|
|
||||||
func (a *Aws) Send(ctx context.Context, msg *mailx.Message) error {
|
|
||||||
logger := mailx.LoggerFromContext(ctx)
|
|
||||||
|
|
||||||
sender := msg.From
|
|
||||||
if sender == "" {
|
|
||||||
sender = a.cfg.Sender
|
|
||||||
}
|
|
||||||
if sender == "" {
|
|
||||||
return fmt.Errorf("%w: aws sender is required", mailx.ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
if len(msg.Inline) > 0 {
|
|
||||||
return fmt.Errorf("%w: aws SendEmail does not support inline images; use raw message mode", mailx.ErrInvalidConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := a.client()
|
if l.Options.Aws == nil {
|
||||||
if err != nil {
|
return fmt.Errorf("AWS configuration is required")
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input := &ses.SendEmailInput{
|
// 验证配置
|
||||||
Source: aws.String(sender),
|
if err := l.validateConfig(); err != nil {
|
||||||
Destination: &ses.Destination{
|
return fmt.Errorf("invalid AWS config: %w", err)
|
||||||
ToAddresses: aws.StringSlice(msg.To),
|
|
||||||
CcAddresses: aws.StringSlice(msg.Cc),
|
|
||||||
BccAddresses: aws.StringSlice(msg.Bcc),
|
|
||||||
},
|
|
||||||
Message: &ses.Message{
|
|
||||||
Subject: &ses.Content{Data: aws.String(msg.Subject), Charset: aws.String("UTF-8")},
|
|
||||||
Body: awsBody(msg),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if msg.ReplyTo != "" {
|
|
||||||
input.ReplyToAddresses = aws.StringSlice([]string{msg.ReplyTo})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := a.withTimeout(ctx)
|
if l.Options.Aws.Region == "" {
|
||||||
defer cancel()
|
l.Options.Aws.Region = DefaultRegion
|
||||||
if _, err := svc.SendEmailWithContext(ctx, input); err != nil {
|
|
||||||
logger.Errorf(ctx, "mailx/aws: send to %v failed: %v", msg.To, err)
|
|
||||||
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
|
|
||||||
}
|
}
|
||||||
logger.Infof(ctx, "mailx/aws: sent to %v subject=%q", msg.To, msg.Subject)
|
|
||||||
|
// 初始化SES客户端
|
||||||
|
if err := l.initSESClient(); err != nil {
|
||||||
|
return 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)
|
||||||
|
|
||||||
|
l.IsSet = true
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// client 惰性初始化并复用 SES 客户端(线程安全)。
|
func (l *Aws) Send(ctx context.Context, params interfaces.Message) error {
|
||||||
// 若已通过测试或其他方式注入 svc,则直接返回注入的客户端。
|
if !l.IsSet {
|
||||||
func (a *Aws) client() (sesiface.SESAPI, error) {
|
return fmt.Errorf("AWS SES client not initialized")
|
||||||
if a.svc != nil {
|
|
||||||
return a.svc, a.initErr
|
|
||||||
}
|
}
|
||||||
a.initOnce.Do(func() {
|
|
||||||
sess, err := session.NewSession(&aws.Config{
|
// 验证消息
|
||||||
Region: aws.String(a.cfg.Region),
|
if err := l.validateMessage(params); err != nil {
|
||||||
Credentials: credentials.NewStaticCredentials(a.cfg.AccessKeyID, a.cfg.AccessKeySecret, ""),
|
return fmt.Errorf("invalid message: %w", err)
|
||||||
})
|
}
|
||||||
if err != nil {
|
|
||||||
a.initErr = fmt.Errorf("%w: create aws session: %v", mailx.ErrInvalidConfig, err)
|
// 重试机制
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
a.svc = ses.New(sess)
|
|
||||||
})
|
lastErr = l.sendEmail(ctx, params)
|
||||||
return a.svc, a.initErr
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// withTimeout 叠加配置超时到 ctx(已有更早 deadline 时保持不变)
|
// 验证配置
|
||||||
func (a *Aws) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (l *Aws) validateConfig() error {
|
||||||
if a.cfg.Timeout <= 0 {
|
if l.Options.Aws.AccessId == "" {
|
||||||
return ctx, func() {}
|
return errors.New("AccessId is required")
|
||||||
}
|
}
|
||||||
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= a.cfg.Timeout {
|
if l.Options.Aws.AccessSecret == "" {
|
||||||
return ctx, func() {}
|
return errors.New("AccessSecret is required")
|
||||||
}
|
}
|
||||||
return context.WithTimeout(ctx, a.cfg.Timeout)
|
if l.Options.Aws.Sender == "" {
|
||||||
|
return errors.New("Sender is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// awsBody 构造 SES Body:优先使用 Html,其次 Text;两者都有时同时提供
|
// 验证消息
|
||||||
func awsBody(msg *mailx.Message) *ses.Body {
|
func (l *Aws) validateMessage(params interfaces.Message) error {
|
||||||
body := &ses.Body{}
|
if len(params.To) == 0 {
|
||||||
if msg.Body != "" {
|
return errors.New("at least one recipient is required")
|
||||||
body.Html = &ses.Content{Data: aws.String(msg.Body), Charset: aws.String("UTF-8")}
|
|
||||||
}
|
}
|
||||||
if msg.TextBody != "" {
|
if len(params.To) > MaxRecipients {
|
||||||
body.Text = &ses.Content{Data: aws.String(msg.TextBody), Charset: aws.String("UTF-8")}
|
return fmt.Errorf("too many recipients: %d (max: %d)", len(params.To), MaxRecipients)
|
||||||
}
|
}
|
||||||
return body
|
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),
|
||||||
|
Credentials: credentials.NewStaticCredentials(l.Options.Aws.AccessId, l.Options.Aws.AccessSecret, ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := session.NewSession(&config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create AWS session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
},
|
||||||
|
Body: &ses.Body{
|
||||||
|
Html: &ses.Content{
|
||||||
|
Data: aws.String(params.Body),
|
||||||
|
Charset: aws.String("UTF-8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送邮件
|
||||||
|
input := &ses.SendEmailInput{
|
||||||
|
Destination: destination,
|
||||||
|
Message: message,
|
||||||
|
Source: aws.String(l.Options.Aws.Sender),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置回复地址
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package aws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ mailx.Sender = (*Aws)(nil)
|
|
||||||
|
|
||||||
func TestConfigDefaults(t *testing.T) {
|
|
||||||
a := New(Config{})
|
|
||||||
if a.cfg.Region != "ap-northeast-1" {
|
|
||||||
t.Errorf("default region = %q, want ap-northeast-1", a.cfg.Region)
|
|
||||||
}
|
|
||||||
// 显式指定 Region 时不应被覆盖
|
|
||||||
a = New(Config{Region: "us-east-1"})
|
|
||||||
if a.cfg.Region != "us-east-1" {
|
|
||||||
t.Errorf("explicit region overwritten: %q", a.cfg.Region)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
if got := New(Config{}).Name(); got != "aws" {
|
|
||||||
t.Errorf("Name() = %q, want aws", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendWithoutSender(t *testing.T) {
|
|
||||||
// 缺少 Sender 时应立即报错,不创建 AWS 会话
|
|
||||||
a := New(Config{})
|
|
||||||
err := a.Send(context.Background(), mailx.NewMessage().
|
|
||||||
To("a@b.com").
|
|
||||||
Subject("s").
|
|
||||||
Build())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Send without sender should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package aws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/aws/request"
|
|
||||||
"github.com/aws/aws-sdk-go/service/ses"
|
|
||||||
"github.com/aws/aws-sdk-go/service/ses/sesiface"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockSES 通过嵌入 sesiface.SESAPI 实现 mock,仅覆盖 SendEmailWithContext
|
|
||||||
type mockSES struct {
|
|
||||||
sesiface.SESAPI
|
|
||||||
sendErr error
|
|
||||||
lastIn *ses.SendEmailInput
|
|
||||||
lastCtx context.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockSES) SendEmailWithContext(ctx aws.Context, input *ses.SendEmailInput, _ ...request.Option) (*ses.SendEmailOutput, error) {
|
|
||||||
m.lastCtx = ctx
|
|
||||||
m.lastIn = input
|
|
||||||
if m.sendErr != nil {
|
|
||||||
return nil, m.sendErr
|
|
||||||
}
|
|
||||||
return &ses.SendEmailOutput{MessageId: aws.String("mock-id")}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendSuccess 验证完整发送流程与输入构造
|
|
||||||
func TestSendSuccess(t *testing.T) {
|
|
||||||
mock := &mockSES{}
|
|
||||||
a := &Aws{cfg: Config{Sender: "noreply@example.com"}, svc: 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").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
if err := a.Send(context.Background(), msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
in := mock.lastIn
|
|
||||||
if in == nil {
|
|
||||||
t.Fatal("no input captured")
|
|
||||||
}
|
|
||||||
if *in.Source != "noreply@example.com" {
|
|
||||||
t.Errorf("Source = %q", *in.Source)
|
|
||||||
}
|
|
||||||
if len(in.Destination.ToAddresses) != 2 || len(in.Destination.CcAddresses) != 1 || len(in.Destination.BccAddresses) != 1 {
|
|
||||||
t.Errorf("dest addresses wrong: %+v", in.Destination)
|
|
||||||
}
|
|
||||||
if len(in.ReplyToAddresses) != 1 || *in.ReplyToAddresses[0] != "reply@example.com" {
|
|
||||||
t.Errorf("ReplyTo = %+v", in.ReplyToAddresses)
|
|
||||||
}
|
|
||||||
if in.Message.Body.Html == nil || in.Message.Body.Text == nil {
|
|
||||||
t.Errorf("body should have both html and text: %+v", in.Message.Body)
|
|
||||||
}
|
|
||||||
if *in.Message.Subject.Charset != "UTF-8" {
|
|
||||||
t.Errorf("subject charset = %q", *in.Message.Subject.Charset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendFromFallback 验证 msg.From 为空时回退 cfg.Sender
|
|
||||||
func TestSendFromFallback(t *testing.T) {
|
|
||||||
mock := &mockSES{}
|
|
||||||
a := &Aws{cfg: Config{Sender: "cfg-sender@example.com"}, svc: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
|
||||||
if err := a.Send(context.Background(), msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if *mock.lastIn.Source != "cfg-sender@example.com" {
|
|
||||||
t.Errorf("Source = %q, want cfg-sender", *mock.lastIn.Source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendFail 验证发送失败包装为 ErrSendFailed
|
|
||||||
func TestSendFail(t *testing.T) {
|
|
||||||
mock := &mockSES{sendErr: errors.New("aws down")}
|
|
||||||
a := &Aws{cfg: Config{Sender: "s@example.com"}, svc: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
|
||||||
err := a.Send(context.Background(), msg)
|
|
||||||
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
|
|
||||||
t.Fatalf("err = %v, want ErrSendFailed", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendNoSender 验证缺少 Sender 时报错且不调用 SDK
|
|
||||||
func TestSendNoSender(t *testing.T) {
|
|
||||||
mock := &mockSES{}
|
|
||||||
a := &Aws{cfg: Config{}, svc: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
|
||||||
err := a.Send(context.Background(), msg)
|
|
||||||
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
|
|
||||||
t.Fatalf("err = %v, want ErrInvalidConfig", err)
|
|
||||||
}
|
|
||||||
if mock.lastIn != nil {
|
|
||||||
t.Error("SDK should not be called")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendInlineError 验证内嵌图片返回明确错误
|
|
||||||
func TestSendInlineError(t *testing.T) {
|
|
||||||
mock := &mockSES{}
|
|
||||||
a := &Aws{cfg: Config{Sender: "s@example.com"}, svc: mock}
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@example.com").Subject("s").
|
|
||||||
InlineImageBytes("cid1", "a.png", []byte{1}).
|
|
||||||
Build()
|
|
||||||
err := a.Send(context.Background(), msg)
|
|
||||||
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
|
|
||||||
t.Fatalf("err = %v, want ErrInvalidConfig", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAwsBody 验证 awsBody 各分支
|
|
||||||
func TestAwsBody(t *testing.T) {
|
|
||||||
// 仅 HTML
|
|
||||||
b := awsBody(&mailx.Message{Body: "<b>hi</b>"})
|
|
||||||
if b.Html == nil || b.Text != nil {
|
|
||||||
t.Errorf("html only: %+v", b)
|
|
||||||
}
|
|
||||||
// 仅 Text
|
|
||||||
b = awsBody(&mailx.Message{TextBody: "plain"})
|
|
||||||
if b.Html != nil || b.Text == nil {
|
|
||||||
t.Errorf("text only: %+v", b)
|
|
||||||
}
|
|
||||||
// 两者都有
|
|
||||||
b = awsBody(&mailx.Message{Body: "<b>hi</b>", TextBody: "plain"})
|
|
||||||
if b.Html == nil || b.Text == nil {
|
|
||||||
t.Errorf("both: %+v", b)
|
|
||||||
}
|
|
||||||
// 都没有
|
|
||||||
b = awsBody(&mailx.Message{})
|
|
||||||
if b.Html != nil || b.Text != nil {
|
|
||||||
t.Errorf("neither: %+v", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWithTimeout 验证超时叠加逻辑
|
|
||||||
func TestWithTimeout(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 未配置 Timeout:原样返回
|
|
||||||
a := &Aws{cfg: Config{}}
|
|
||||||
c, cancel := a.withTimeout(ctx)
|
|
||||||
if c != ctx {
|
|
||||||
t.Error("no timeout should return original ctx")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
// 配置了 Timeout:返回带 deadline 的 ctx
|
|
||||||
a = &Aws{cfg: Config{Timeout: time.Second}}
|
|
||||||
c, cancel = a.withTimeout(ctx)
|
|
||||||
if c == ctx {
|
|
||||||
t.Error("with timeout should return new ctx")
|
|
||||||
}
|
|
||||||
if _, ok := c.Deadline(); !ok {
|
|
||||||
t.Error("new ctx should have deadline")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
// ctx 已有更早 deadline:保持原 ctx
|
|
||||||
early, ecancel := context.WithTimeout(ctx, time.Millisecond)
|
|
||||||
defer ecancel()
|
|
||||||
c, cancel = a.withTimeout(early)
|
|
||||||
if c != early {
|
|
||||||
t.Error("earlier deadline should keep original ctx")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
@@ -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) error {
|
||||||
|
|
||||||
|
for _, o := range opt {
|
||||||
|
o(&l.Options)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Options.Logger.Infof(ctx, "Aws:%+v", l.Options.Aws)
|
||||||
|
if l.Options.Aws == nil {
|
||||||
|
return errors.New("not aws")
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.Options.Aws.Region == "" {
|
||||||
|
l.Options.Aws.Region = "ap-northeast-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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
|
||||||
|
}
|
||||||
+29
-16
@@ -4,30 +4,43 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
"code.yun.ink/pkg/mailx/aws"
|
"code.yun.ink/pkg/mailx/aws"
|
||||||
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://ap-northeast-1.console.aws.amazon.com/ses/home?region=ap-northeast-1#/identities
|
// https://ap-northeast-1.console.aws.amazon.com/ses/home?region=ap-northeast-1#/identities
|
||||||
|
|
||||||
func TestSend(t *testing.T) {
|
func TestSend(t *testing.T) {
|
||||||
if testing.Short() {
|
// email:
|
||||||
t.Skip("skip real send in short mode")
|
// #区域
|
||||||
}
|
// AwsRegion: "ap-northeast-1"
|
||||||
client := aws.New(aws.Config{
|
// #秘钥ID
|
||||||
AccessKeyID: "AKIAU6GD3MNRHKR4RZG5",
|
// AwsAccessKeyId: "AKIAU6GD3MNRHKR4RZG5"
|
||||||
AccessKeySecret: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l",
|
// #秘钥
|
||||||
Region: "ap-northeast-1",
|
// AwsSecretAccessKey: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l"
|
||||||
Sender: "chenlihan@dreaminglife.cn",
|
// #发件人
|
||||||
})
|
// Source: "chenlihan@dreaminglife.cn"
|
||||||
|
|
||||||
|
ini := aws.NewAws()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := client.Send(ctx, mailx.NewMessage().
|
|
||||||
From("chenlihan@dreaminglife.cn").
|
err := ini.SetOption(ctx, interfaces.SetAws(&interfaces.EmailConfigDataAws{
|
||||||
To("huangxinyun@dreaminglife.cn").
|
AccessId: "AKIAU6GD3MNRHKR4RZG5",
|
||||||
Subject("主题").
|
AccessSecret: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l",
|
||||||
Body("Hello").
|
Region: "ap-northeast-1",
|
||||||
Build())
|
Sender: "chenlihan@dreaminglife.cn",
|
||||||
|
}))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = ini.Send(ctx, interfaces.Message{
|
||||||
|
Form: "chenlihan@dreaminglife.cn",
|
||||||
|
To: []string{"huangxinyun@dreaminglife.cn"},
|
||||||
|
Body: "Hello",
|
||||||
|
Subject: "主题",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-81
@@ -1,99 +1,42 @@
|
|||||||
// 使用示例:展示 mailx 的三种典型用法
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
"code.yun.ink/pkg/mailx"
|
||||||
"code.yun.ink/pkg/mailx/aliyun"
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
"code.yun.ink/pkg/mailx/aws"
|
|
||||||
"code.yun.ink/pkg/mailx/mailgun"
|
|
||||||
"code.yun.ink/pkg/mailx/smtp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// ===== 方式一:单个通道直接发送 =====
|
ch := mailx.Platform
|
||||||
// 465 端口走 SSL,587/25 走 STARTTLS;也可显式指定 Encryption
|
|
||||||
err := smtp.New(smtp.Config{
|
em, err := ch.GetEmail(interfaces.EmailTypeSmtp)
|
||||||
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
if err != nil {
|
||||||
Port: 80,
|
panic(err)
|
||||||
User: "support@email.blueoceanpay.com",
|
}
|
||||||
|
// 使用em进行后续操作
|
||||||
|
err = em.SetOption(ctx, interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{
|
||||||
|
Username: "support@email.blueoceanpay.com",
|
||||||
Password: "SupporT2017",
|
Password: "SupporT2017",
|
||||||
From: "yun@blueoceanpay.com",
|
ReplyTo: "",
|
||||||
}).Send(ctx, mailx.NewMessage().
|
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
||||||
From(`"蓝海支付" <yun@blueoceanpay.com>`). // 发件人可带显示名
|
Port: "80",
|
||||||
To("995116474@qq.com").
|
|
||||||
Subject("Test Email").
|
|
||||||
Text("纯文本正文").
|
|
||||||
HTML(`<h1>Hello</h1><p>This is a test email.</p><img src="cid:logo1">`).
|
|
||||||
InlineImage("logo1", "assets/logo.png"). // 内嵌图片
|
|
||||||
Build())
|
|
||||||
fmt.Println("smtp send:", err)
|
|
||||||
|
|
||||||
// ===== 方式二:多通道管理器,发送时切换通道 =====
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
_ = mgr.Register(smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", Port: 587, User: "from@qq.com", Password: "auth-code",
|
|
||||||
}))
|
}))
|
||||||
_ = mgr.Register(aliyun.New(aliyun.Config{
|
if err != nil {
|
||||||
AccessKeyID: "your-access-key-id", AccessKeySecret: "your-access-key-secret",
|
panic(err)
|
||||||
AccountName: "noreply@example.com",
|
|
||||||
}))
|
|
||||||
_ = mgr.Register(aws.New(aws.Config{
|
|
||||||
AccessKeyID: "your-ak", AccessKeySecret: "your-sk", Region: "ap-northeast-1",
|
|
||||||
Sender: "noreply@example.com",
|
|
||||||
}))
|
|
||||||
_ = mgr.Register(mailgun.New(mailgun.Config{
|
|
||||||
APIKey: "your-api-key", Domain: "mg.example.com", Sender: "noreply@example.com",
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 同一通道类型(smtp)注册多份不同配置,用实例名区分
|
|
||||||
_ = mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "code-main",
|
|
||||||
}))
|
|
||||||
_ = mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "code-backup",
|
|
||||||
}))
|
|
||||||
|
|
||||||
_ = mgr.SetDefault("aliyun")
|
|
||||||
|
|
||||||
// 遍历当前已注册的通道实例信息(实例名 + 类型)
|
|
||||||
for _, info := range mgr.Senders() {
|
|
||||||
log.Printf("registered sender: instance=%s type=%s", info.Name, info.Type)
|
|
||||||
}
|
|
||||||
log.Println("default:", mgr.Default())
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From("noreply@example.com").
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("Hello").
|
|
||||||
Text("hello").
|
|
||||||
HTML("<h1>Hello</h1>").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
if err := mgr.Send(ctx, msg); err != nil { // 默认通道
|
|
||||||
log.Println("default send:", err)
|
|
||||||
}
|
|
||||||
if err := mgr.SendWith(ctx, "aws", msg); err != nil { // 按名称指定通道
|
|
||||||
log.Println("aws send:", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 方式三:发送时临时指定配置(无需提前注册) =====
|
err = em.Send(ctx, interfaces.Message{
|
||||||
if err := mgr.SendBy(ctx, mailgun.New(mailgun.Config{
|
Form: "yun@blueoceanpay.com",
|
||||||
APIKey: "another-key", Domain: "mg2.example.com", Sender: "noreply@example.com",
|
To: []string{"995116474@qq.com"},
|
||||||
}), msg); err != nil {
|
Subject: "Test Email",
|
||||||
log.Println("mailgun send:", err)
|
Body: "Hello, this is a test email.",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 错误类型判断 =====
|
|
||||||
if err := mgr.SendWith(ctx, "no-such-channel", msg); err != nil {
|
|
||||||
if errors.Is(err, mailx.ErrSenderNotFound) {
|
|
||||||
log.Println("channel not found, will retry with another channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "<h1>Test Content</h1>",
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
// 包级哨兵错误,供调用方使用 errors.Is / errors.As 精确判断失败原因。
|
|
||||||
var (
|
|
||||||
// ErrSenderNotFound 通道未注册时返回
|
|
||||||
ErrSenderNotFound = errors.New("mailx: sender not found")
|
|
||||||
// ErrInvalidConfig 通道配置缺失/非法时返回
|
|
||||||
ErrInvalidConfig = errors.New("mailx: invalid configuration")
|
|
||||||
// ErrInvalidMessage 消息校验未通过时返回
|
|
||||||
ErrInvalidMessage = errors.New("mailx: invalid message")
|
|
||||||
// ErrSendFailed 发送过程中通道返回错误时返回(可继续用 %w 链式展开底层原因)
|
|
||||||
ErrSendFailed = errors.New("mailx: send failed")
|
|
||||||
)
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package mailx_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ExampleNewMessage() {
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From("noreply@example.com").
|
|
||||||
To("user@example.com").
|
|
||||||
Cc("cc@example.com").
|
|
||||||
Subject("Hello").
|
|
||||||
HTML("<h1>Hello</h1>").
|
|
||||||
ReplyTo("support@example.com").
|
|
||||||
AttachBytes("report.txt", []byte("data")).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
fmt.Println(msg.From, msg.To[0], msg.Subject, len(msg.Attachments))
|
|
||||||
// Output: noreply@example.com user@example.com Hello 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExampleManager_Send() {
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
_ = mgr.Register(newMockSender("mock"))
|
|
||||||
|
|
||||||
err := mgr.Send(context.Background(), mailx.NewMessage().
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("Hello").
|
|
||||||
Build())
|
|
||||||
fmt.Println("send err:", err)
|
|
||||||
// Output: send err: <nil>
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExampleManager_SendWith() {
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
_ = mgr.Register(newMockSender("smtp"))
|
|
||||||
_ = mgr.Register(newMockSender("aliyun"))
|
|
||||||
_ = mgr.SetDefault("smtp")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
msg := mailx.NewMessage().To("user@example.com").Subject("Hi").Build()
|
|
||||||
|
|
||||||
_ = mgr.Send(ctx, msg) // 默认通道 smtp
|
|
||||||
err := mgr.SendWith(ctx, "aliyun", msg)
|
|
||||||
fmt.Println("send with aliyun err:", err)
|
|
||||||
// Output: send with aliyun err: <nil>
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExampleManager_SendBy() {
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
|
|
||||||
// 无需注册,发送时临时指定通道
|
|
||||||
err := mgr.SendBy(context.Background(), newMockSender("temp"), mailx.NewMessage().
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("Hi").
|
|
||||||
Build())
|
|
||||||
fmt.Println("send by err:", err)
|
|
||||||
// Output: send by err: <nil>
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExampleMessage_Validate() {
|
|
||||||
msg := mailx.NewMessage().Subject("no recipients").Build()
|
|
||||||
err := msg.Validate()
|
|
||||||
fmt.Println(errors.Is(err, mailx.ErrInvalidMessage))
|
|
||||||
fmt.Println(err)
|
|
||||||
// Output:
|
|
||||||
// true
|
|
||||||
// mailx: invalid message: requires at least one recipient
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# mailx 使用示例
|
|
||||||
|
|
||||||
本目录包含多个由浅入深的示例,方便快速接入。
|
|
||||||
|
|
||||||
## 示例总览
|
|
||||||
|
|
||||||
| 示例 | 难度 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| [`quickstart`](./quickstart) | 入门 | 最简 SMTP 发送,逐行注释,适合第一次接触 |
|
|
||||||
| [`basic`](./basic) | 入门 | 单通道直接发送,含附件/抄送/回复地址 |
|
|
||||||
| [`manager`](./manager) | 入门 | 多通道管理器:注册、路由、默认通道、临时指定配置 |
|
|
||||||
| [`with_env`](./with_env) | 进阶 | 用环境变量管理多通道凭据,避免密钥写死在代码里 |
|
|
||||||
| [`advanced`](./advanced) | 进阶 | 主备切换、实例遍历、日志注入、超时、内嵌图片、错误分类 |
|
|
||||||
| [`custom_sender`](./custom_sender) | 进阶 | 实现自定义通道 + 自定义 Logger |
|
|
||||||
|
|
||||||
## 快速开始(5 分钟)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 进入最简示例
|
|
||||||
cd examples/quickstart
|
|
||||||
|
|
||||||
# 2. 打开 main.go,把里面的 4 个 SMTP 配置改成你自己的
|
|
||||||
# (QQ 邮箱需先开启 SMTP 服务并获取授权码)
|
|
||||||
|
|
||||||
# 3. 运行
|
|
||||||
go run main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
看到 `send success` 即表示发送成功。
|
|
||||||
|
|
||||||
## 运行方式
|
|
||||||
|
|
||||||
所有示例都在 mailx 主模块内,直接在示例目录执行 `go run .` 即可:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 示例:运行多通道管理器示例
|
|
||||||
cd examples/manager
|
|
||||||
go run .
|
|
||||||
|
|
||||||
# 示例:运行环境变量示例(先配置好 .env)
|
|
||||||
cd examples/with_env
|
|
||||||
go run .
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **凭据**:所有示例中的密钥都是占位符,运行前必须替换为真实值
|
|
||||||
2. **SMTP 授权码**:QQ/163 等邮箱不是用邮箱登录密码,而是需要在邮箱后台开启 SMTP 服务后生成的授权码
|
|
||||||
3. **AWS/阿里云**:需要先完成服务开通、域名验证(发件地址需为已验证身份)
|
|
||||||
4. **`.env`**:`with_env` 示例依赖 `github.com/joho/godotenv`,首次运行前执行 `go get github.com/joho/godotenv`;`with_env/.env` 含敏感信息,勿提交 git
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
// advanced 展示生产环境常用的一些进阶能力。
|
|
||||||
//
|
|
||||||
// 覆盖以下内容:
|
|
||||||
// 1. 同一通道类型(smtp)注册多份不同配置(主备切换)
|
|
||||||
// 2. 遍历当前注册的通道实例信息
|
|
||||||
// 3. 注入自定义 Logger(日志可插拔)
|
|
||||||
// 4. 设置通道超时,防止发送阻塞
|
|
||||||
// 5. 内嵌图片 + HTML 邮件
|
|
||||||
// 6. 用 FromMap 从 map 快捷构建消息
|
|
||||||
// 7. 按错误类型精确处理(errors.Is)
|
|
||||||
//
|
|
||||||
// 怎么运行:设置好下方配置后执行:go run main.go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
"code.yun.ink/pkg/mailx/smtp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// appLogger 一个简单的 Logger 实现(也可接入 zap/logrus)。
|
|
||||||
// 生产环境建议实现 Debugf/Infof/Warnf/Errorf 四个方法,用于观测发送过程。
|
|
||||||
type appLogger struct{}
|
|
||||||
|
|
||||||
func (appLogger) Debugf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[debug] "+format, args...)
|
|
||||||
}
|
|
||||||
func (appLogger) Infof(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[info] "+format, args...)
|
|
||||||
}
|
|
||||||
func (appLogger) Warnf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[warn] "+format, args...)
|
|
||||||
}
|
|
||||||
func (appLogger) Errorf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[error] "+format, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
mgr.SetLogger(appLogger{}) // 注入全局 Logger
|
|
||||||
|
|
||||||
// ===== 1. 同一通道类型注册多份配置(主备切换) =====
|
|
||||||
_ = mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "main-code",
|
|
||||||
Timeout: 10 * time.Second, // ===== 4. 通道超时,防止发送阻塞 =====
|
|
||||||
}))
|
|
||||||
_ = mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "backup-code",
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}))
|
|
||||||
_ = mgr.SetDefault("smtp-main") // 默认用主通道
|
|
||||||
|
|
||||||
// ===== 2. 遍历当前注册的通道实例信息 =====
|
|
||||||
for _, info := range mgr.Senders() {
|
|
||||||
fmt.Printf("sender instance: name=%s type=%s\n", info.Name, info.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 5. 内嵌图片 + HTML 邮件 =====
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From(`"通知中心" <a@qq.com>`). // 发件人带显示名
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("生产环境告警").
|
|
||||||
Text("这是一封告警邮件,请及时处理。").
|
|
||||||
HTML(`<h2>磁盘使用率超过 90%</h2><img src="cid:chart1">`).
|
|
||||||
InlineImageBytes("chart1", "chart.png", []byte{0x89, 0x50, 0x4e, 0x47}). // 内嵌图片,cid:chart1 对应
|
|
||||||
Header("X-Mailer", "mailx"). // 自定义邮件头
|
|
||||||
Header("List-Unsubscribe", "<https://example.com/unsub>"). // 退订头(营销邮件合规)
|
|
||||||
Build()
|
|
||||||
|
|
||||||
// ===== 3. 用默认通道发送(自动注入 Logger) =====
|
|
||||||
if err := mgr.Send(ctx, msg); err != nil {
|
|
||||||
fmt.Println("main send failed:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 7. 错误类型精确处理:主通道失败自动切换备通道 =====
|
|
||||||
err := mgr.SendWith(ctx, "smtp-main", msg)
|
|
||||||
if err != nil {
|
|
||||||
// 发送失败(如网络/认证)属于 ErrSendFailed,而非配置/消息问题
|
|
||||||
if errors.Is(err, mailx.ErrSendFailed) {
|
|
||||||
fmt.Println("main failed, switching to backup:", err)
|
|
||||||
if err2 := mgr.SendWith(ctx, "smtp-backup", msg); err2 != nil {
|
|
||||||
fmt.Println("backup send failed:", err2)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if errors.Is(err, mailx.ErrInvalidMessage) {
|
|
||||||
fmt.Println("message invalid:", err) // 消息本身有问题,重试无意义
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
fmt.Println("send error:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 6. 用 FromMap 从 map 快捷构建消息(适合接入 HTTP 请求体) =====
|
|
||||||
m, err := mailx.FromMap(map[string]any{
|
|
||||||
"from": "a@qq.com",
|
|
||||||
"to": "user@example.com, admin@example.com",
|
|
||||||
"subject": "welcome",
|
|
||||||
"text": "hi",
|
|
||||||
"html": "<b>welcome</b>",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
_ = mgr.Send(ctx, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// basic 展示单个通道直接发送的完整用法。
|
|
||||||
//
|
|
||||||
// 功能:用 SMTP 发送一封含 HTML 正文、抄送、附件、内嵌图片的邮件。
|
|
||||||
//
|
|
||||||
// 怎么运行:
|
|
||||||
// 1. 把下方 smtp.Config 的 4 个配置改成你自己的
|
|
||||||
// 2. 在项目根目录执行:go run ./examples/basic
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
"code.yun.ink/pkg/mailx/smtp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// ===== 第 1 步:创建 SMTP 通道 =====
|
|
||||||
client := smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", // SMTP 服务器地址
|
|
||||||
Port: 587, // 端口:465=SSL,587/25=STARTTLS
|
|
||||||
User: "sender@qq.com", // 账号
|
|
||||||
Password: "your-auth-code", // 授权码(非邮箱密码)
|
|
||||||
From: "sender@qq.com", // 默认发件人(可选,Message.From 未设置时使用)
|
|
||||||
ReplyTo: "sender@qq.com", // 默认回复地址(可选)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ===== 第 2 步:构建消息 =====
|
|
||||||
// 地址都可带显示名,如 "张三" <a@qq.com>;收件人/抄送/密送可传多个
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From(`"客服中心" <sender@qq.com>`). // 发件人(带显示名)
|
|
||||||
To("receiver@example.com"). // 收件人
|
|
||||||
Cc("manager@example.com"). // 抄送(可选)
|
|
||||||
Bcc("leader@example.com"). // 密送(可选)
|
|
||||||
Subject("hello from mailx").
|
|
||||||
Text("如果邮件客户端不支持 HTML,会显示这段纯文本。"). // 纯文本正文(推荐)
|
|
||||||
HTML(`<h1>Hello</h1><p>This email is sent by mailx.</p><img src="cid:logo1">`).
|
|
||||||
ReplyTo("support@example.com"). // 回复地址(可选,覆盖 Config.ReplyTo)
|
|
||||||
Attach("report.txt"). // 按路径添加附件(路径需真实存在)
|
|
||||||
AttachBytes("summary.txt", []byte("summary content")). // 内存附件
|
|
||||||
InlineImageBytes("logo1", "logo.png", mustPNG()). // 内嵌图片(HTML 中 src="cid:logo1")
|
|
||||||
Build()
|
|
||||||
|
|
||||||
// ===== 第 3 步:发送 =====
|
|
||||||
if err := client.Send(ctx, msg); err != nil {
|
|
||||||
fmt.Println("send failed:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println("send success")
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustPNG 返回一个极小的 1x1 PNG(1 像素透明图)用于演示内嵌图片。
|
|
||||||
// 实际使用中请替换为真实的图片文件路径或字节内容。
|
|
||||||
func mustPNG() []byte {
|
|
||||||
// 一个合法的 1x1 透明 PNG
|
|
||||||
return []byte{
|
|
||||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
|
||||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
||||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
|
||||||
0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
||||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
|
||||||
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// custom_sender 展示如何接入一个全新的发送通道(自定义 Sender)。
|
|
||||||
//
|
|
||||||
// 适用场景:公司内部自研邮件网关、短信通道、钉钉/飞书通知等,
|
|
||||||
// 只需实现 mailx.Sender 接口(Name + Send),即可复用 Manager 的
|
|
||||||
// 注册路由、默认通道、日志注入、消息校验等全部能力。
|
|
||||||
//
|
|
||||||
// 怎么运行:在项目根目录执行:go run ./examples/custom_sender
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// printSender 自定义通道:仅把消息打印到控制台,不真正发送。
|
|
||||||
// 只需实现 Sender 接口的两个方法:Name() 和 Send()。
|
|
||||||
type printSender struct {
|
|
||||||
name string // 通道类型名
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPrintSender(name string) *printSender { return &printSender{name: name} }
|
|
||||||
|
|
||||||
// Name 返回通道类型名(如 "print"),用于 Manager 注册与路由
|
|
||||||
func (p *printSender) Name() string { return p.name }
|
|
||||||
|
|
||||||
// Send 实现发送逻辑。ctx 里可通过 mailx.LoggerFromContext 拿到注入的日志器。
|
|
||||||
func (p *printSender) Send(ctx context.Context, msg *mailx.Message) error {
|
|
||||||
logger := mailx.LoggerFromContext(ctx) // 读取 Manager 注入的 Logger(可选)
|
|
||||||
logger.Infof(ctx, "printSender Send called")
|
|
||||||
fmt.Printf("[%s] to=%v cc=%v subject=%q text=%q html=%q\n",
|
|
||||||
p.name, msg.To, msg.Cc, msg.Subject, msg.TextBody, msg.Body)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// appLogger 实现 mailx.Logger 接口(4 个方法)。
|
|
||||||
// 生产中可改用 zap / logrus / slog 等,只需实现同样 4 个方法即可接入。
|
|
||||||
type appLogger struct{}
|
|
||||||
|
|
||||||
func (appLogger) Debugf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[debug] "+format, args...)
|
|
||||||
}
|
|
||||||
func (appLogger) Infof(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[info] "+format, args...)
|
|
||||||
}
|
|
||||||
func (appLogger) Warnf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[warn] "+format, args...)
|
|
||||||
}
|
|
||||||
func (appLogger) Errorf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[error] "+format, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
mgr.SetLogger(appLogger{}) // 注入 Logger,自定义通道内可读取
|
|
||||||
|
|
||||||
// 注册自定义通道
|
|
||||||
_ = mgr.Register(newPrintSender("print"))
|
|
||||||
|
|
||||||
// 同一自定义类型也可注册多份实例(不同配置)
|
|
||||||
_ = mgr.RegisterNamed("print-debug", newPrintSender("print"))
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("hello").
|
|
||||||
Text("from custom sender").
|
|
||||||
HTML("<b>from custom sender</b>").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
// 通过 Manager 统一发送
|
|
||||||
if err := mgr.Send(ctx, msg); err != nil {
|
|
||||||
fmt.Println("send failed:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println("send ok")
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
// manager 展示多通道管理器的完整用法。
|
|
||||||
//
|
|
||||||
// 功能:
|
|
||||||
// 1. 注册多个不同类型的通道(smtp/aliyun/aws),统一路由
|
|
||||||
// 2. 同一类型注册多份配置(主备 SMTP 切换)
|
|
||||||
// 3. 发送时按实例名指定通道,或发送时临时指定配置
|
|
||||||
// 4. 遍历当前注册的通道实例信息
|
|
||||||
//
|
|
||||||
// 怎么运行:
|
|
||||||
// 1. 替换下方各通道的凭据为真实值
|
|
||||||
// 2. 在项目根目录执行:go run ./examples/manager
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
"code.yun.ink/pkg/mailx/aliyun"
|
|
||||||
"code.yun.ink/pkg/mailx/aws"
|
|
||||||
"code.yun.ink/pkg/mailx/smtp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
|
|
||||||
// ===== 注册多个不同类型的通道 =====
|
|
||||||
// 用 Register 注册时,实例名 = 通道类型名(smtp / aliyun / aws / mailgun)
|
|
||||||
_ = mgr.Register(smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", Port: 587, User: "a@qq.com", Password: "code",
|
|
||||||
}))
|
|
||||||
_ = mgr.Register(aliyun.New(aliyun.Config{
|
|
||||||
AccessKeyID: "ak", AccessKeySecret: "sk", AccountName: "noreply@example.com",
|
|
||||||
}))
|
|
||||||
_ = mgr.Register(aws.New(aws.Config{
|
|
||||||
AccessKeyID: "ak", AccessKeySecret: "sk", Region: "ap-northeast-1",
|
|
||||||
Sender: "noreply@example.com",
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ===== 同一类型注册多份配置(主备切换) =====
|
|
||||||
// 用 RegisterNamed 指定实例名,可注册多个 smtp 实例
|
|
||||||
_ = mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "main-code",
|
|
||||||
}))
|
|
||||||
_ = mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "backup-code",
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ===== 设置默认通道(不设置则用第一个注册的) =====
|
|
||||||
_ = mgr.SetDefault("aliyun")
|
|
||||||
|
|
||||||
// ===== 遍历当前注册的通道实例信息 =====
|
|
||||||
fmt.Println("registered instances:")
|
|
||||||
for _, info := range mgr.Senders() {
|
|
||||||
fmt.Printf(" - %s (type: %s)\n", info.Name, info.Type)
|
|
||||||
}
|
|
||||||
fmt.Println("default:", mgr.Default())
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From("noreply@example.com").
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("Notice").
|
|
||||||
Text("hello").
|
|
||||||
HTML("<h1>Hello</h1>").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
// ===== 用默认通道发送 =====
|
|
||||||
if err := mgr.Send(ctx, msg); err != nil {
|
|
||||||
fmt.Println("default send:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 按实例名指定通道发送 =====
|
|
||||||
if err := mgr.SendWith(ctx, "aws", msg); err != nil {
|
|
||||||
fmt.Println("aws send:", err)
|
|
||||||
}
|
|
||||||
if err := mgr.SendWith(ctx, "smtp-backup", msg); err != nil { // 主 SMTP 失败时可切备用
|
|
||||||
fmt.Println("smtp-backup send:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 发送时临时指定配置,无需提前注册 =====
|
|
||||||
if err := mgr.SendBy(ctx, smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.163.com", Port: 465, User: "b@163.com", Password: "code",
|
|
||||||
}), msg); err != nil {
|
|
||||||
fmt.Println("temp smtp send:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 注销一个实例(可选) =====
|
|
||||||
mgr.Unregister("smtp-main")
|
|
||||||
fmt.Println("after unregister, instances:", mgr.Names())
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// quickstart 是最简单的 mailx 入门示例。
|
|
||||||
//
|
|
||||||
// 功能:用 QQ 邮箱 SMTP 发送一封纯文本邮件。
|
|
||||||
//
|
|
||||||
// 怎么运行:
|
|
||||||
// 1. 在 QQ 邮箱「设置 -> 账户」中开启 SMTP,获取授权码(不是 QQ 密码)
|
|
||||||
// 2. 把下方 main.go 里的 4 个配置改成你自己的
|
|
||||||
// 3. 在本目录执行:go run main.go
|
|
||||||
//
|
|
||||||
// 参考:https://mail.qq.com 开启 SMTP 服务的授权码获取方法
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx" // mailx 核心包:消息构建 + Sender 接口
|
|
||||||
"code.yun.ink/pkg/mailx/smtp" // smtp 通道:基于 SMTP 协议发送
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// context 用于控制发送的超时与取消,先创建空背景 context
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// ===== 第 1 步:创建发送通道 =====
|
|
||||||
// smtp.New 接收一个 smtp.Config 配置,返回一个发送通道实例
|
|
||||||
client := smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", // SMTP 服务器地址
|
|
||||||
Port: 587, // 端口:465 走 SSL,587 走 STARTTLS
|
|
||||||
User: "sender@qq.com", // 你的 QQ 邮箱地址
|
|
||||||
Password: "your-auth-code", // 授权码(QQ 邮箱后台开启 SMTP 后获取)
|
|
||||||
// From: "sender@qq.com", // 可选:默认发件人,不填则用上面的 User
|
|
||||||
})
|
|
||||||
|
|
||||||
// ===== 第 2 步:构建邮件消息 =====
|
|
||||||
// 使用链式调用(builder 模式),一行一个字段,最后 Build() 生成消息
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From("sender@qq.com"). // 发件人(可带显示名,如 "张三" <a@qq.com>)
|
|
||||||
To("receiver@example.com"). // 收件人,可传多个:To("a@x.com", "b@x.com")
|
|
||||||
Subject("hello from mailx"). // 邮件主题
|
|
||||||
Text("This is a plain text email."). // 纯文本正文
|
|
||||||
Build() // 生成 *Message
|
|
||||||
|
|
||||||
// ===== 第 3 步:发送 =====
|
|
||||||
// 传入 ctx 和消息,返回 error(nil 表示成功)
|
|
||||||
if err := client.Send(ctx, msg); err != nil {
|
|
||||||
fmt.Println("send failed:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println("send success")
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 复制本文件为 .env 并填入你自己的真实凭据
|
|
||||||
# 注意:.env 包含敏感信息,不要提交到 git(已在 .gitignore 中忽略)
|
|
||||||
|
|
||||||
# ===== 收件人(必填) =====
|
|
||||||
TO_ADDR=receiver@example.com
|
|
||||||
# 发件人兜底地址(选填)
|
|
||||||
FROM_ADDR=sender@example.com
|
|
||||||
|
|
||||||
# ===== SMTP 通道(选填,配置后即启用) =====
|
|
||||||
SMTP_HOST=smtp.qq.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=sender@qq.com
|
|
||||||
SMTP_PASSWORD=your-auth-code
|
|
||||||
SMTP_FROM=sender@qq.com
|
|
||||||
|
|
||||||
# ===== 阿里云邮件推送(选填) =====
|
|
||||||
ALIYUN_ACCESS_KEY_ID=your-access-key-id
|
|
||||||
ALIYUN_ACCESS_KEY_SECRET=your-access-key-secret
|
|
||||||
ALIYUN_ACCOUNT_NAME=noreply@example.com
|
|
||||||
# ALIYUN_ENDPOINT=dm.aliyuncs.com
|
|
||||||
|
|
||||||
# ===== AWS SES(选填) =====
|
|
||||||
AWS_ACCESS_KEY_ID=your-aws-ak
|
|
||||||
AWS_ACCESS_KEY_SECRET=your-aws-sk
|
|
||||||
AWS_REGION=ap-northeast-1
|
|
||||||
AWS_SENDER=noreply@example.com
|
|
||||||
|
|
||||||
# ===== Mailgun(选填) =====
|
|
||||||
MAILGUN_API_KEY=your-api-key
|
|
||||||
MAILGUN_DOMAIN=mg.example.com
|
|
||||||
MAILGUN_SENDER=noreply@example.com
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// with_env 展示如何通过环境变量管理通道凭据(避免把密钥写死在代码里)。
|
|
||||||
//
|
|
||||||
// 适用场景:已有多个通道的凭据(SMTP / 阿里云 / AWS SES / Mailgun),
|
|
||||||
// 希望在程序启动时从环境变量读取配置,注册到 Manager 统一管理。
|
|
||||||
//
|
|
||||||
// 怎么运行:
|
|
||||||
// 1. 复制 .env.example 为 .env(或直接设置下方环境变量)
|
|
||||||
// 2. 安装 dotenv 依赖:go get github.com/joho/godotenv
|
|
||||||
// 3. 设置好至少一个通道的凭据后执行:go run main.go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
"code.yun.ink/pkg/mailx/aliyun"
|
|
||||||
"code.yun.ink/pkg/mailx/aws"
|
|
||||||
"code.yun.ink/pkg/mailx/mailgun"
|
|
||||||
"code.yun.ink/pkg/mailx/smtp"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv" // 读取 .env 文件(可选)
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildSenders 从环境变量读取凭据,返回已配置好的通道列表。
|
|
||||||
// 只返回凭据齐全的通道,未配置的通道直接跳过,方便按需启用。
|
|
||||||
func buildSenders() []mailx.Sender {
|
|
||||||
var senders []mailx.Sender
|
|
||||||
|
|
||||||
// --- SMTP ---
|
|
||||||
if os.Getenv("SMTP_HOST") != "" {
|
|
||||||
senders = append(senders, smtp.New(smtp.Config{
|
|
||||||
Host: os.Getenv("SMTP_HOST"),
|
|
||||||
Port: atoi(os.Getenv("SMTP_PORT"), 465),
|
|
||||||
User: os.Getenv("SMTP_USER"),
|
|
||||||
Password: os.Getenv("SMTP_PASSWORD"),
|
|
||||||
From: os.Getenv("SMTP_FROM"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 阿里云邮件推送 ---
|
|
||||||
if os.Getenv("ALIYUN_ACCESS_KEY_ID") != "" {
|
|
||||||
senders = append(senders, aliyun.New(aliyun.Config{
|
|
||||||
AccessKeyID: os.Getenv("ALIYUN_ACCESS_KEY_ID"),
|
|
||||||
AccessKeySecret: os.Getenv("ALIYUN_ACCESS_KEY_SECRET"),
|
|
||||||
AccountName: os.Getenv("ALIYUN_ACCOUNT_NAME"),
|
|
||||||
Endpoint: os.Getenv("ALIYUN_ENDPOINT"), // 可选,默认 dm.aliyuncs.com
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- AWS SES ---
|
|
||||||
if os.Getenv("AWS_ACCESS_KEY_ID") != "" {
|
|
||||||
senders = append(senders, aws.New(aws.Config{
|
|
||||||
AccessKeyID: os.Getenv("AWS_ACCESS_KEY_ID"),
|
|
||||||
AccessKeySecret: os.Getenv("AWS_ACCESS_KEY_SECRET"),
|
|
||||||
Region: os.Getenv("AWS_REGION"),
|
|
||||||
Sender: os.Getenv("AWS_SENDER"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mailgun ---
|
|
||||||
if os.Getenv("MAILGUN_API_KEY") != "" {
|
|
||||||
senders = append(senders, mailgun.New(mailgun.Config{
|
|
||||||
APIKey: os.Getenv("MAILGUN_API_KEY"),
|
|
||||||
Domain: os.Getenv("MAILGUN_DOMAIN"),
|
|
||||||
Sender: os.Getenv("MAILGUN_SENDER"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return senders
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 可选:加载 .env 文件(没有 .env 时静默忽略,改用系统环境变量)
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
senders := buildSenders()
|
|
||||||
if len(senders) == 0 {
|
|
||||||
log.Fatal("no channel configured, please set env vars (see .env.example)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册到 Manager,第一个注册的成为默认通道
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
for _, s := range senders {
|
|
||||||
if err := mgr.Register(s); err != nil {
|
|
||||||
log.Fatalf("register %s: %v", s.Name(), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("registered channels: %v (default: %s)", mgr.Names(), mgr.Default())
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From(firstNonEmpty(os.Getenv("FROM_ADDR"), os.Getenv("SMTP_FROM"), os.Getenv("AWS_SENDER"), os.Getenv("MAILGUN_SENDER"))).
|
|
||||||
To(os.Getenv("TO_ADDR")).
|
|
||||||
Subject("hello from mailx (env config)").
|
|
||||||
Text("This email is sent using env-var configured channel.").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
// 用默认通道发送
|
|
||||||
if err := mgr.Send(ctx, msg); err != nil {
|
|
||||||
fmt.Println("send failed:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println("send success")
|
|
||||||
}
|
|
||||||
|
|
||||||
// atoi 解析端口,失败时返回默认值
|
|
||||||
func atoi(s string, def int) int {
|
|
||||||
n := 0
|
|
||||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n <= 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstNonEmpty 返回第一个非空字符串
|
|
||||||
func firstNonEmpty(vals ...string) string {
|
|
||||||
for _, v := range vals {
|
|
||||||
if v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FromBytes 从 JSON 字节构建消息(便捷入口,用于接入配置/存储)。
|
|
||||||
func FromBytes(data []byte) (*Message, error) {
|
|
||||||
var m Message
|
|
||||||
if err := json.Unmarshal(data, &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("mailx: unmarshal message: %w", err)
|
|
||||||
}
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FromMap 从 map 构建消息,常用字段自动映射:
|
|
||||||
// - from / to / cc / bcc / subject / text / html / body / replyto / reply_to
|
|
||||||
// - to/cc/bcc 可为 []string、[]any 或 "a@x.com,b@y.com" 分隔串
|
|
||||||
// - attachments: []map{name,path,data} 或 []string(path)
|
|
||||||
func FromMap(m map[string]any) (*Message, error) {
|
|
||||||
if m == nil {
|
|
||||||
return nil, fmt.Errorf("%w: map is nil", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
msg := &Message{}
|
|
||||||
var err error
|
|
||||||
|
|
||||||
str := func(keys ...string) string {
|
|
||||||
for _, k := range keys {
|
|
||||||
if v, ok := m[k].(string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
msg.From = str("from")
|
|
||||||
msg.Subject = str("subject")
|
|
||||||
msg.TextBody = firstNonEmptyStr(str("text"), str("textbody"), str("text_body"))
|
|
||||||
msg.Body = firstNonEmptyStr(str("html"), str("body"))
|
|
||||||
msg.ReplyTo = firstNonEmptyStr(str("replyto"), str("reply_to"))
|
|
||||||
|
|
||||||
if msg.To, err = toList(m["to"]); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if msg.Cc, err = toList(m["cc"]); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if msg.Bcc, err = toList(m["bcc"]); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if atts, ok := m["attachments"]; ok {
|
|
||||||
msg.Attachments, err = toAttachments(atts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// toList 将任意输入转为地址列表([]string、[]any 或逗号/分号分隔串)
|
|
||||||
func toList(v any) ([]string, error) {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case nil:
|
|
||||||
return nil, nil
|
|
||||||
case []string:
|
|
||||||
return t, nil
|
|
||||||
case string:
|
|
||||||
var out []string
|
|
||||||
for _, p := range strings.FieldsFunc(t, func(r rune) bool { return r == ',' || r == ';' }) {
|
|
||||||
if p = strings.TrimSpace(p); p != "" {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case []any:
|
|
||||||
out := make([]string, 0, len(t))
|
|
||||||
for _, item := range t {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("mailx: invalid address list type %T", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// toAttachments 将 attachments 输入转为附件列表
|
|
||||||
func toAttachments(v any) ([]Attachment, error) {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case []string:
|
|
||||||
out := make([]Attachment, 0, len(t))
|
|
||||||
for _, p := range t {
|
|
||||||
out = append(out, Attachment{Name: fileBaseName(p), Path: p})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case []map[string]any:
|
|
||||||
out := make([]Attachment, 0, len(t))
|
|
||||||
for _, mm := range t {
|
|
||||||
var att Attachment
|
|
||||||
if name, ok := mm["name"].(string); ok {
|
|
||||||
att.Name = name
|
|
||||||
}
|
|
||||||
if p, ok := mm["path"].(string); ok {
|
|
||||||
att.Path = p
|
|
||||||
if att.Name == "" {
|
|
||||||
att.Name = fileBaseName(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d, ok := mm["data"]; ok {
|
|
||||||
if bs, ok := d.([]byte); ok {
|
|
||||||
att.Data = bs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, att)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case []any:
|
|
||||||
return toAttachments(mapsFromAny(t))
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("mailx: invalid attachments type %T", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapsFromAny 将 []any 转为 []map[string]any(忽略非 map 元素)
|
|
||||||
func mapsFromAny(arr []any) []map[string]any {
|
|
||||||
out := make([]map[string]any, 0, len(arr))
|
|
||||||
for _, item := range arr {
|
|
||||||
if mm, ok := item.(map[string]any); ok {
|
|
||||||
out = append(out, mm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmptyStr(vals ...string) string {
|
|
||||||
for _, v := range vals {
|
|
||||||
if v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,8 @@ require (
|
|||||||
github.com/alibabacloud-go/tea v1.2.2
|
github.com/alibabacloud-go/tea v1.2.2
|
||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
|
||||||
github.com/aws/aws-sdk-go v1.55.5
|
github.com/aws/aws-sdk-go v1.55.5
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/mailgun/mailgun-go/v4 v4.18.5
|
github.com/mailgun/mailgun-go/v4 v4.18.5
|
||||||
|
github.com/yuninks/loggerx v1.0.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,16 +24,36 @@ require (
|
|||||||
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
||||||
github.com/aliyun/credentials-go v1.3.10 // indirect
|
github.com/aliyun/credentials-go v1.3.10 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
github.com/clbanning/mxj/v2 v2.5.5 // indirect
|
github.com/clbanning/mxj/v2 v2.5.5 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/gin-gonic/gin v1.9.1 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.0.8 // indirect
|
github.com/go-chi/chi/v5 v5.0.8 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/mailgun/errors v0.3.0 // indirect
|
github.com/mailgun/errors v0.3.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.0 // indirect
|
github.com/sirupsen/logrus v1.9.0 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/crypto v0.22.0 // indirect
|
||||||
golang.org/x/net v0.24.0 // indirect
|
golang.org/x/net v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.19.0 // indirect
|
golang.org/x/sys v0.19.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsVi
|
|||||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E=
|
github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E=
|
||||||
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
@@ -69,8 +75,24 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
|
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
|
||||||
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -82,10 +104,13 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
|
|||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
@@ -93,18 +118,24 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
|
|||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
github.com/mailgun/errors v0.3.0 h1:g8R8lodkwqk5WIVMAClyUqt0PSd5JTVgobB+H7C2sLs=
|
github.com/mailgun/errors v0.3.0 h1:g8R8lodkwqk5WIVMAClyUqt0PSd5JTVgobB+H7C2sLs=
|
||||||
github.com/mailgun/errors v0.3.0/go.mod h1:+ltknP+jhv3gZ1StKY6ugoQECcPxDCaSdmYesqTZcLQ=
|
github.com/mailgun/errors v0.3.0/go.mod h1:+ltknP+jhv3gZ1StKY6ugoQECcPxDCaSdmYesqTZcLQ=
|
||||||
github.com/mailgun/mailgun-go/v4 v4.18.5 h1:wZnTutW/fzxNyJVBMa6O4LLGW/hO+IGfKpuqBT4nbVs=
|
github.com/mailgun/mailgun-go/v4 v4.18.5 h1:wZnTutW/fzxNyJVBMa6O4LLGW/hO+IGfKpuqBT4nbVs=
|
||||||
github.com/mailgun/mailgun-go/v4 v4.18.5/go.mod h1:+d4FCswFAukgYc1XtKK2IxOYaVxjVm8AN2z/5TBiT8M=
|
github.com/mailgun/mailgun-go/v4 v4.18.5/go.mod h1:+d4FCswFAukgYc1XtKK2IxOYaVxjVm8AN2z/5TBiT8M=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -112,7 +143,10 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
|
|||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
@@ -123,17 +157,33 @@ github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYl
|
|||||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuninks/loggerx v1.0.12 h1:5XxhSo5bQZEjLRdJ9FPmj+uS018s1meo/9OhG9y2hUg=
|
||||||
|
github.com/yuninks/loggerx v1.0.12/go.mod h1:+QFoywQ1ICh4v40zj6OHM8GBZHEqV0yvRbkZjZUe2o4=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
@@ -146,6 +196,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
|
|||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
|
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||||
|
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
@@ -192,9 +244,11 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -222,6 +276,7 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -235,6 +290,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -250,7 +306,11 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
|
|||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||||
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
@@ -263,3 +323,4 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -2,50 +2,76 @@ package mailx
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseHTMLResource 解析 HTML 中引用的静态资源地址(css/js/img/video/audio)。
|
// html路径替换
|
||||||
// 返回资源 URL 列表;忽略空属性与 dns-prefetch 预请求。
|
func ParseHtmlResource(html string) ([]string, error) {
|
||||||
func ParseHTMLResource(html string) ([]string, error) {
|
resp := []string{}
|
||||||
doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(html))
|
|
||||||
|
b := bytes.NewBufferString(html)
|
||||||
|
doc, err := goquery.NewDocumentFromReader(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("mailx: parse html: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res []string
|
// 找到所有的 css 标签,并且打印它们的 href 属性
|
||||||
collect := func(sel, attr string) {
|
doc.Find("link").Each(func(i int, s *goquery.Selection) {
|
||||||
doc.Find(sel).Each(func(_ int, s *goquery.Selection) {
|
// 忽略dns预请求
|
||||||
if v, ok := s.Attr(attr); ok && v != "" {
|
r, ok := s.Attr("rel")
|
||||||
res = append(res, v)
|
if ok && r == "dns-prefetch" {
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// link:跳过 dns-prefetch
|
|
||||||
doc.Find("link").Each(func(_ int, s *goquery.Selection) {
|
|
||||||
if rel, ok := s.Attr("rel"); ok && rel == "dns-prefetch" {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if href, ok := s.Attr("href"); ok && href != "" {
|
|
||||||
res = append(res, href)
|
href, ok := s.Attr("href")
|
||||||
|
if ok && href != "" {
|
||||||
|
resp = append(resp, href)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
collect("script", "src")
|
|
||||||
collect("img", "src")
|
|
||||||
collect("img", "data-src")
|
|
||||||
collect("video", "src")
|
|
||||||
collect("video", "data-src")
|
|
||||||
collect("audio", "src")
|
|
||||||
|
|
||||||
return res, nil
|
// 找到所有的 script 标签,并且打印它们的 src 属性
|
||||||
}
|
doc.Find("script").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有的 img 标签,并且打印它们的 src 属性
|
||||||
|
doc.Find("img").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
data_src, ok := s.Attr("data-src")
|
||||||
|
if ok && data_src != "" {
|
||||||
|
resp = append(resp, data_src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有的 video 标签,并且打印它们的 src 属性
|
||||||
|
doc.Find("video").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
data_src, ok := s.Attr("data-src")
|
||||||
|
if ok && data_src != "" {
|
||||||
|
resp = append(resp, data_src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有的 audio 标签,并且打印它们的 src 属性
|
||||||
|
doc.Find("audio").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
|
||||||
// ParseHtmlResource 是 ParseHTMLResource 的旧名称,保留以兼容历史调用,建议使用新名。
|
|
||||||
//
|
|
||||||
// Deprecated: 请使用 ParseHTMLResource。
|
|
||||||
func ParseHtmlResource(html string) ([]string, error) {
|
|
||||||
return ParseHTMLResource(html)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>HTML邮件测试</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="color: #333;">欢迎使用HTML邮件</h1>
|
||||||
|
<p>这是一封包含<strong>HTML格式</strong>的邮件。</p>
|
||||||
|
|
||||||
|
<h2>内嵌图片示例</h2>
|
||||||
|
<p>下面是一张内嵌图片:</p>
|
||||||
|
<img src="cid:logo" alt="Logo" style="width: 200px; height: auto;">
|
||||||
|
|
||||||
|
<h2>表格示例</h2>
|
||||||
|
<table border="1" style="border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<th>姓名</th>
|
||||||
|
<th>邮箱</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>张三</td>
|
||||||
|
<td>zhangsan@example.com</td>
|
||||||
|
<td style="color: green;">已激活</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>如果您无法查看此邮件,请<a href="https://example.com">点击这里</a>。</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
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: "<h1>这会被自动识别为HTML</h1><p>因为包含HTML标签。</p>",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动检测为纯文本
|
||||||
|
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: "<h1>员工名单</h1><p>请查看附件中的详细信息。</p>",
|
||||||
|
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: `
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; }
|
||||||
|
.header { background-color: #f0f0f0; padding: 20px; }
|
||||||
|
.content { padding: 20px; }
|
||||||
|
.footer { background-color: #333; color: white; padding: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<img src="cid:header_logo" alt="公司Logo" style="height: 50px;">
|
||||||
|
<h1>月度报告</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>销售数据</h2>
|
||||||
|
<p>本月销售情况如下图所示:</p>
|
||||||
|
<img src="cid:sales_chart" alt="销售图表" style="width: 100%; max-width: 600px;">
|
||||||
|
|
||||||
|
<h2>用户增长</h2>
|
||||||
|
<p>用户增长趋势:</p>
|
||||||
|
<img src="cid:user_growth" alt="用户增长图" style="width: 100%; max-width: 600px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>详细数据请查看附件。如有疑问,请联系我们。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
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邮件发送成功!")
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package mailx_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseHtmlResourceAll(t *testing.T) {
|
|
||||||
html := `<html>
|
|
||||||
<head>
|
|
||||||
<link rel="dns-prefetch" href="//dns.example.com">
|
|
||||||
<link rel="stylesheet" href="./css/a.css">
|
|
||||||
<script src="./js/app.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<img src="./img/a.png" data-src="./img/b.png">
|
|
||||||
<video src="./v/a.mp4" data-src="./v/b.mp4"></video>
|
|
||||||
<audio src="./au/a.mp3"></audio>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
got, err := mailx.ParseHTMLResource(html)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
"./css/a.css",
|
|
||||||
"./js/app.js",
|
|
||||||
"./img/a.png",
|
|
||||||
"./img/b.png",
|
|
||||||
"./v/a.mp4",
|
|
||||||
"./v/b.mp4",
|
|
||||||
"./au/a.mp3",
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("got %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseHtmlResourceNoResources(t *testing.T) {
|
|
||||||
got, err := mailx.ParseHtmlResource("<html><body>plain</body></html>")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Fatalf("got %v, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseHtmlResourceInvalidInput(t *testing.T) {
|
|
||||||
// 非法 HTML 输入不应返回错误(goquery 容错解析)
|
|
||||||
if _, err := mailx.ParseHtmlResource("not html at all <<<"); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseHtmlResourceEmptyHrefIgnored(t *testing.T) {
|
|
||||||
got, err := mailx.ParseHtmlResource(`<html>
|
|
||||||
<link rel="stylesheet" href="">
|
|
||||||
<script src=""></script>
|
|
||||||
<img src="">
|
|
||||||
</html>`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Fatalf("got %v, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-3
@@ -2,8 +2,7 @@ package mailx_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"code.yun.ink/pkg/mailx"
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseHtmlResource(t *testing.T) {
|
func TestParseHtmlResource(t *testing.T) {
|
||||||
@@ -50,7 +49,7 @@ func TestParseHtmlResource(t *testing.T) {
|
|||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
|
|
||||||
res, err := mailx.ParseHTMLResource(html)
|
res, err := mailx.ParseHtmlResource(html)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package interfaces
|
||||||
|
|
||||||
|
type EmailType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EmailTypeAliyun EmailType = "aliyun"
|
||||||
|
EmailTypeAws EmailType = "aws"
|
||||||
|
EmailTypeMailgun EmailType = "mailgun"
|
||||||
|
EmailTypeSmtp EmailType = "smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmialConfig struct {
|
||||||
|
Smtp *EmailConfigDataSmtp `json:"smtp,omitempty"` // smtp
|
||||||
|
Aws *EmailConfigDataAws `json:"aws,omitempty"` // 亚马逊
|
||||||
|
Aliyun *EmialConfigDataAliyun `json:"aliyun,omitempty"` // 阿里云
|
||||||
|
Mailgun *EmialConfigDataMailgun `json:"mailgun,omitempty"` // mailgun
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmialConfigDataMailgun struct {
|
||||||
|
ApiKey string `json:"api_key"` // mailgun api key
|
||||||
|
Domain string `json:"domain"` // mailgun domain
|
||||||
|
Sender string `json:"sender"` // 发件人
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailConfigDataSmtp struct {
|
||||||
|
IsSSL bool `json:"is_ssl"` // 是否SSL
|
||||||
|
Username string `json:"username"` // 邮箱账号
|
||||||
|
Password string `json:"password"` // 授权码
|
||||||
|
Host string `json:"host"` // SMTP 服务器【默认smtpdm.aliyun.com】
|
||||||
|
Port string `json:"port"` // 发信端口
|
||||||
|
ReplyTo string `json:"reply_to"` // 【选填】回复地址
|
||||||
|
// From string `json:"from"` // 【选填】阿里云邮箱发件人
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailConfigDataAws struct {
|
||||||
|
AccessId string `json:"access_id"` // 亚马逊AccessId
|
||||||
|
AccessSecret string `json:"access_secret"` // 亚马逊AccessSecret
|
||||||
|
Region string `json:"region"` // 亚马逊Region
|
||||||
|
Sender string `json:"sender"` // 亚马逊发件人
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmialConfigDataAliyun struct {
|
||||||
|
AccessId string `json:"access_id"` // 阿里云AccessId
|
||||||
|
AccessKey string `json:"access_key"` // 阿里云AccessKey
|
||||||
|
Endpoint string `json:"endpoint"` // 区域 默认dm.aliyuncs.com
|
||||||
|
AccountName string `json:"account_name"` // 账号名
|
||||||
|
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 // 邮件内容
|
||||||
|
BodyType ContentType // 内容类型:text/html/auto
|
||||||
|
TextBody string // 纯文本版本(可选)
|
||||||
|
ReplyTo string
|
||||||
|
Attachment []Attachment // 普通附件
|
||||||
|
InlineImage []Attachment // 内嵌图片
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
Content string // 文件路径或内容
|
||||||
|
ContentType string // MIME类型
|
||||||
|
Name string // 文件名
|
||||||
|
CID string // Content-ID(内嵌图片用)
|
||||||
|
Type AttachmentType // 附件类型
|
||||||
|
Data []byte // 直接提供字节数据(可选)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailSendRecord struct {
|
||||||
|
AccountName string // 发件人
|
||||||
|
UpdateTime int64 // 毫秒时间戳
|
||||||
|
Status EmailSendStatus // 状态
|
||||||
|
ToUser string // 收件人
|
||||||
|
Subject string // 邮件主题
|
||||||
|
ErrorMessage string // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailSendStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
EmailSendStatusUnknown EmailSendStatus = 0
|
||||||
|
EmailSendStatusSuccess EmailSendStatus = 1
|
||||||
|
EmailSendStatusInvalidAddress EmailSendStatus = 2
|
||||||
|
EmailSendStatusSpam EmailSendStatus = 3
|
||||||
|
EmailSendStatusFailed EmailSendStatus = 4
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package interfaces
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailInterface interface {
|
||||||
|
SetOption(ctx context.Context, opt ...Option) error // 初始化
|
||||||
|
GetEmailType() EmailType
|
||||||
|
// Send 发送邮件
|
||||||
|
Send(ctx context.Context, params Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailFactoryInterface interface {
|
||||||
|
Register(EmailInterface) // 注册一个接口
|
||||||
|
SetOption(opt ...Option) // 针对已注册的进行初始化
|
||||||
|
GetEmail(EmailType) (EmailInterface, error)
|
||||||
|
UnRegister(EmailType)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultEmail struct {
|
||||||
|
Options Options // 配置信息
|
||||||
|
EmailType EmailType
|
||||||
|
IsSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDefaultEmail() *DefaultEmail {
|
||||||
|
return &DefaultEmail{
|
||||||
|
Options: DefaultOptions(),
|
||||||
|
EmailType: "DefaultEmail",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DefaultEmail) SetOption(ctx context.Context, opt ...Option) error {
|
||||||
|
// 深复制l并且返回新的
|
||||||
|
|
||||||
|
for _, o := range opt {
|
||||||
|
o(&l.Options)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.IsSet = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DefaultEmail) GetEmailType() EmailType {
|
||||||
|
return l.EmailType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DefaultEmail) Send(ctx context.Context, params Message) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultEmailFactory struct {
|
||||||
|
Emails map[EmailType]EmailInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDefaultEmailFactory() *DefaultEmailFactory {
|
||||||
|
return &DefaultEmailFactory{
|
||||||
|
Emails: make(map[EmailType]EmailInterface),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DefaultEmailFactory) Register(email EmailInterface) {
|
||||||
|
l.Emails[email.GetEmailType()] = email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DefaultEmailFactory) SetOption(opt ...Option) {
|
||||||
|
for _, email := range l.Emails {
|
||||||
|
email.SetOption(context.Background(), opt...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DefaultEmailFactory) GetEmail(emailType EmailType) (EmailInterface, error) {
|
||||||
|
email, ok := l.Emails[emailType]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DefaultEmailFactory) UnRegister(emailType EmailType) {
|
||||||
|
delete(l.Emails, emailType)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package interfaces
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yuninks/loggerx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Logger loggerx.LoggerInterface
|
||||||
|
|
||||||
|
EmialConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultOptions() Options {
|
||||||
|
ctx := context.Background()
|
||||||
|
return Options{
|
||||||
|
Logger: loggerx.NewLogger(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Options)
|
||||||
|
|
||||||
|
// 设置日志
|
||||||
|
func SetLogger(logger loggerx.LoggerInterface) Option {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.Logger = logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetEmialConfig(emialConfig EmialConfig) Option {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.EmialConfig = emialConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSmtp(smtp *EmailConfigDataSmtp) Option {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.Smtp = smtp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetAws(aws *EmailConfigDataAws) Option {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.Aws = aws
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetAliyun(aliyun *EmialConfigDataAliyun) Option {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.Aliyun = aliyun
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetMailgun(mailgun *EmialConfigDataMailgun) Option {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.Mailgun = mailgun
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestFileBaseName(t *testing.T) {
|
|
||||||
cases := []struct{ in, want string }{
|
|
||||||
{"dir/file.txt", "file.txt"},
|
|
||||||
{`dir\file.txt`, "file.txt"},
|
|
||||||
{"/a/b/c.txt", "c.txt"},
|
|
||||||
{"file.txt", "file.txt"},
|
|
||||||
{"", "attachment"},
|
|
||||||
{"/", "attachment"},
|
|
||||||
{".", "attachment"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
if got := fileBaseName(c.in); got != c.want {
|
|
||||||
t.Errorf("fileBaseName(%q) = %q, want %q", c.in, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Logger 日志接口,可实现或适配任意日志库(如 zap、logrus、slog)。
|
|
||||||
// 通过 Manager.SetLogger 或 context 注入(WithLogger)后,
|
|
||||||
// 各通道在发送成功/失败时会自动记录。
|
|
||||||
type Logger interface {
|
|
||||||
Debugf(ctx context.Context, format string, args ...any)
|
|
||||||
Infof(ctx context.Context, format string, args ...any)
|
|
||||||
Warnf(ctx context.Context, format string, args ...any)
|
|
||||||
Errorf(ctx context.Context, format string, args ...any)
|
|
||||||
}
|
|
||||||
|
|
||||||
// noopLogger 空实现,作为未注入日志器时的安全默认值
|
|
||||||
type noopLogger struct{}
|
|
||||||
|
|
||||||
func (noopLogger) Debugf(context.Context, string, ...any) {}
|
|
||||||
func (noopLogger) Infof(context.Context, string, ...any) {}
|
|
||||||
func (noopLogger) Warnf(context.Context, string, ...any) {}
|
|
||||||
func (noopLogger) Errorf(context.Context, string, ...any) {}
|
|
||||||
|
|
||||||
// stdLogger 基于标准库 log 实现的 Logger
|
|
||||||
type stdLogger struct{}
|
|
||||||
|
|
||||||
func (stdLogger) Debugf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[debug] "+format, args...)
|
|
||||||
}
|
|
||||||
func (stdLogger) Infof(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[info] "+format, args...)
|
|
||||||
}
|
|
||||||
func (stdLogger) Warnf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[warn] "+format, args...)
|
|
||||||
}
|
|
||||||
func (stdLogger) Errorf(_ context.Context, format string, args ...any) {
|
|
||||||
log.Printf("[error] "+format, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StdLogger 返回一个基于标准库 log 的 Logger 实现。
|
|
||||||
// 若希望日志带行号/级别前缀,或接入 zap/logrus/slog 等,请自定义实现 Logger 接口。
|
|
||||||
func StdLogger() Logger {
|
|
||||||
return stdLogger{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noop 返回一个空实现的 Logger,用于未注入时的默认值。
|
|
||||||
func noop() Logger {
|
|
||||||
return noopLogger{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ctxKey 用于在 context 中存取 Logger 的私有键
|
|
||||||
type ctxKey struct{}
|
|
||||||
|
|
||||||
// WithLogger 将日志器注入 context,通道发送时自动读取。
|
|
||||||
func WithLogger(ctx context.Context, l Logger) context.Context {
|
|
||||||
return context.WithValue(ctx, ctxKey{}, l)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoggerFromContext 从 context 读取日志器;未注入时返回空实现(不输出任何日志)。
|
|
||||||
func LoggerFromContext(ctx context.Context) Logger {
|
|
||||||
if l, ok := ctx.Value(ctxKey{}).(Logger); ok && l != nil {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
return noop()
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package mailx_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// recordingLogger 记录日志调用的最小实现
|
|
||||||
type recordingLogger struct{}
|
|
||||||
|
|
||||||
func (recordingLogger) Debugf(context.Context, string, ...any) {}
|
|
||||||
func (recordingLogger) Infof(context.Context, string, ...any) {}
|
|
||||||
func (recordingLogger) Warnf(context.Context, string, ...any) {}
|
|
||||||
func (recordingLogger) Errorf(context.Context, string, ...any) {}
|
|
||||||
|
|
||||||
var _ mailx.Logger = recordingLogger{}
|
|
||||||
|
|
||||||
func TestLoggerRoundTrip(t *testing.T) {
|
|
||||||
ctx := mailx.WithLogger(context.Background(), recordingLogger{})
|
|
||||||
l := mailx.LoggerFromContext(ctx)
|
|
||||||
if _, ok := l.(recordingLogger); !ok {
|
|
||||||
t.Fatalf("logger type = %T, want recordingLogger", l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoggerDefaultNotNil(t *testing.T) {
|
|
||||||
l := mailx.LoggerFromContext(context.Background())
|
|
||||||
if l == nil {
|
|
||||||
t.Fatal("default logger should not be nil")
|
|
||||||
}
|
|
||||||
// 不应 panic
|
|
||||||
l.Debugf(context.Background(), "noop")
|
|
||||||
l.Infof(context.Background(), "noop")
|
|
||||||
l.Warnf(context.Background(), "noop")
|
|
||||||
l.Errorf(context.Background(), "noop")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetLoggerNilIgnored(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
m.SetLogger(nil) // 不应 panic
|
|
||||||
m.SetLogger(recordingLogger{})
|
|
||||||
}
|
|
||||||
+127
-129
@@ -1,162 +1,160 @@
|
|||||||
// Package mailgun 提供 Mailgun 邮件发送通道。
|
|
||||||
package mailgun
|
package mailgun
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
"github.com/mailgun/mailgun-go/v4"
|
"github.com/mailgun/mailgun-go/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultTimeout 单次 API 调用的默认超时
|
const (
|
||||||
const defaultTimeout = 30 * time.Second
|
MaxRetries = 3
|
||||||
|
MaxRecipients = 1000 // Mailgun limit
|
||||||
|
)
|
||||||
|
|
||||||
// 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 {
|
type MailGun struct {
|
||||||
cfg Config
|
interfaces.DefaultEmail
|
||||||
|
// params *interfaces.EmialConfigDataMailgun
|
||||||
initOnce sync.Once
|
mg *mailgun.MailgunImpl
|
||||||
client mgClient
|
// logger loggerx.LoggerInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建 Mailgun 通道
|
func NewMailGun() *MailGun {
|
||||||
func New(cfg Config) *MailGun {
|
mailgun := &MailGun{}
|
||||||
return &MailGun{cfg: cfg}
|
mailgun.Options = interfaces.DefaultOptions()
|
||||||
|
mailgun.EmailType = interfaces.EmailTypeMailgun
|
||||||
|
return mailgun
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name 返回通道名称
|
func (l *MailGun) SetOption(ctx context.Context, opt ...interfaces.Option) error {
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mg := g.getClient()
|
if l.Options.Mailgun == nil {
|
||||||
text := msg.TextBody
|
return fmt.Errorf("Mailgun configuration is required")
|
||||||
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)
|
if err := l.validateConfig(); err != nil {
|
||||||
defer cancel()
|
return fmt.Errorf("invalid Mailgun config: %w", err)
|
||||||
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)
|
|
||||||
|
// 安全日志输出
|
||||||
|
l.Options.Logger.Infof(ctx, "Mailgun configured - Domain:%s Sender:%s",
|
||||||
|
l.Options.Mailgun.Domain, l.Options.Mailgun.Sender)
|
||||||
|
|
||||||
|
l.mg = mailgun.NewMailgun(l.Options.Mailgun.Domain, l.Options.Mailgun.ApiKey)
|
||||||
|
|
||||||
|
l.IsSet = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClient 惰性创建并复用 Mailgun 客户端(线程安全)。
|
func (l *MailGun) Send(ctx context.Context, params interfaces.Message) error {
|
||||||
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
|
if !l.IsSet {
|
||||||
func (g *MailGun) getClient() mgClient {
|
return fmt.Errorf("Mailgun client not initialized")
|
||||||
if g.client != nil {
|
|
||||||
return g.client
|
|
||||||
}
|
}
|
||||||
g.initOnce.Do(func() {
|
|
||||||
g.client = mailgun.NewMailgun(g.cfg.Domain, g.cfg.APIKey)
|
// 验证消息
|
||||||
})
|
if err := l.validateMessage(params); err != nil {
|
||||||
return g.client
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// withTimeout 叠加配置超时到 ctx(已有更早 deadline 时保持不变)
|
// 验证配置
|
||||||
func (g *MailGun) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (l *MailGun) validateConfig() error {
|
||||||
if g.cfg.Timeout <= 0 {
|
if l.Options.Mailgun.Domain == "" {
|
||||||
return ctx, func() {}
|
return errors.New("Domain is required")
|
||||||
}
|
}
|
||||||
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= g.cfg.Timeout {
|
if l.Options.Mailgun.ApiKey == "" {
|
||||||
return ctx, func() {}
|
return errors.New("ApiKey is required")
|
||||||
}
|
}
|
||||||
return context.WithTimeout(ctx, g.cfg.Timeout)
|
if l.Options.Mailgun.Sender == "" {
|
||||||
|
return errors.New("Sender is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nopCloser 包装 io.Reader 为 io.ReadCloser(Close 为空操作)
|
// 验证消息
|
||||||
type nopCloser struct {
|
func (l *MailGun) validateMessage(params interfaces.Message) error {
|
||||||
io.Reader
|
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 (nopCloser) Close() error { 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 {
|
||||||
|
return fmt.Errorf("Mailgun API call failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录响应信息
|
||||||
|
l.Options.Logger.Infof(ctx, "Mailgun email sent - ID:%s Response:%s", id, resp)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-13
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
"code.yun.ink/pkg/mailx/mailgun"
|
"code.yun.ink/pkg/mailx/mailgun"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,23 +15,26 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSendEmail(t *testing.T) {
|
func TestSendEmail(t *testing.T) {
|
||||||
if testing.Short() {
|
ini := mailgun.NewMailGun()
|
||||||
t.Skip("skip real send in short mode")
|
ctx := context.Background()
|
||||||
}
|
|
||||||
client := mailgun.New(mailgun.Config{
|
err := ini.SetOption(ctx, interfaces.SetMailgun(&interfaces.EmialConfigDataMailgun{
|
||||||
APIKey: apikey,
|
ApiKey: apikey,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Sender: sender,
|
Sender: sender,
|
||||||
})
|
}))
|
||||||
|
|
||||||
ctx := context.Background()
|
if err != nil {
|
||||||
err := client.Send(ctx, mailx.NewMessage().
|
t.Fatal(err)
|
||||||
To("995116474@qq.com").
|
}
|
||||||
Subject("test mail").
|
err = ini.Send(ctx, interfaces.Message{
|
||||||
Body("Hello").
|
To: []string{"995116474@qq.com"},
|
||||||
Build())
|
Subject: "test mail",
|
||||||
|
Body: "Hello",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Log("send success")
|
t.Log("send success")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
// Package mailx 是一个简单易用的多通道邮件发送库。
|
|
||||||
//
|
|
||||||
// 核心特性:
|
|
||||||
// - 多通道支持:smtp / 阿里云 / AWS SES / mailgun 等,可自由扩展
|
|
||||||
// - 链式消息构建:mailx.NewMessage().From(...).To(...).Subject(...).Build()
|
|
||||||
// - 通道管理器:注册多个通道,发送时按名称路由或临时指定配置
|
|
||||||
// - 发送时指定配置:SendWith 选已注册通道,SendBy 直接传入带配置的通道
|
|
||||||
//
|
|
||||||
// 快速开始:
|
|
||||||
//
|
|
||||||
// ctx := context.Background()
|
|
||||||
//
|
|
||||||
// // 方式一:直接使用单个通道
|
|
||||||
// err := smtp.New(smtp.Config{Host: "...", Port: 465, User: "...", Password: "..."}).
|
|
||||||
// Send(ctx, mailx.NewMessage().
|
|
||||||
// From("a@example.com").
|
|
||||||
// To("b@example.com").
|
|
||||||
// Subject("hello").
|
|
||||||
// Body("world").
|
|
||||||
// Build())
|
|
||||||
//
|
|
||||||
// // 方式二:多通道管理器,发送时切换通道
|
|
||||||
// mgr := mailx.NewManager()
|
|
||||||
// mgr.Register(smtp.New(...))
|
|
||||||
// mgr.Register(aliyun.New(...))
|
|
||||||
// mgr.Send(ctx, msg) // 默认通道
|
|
||||||
// mgr.SendWith(ctx, "aliyun", msg) // 指定通道
|
|
||||||
// mgr.SendBy(ctx, mailgun.New(...), msg) // 临时指定配置的通道
|
|
||||||
package mailx
|
package mailx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Platform interfaces.EmailFactoryInterface
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
func init() {
|
||||||
|
Platform = interfaces.NewDefaultEmailFactory()
|
||||||
|
|
||||||
|
// 阿里
|
||||||
|
Platform.Register(aliyun.NewAliyun())
|
||||||
|
|
||||||
|
// AWS
|
||||||
|
Platform.Register(aws.NewAws())
|
||||||
|
|
||||||
|
// Smtp
|
||||||
|
Platform.Register(smtp.NewSmtp())
|
||||||
|
|
||||||
|
// mailgun
|
||||||
|
Platform.Register(mailgun.NewMailGun())
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
-218
@@ -1,218 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SenderInfo 描述一个已注册通道实例的信息
|
|
||||||
type SenderInfo struct {
|
|
||||||
// Name 实例名,是管理器中的唯一标识,用于 SendWith/Unregister/SetDefault 路由
|
|
||||||
Name string
|
|
||||||
// Type 通道类型(底层 Sender.Name()),如 "smtp" / "aliyun" / "aws" / "mailgun"
|
|
||||||
Type string
|
|
||||||
}
|
|
||||||
|
|
||||||
// senderEntry 管理器内部存储的通道条目
|
|
||||||
type senderEntry struct {
|
|
||||||
instance string // 实例名(管理器内的唯一标识)
|
|
||||||
sender Sender
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager 多通道管理器:注册多个发送通道实例,发送时按实例名路由
|
|
||||||
type Manager struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
senders map[string]senderEntry // key 为实例名
|
|
||||||
def string
|
|
||||||
|
|
||||||
loggerMu sync.RWMutex
|
|
||||||
logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager 创建多通道管理器
|
|
||||||
func NewManager() *Manager {
|
|
||||||
return &Manager{
|
|
||||||
senders: make(map[string]senderEntry),
|
|
||||||
logger: noopLogger{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New 创建多通道管理器(NewManager 的别名,简化调用)
|
|
||||||
func New() *Manager {
|
|
||||||
return NewManager()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register 注册一个发送通道,实例名即通道的 Name()。
|
|
||||||
// 若需同一类型通道注册多份不同配置,请使用 RegisterNamed。
|
|
||||||
func (m *Manager) Register(s Sender) error {
|
|
||||||
if s == nil {
|
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
return m.RegisterNamed(s.Name(), s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterNamed 以指定实例名注册一个发送通道。
|
|
||||||
// 实例名是管理器内的唯一标识;允许同一通道类型(如 smtp)注册多份不同配置,
|
|
||||||
// 例如 "smtp-main" 与 "smtp-backup"。
|
|
||||||
func (m *Manager) RegisterNamed(name string, s Sender) error {
|
|
||||||
if s == nil {
|
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("%w: sender instance name is empty", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := m.senders[name]; ok {
|
|
||||||
return fmt.Errorf("%w: %q already registered", ErrInvalidConfig, name)
|
|
||||||
}
|
|
||||||
m.senders[name] = senderEntry{instance: name, sender: s}
|
|
||||||
if m.def == "" {
|
|
||||||
m.def = name
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unregister 注销指定实例名的通道
|
|
||||||
func (m *Manager) Unregister(name string) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
delete(m.senders, name)
|
|
||||||
if m.def == name {
|
|
||||||
m.def = ""
|
|
||||||
for n := range m.senders {
|
|
||||||
m.def = n
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDefault 设置默认通道实例
|
|
||||||
func (m *Manager) SetDefault(name string) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if _, ok := m.senders[name]; !ok {
|
|
||||||
return fmt.Errorf("%w: %q", ErrSenderNotFound, name)
|
|
||||||
}
|
|
||||||
m.def = name
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sender 按实例名获取已注册的通道;name 为空时返回默认实例
|
|
||||||
func (m *Manager) Sender(name string) (Sender, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
if name == "" {
|
|
||||||
name = m.def
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
return nil, ErrSenderNotFound
|
|
||||||
}
|
|
||||||
e, ok := m.senders[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%w: %q", ErrSenderNotFound, name)
|
|
||||||
}
|
|
||||||
return e.sender, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registered 判断指定实例名的通道是否已注册
|
|
||||||
func (m *Manager) Registered(name string) bool {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
_, ok := m.senders[name]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Names 返回所有已注册通道的实例名(顺序不保证稳定)。
|
|
||||||
// 如需包含通道类型信息,请使用 Senders。
|
|
||||||
func (m *Manager) Names() []string {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
names := make([]string, 0, len(m.senders))
|
|
||||||
for n := range m.senders {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// Senders 返回所有已注册通道实例的信息列表(含实例名与通道类型)。
|
|
||||||
func (m *Manager) Senders() []SenderInfo {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
infos := make([]SenderInfo, 0, len(m.senders))
|
|
||||||
for _, e := range m.senders {
|
|
||||||
infos = append(infos, SenderInfo{Name: e.instance, Type: e.sender.Name()})
|
|
||||||
}
|
|
||||||
return infos
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default 返回当前默认通道实例名(无默认时返回空串)
|
|
||||||
func (m *Manager) Default() string {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
return m.def
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLogger 设置管理器全局日志器
|
|
||||||
func (m *Manager) SetLogger(l Logger) *Manager {
|
|
||||||
if l != nil {
|
|
||||||
m.loggerMu.Lock()
|
|
||||||
m.logger = l
|
|
||||||
m.loggerMu.Unlock()
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger 返回管理器当前的日志器
|
|
||||||
func (m *Manager) Logger() Logger {
|
|
||||||
m.loggerMu.RLock()
|
|
||||||
defer m.loggerMu.RUnlock()
|
|
||||||
return m.logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send 使用默认通道发送
|
|
||||||
func (m *Manager) Send(ctx context.Context, msg *Message) error {
|
|
||||||
return m.SendWith(ctx, "", msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendWith 使用指定实例名的通道发送(发送时指定配置/通道)
|
|
||||||
func (m *Manager) SendWith(ctx context.Context, name string, msg *Message) error {
|
|
||||||
s, err := m.Sender(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return m.dispatch(ctx, s, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendBy 使用临时传入的通道发送,无需提前注册(发送时指定配置)
|
|
||||||
func (m *Manager) SendBy(ctx context.Context, s Sender, msg *Message) error {
|
|
||||||
if s == nil {
|
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
return m.dispatch(ctx, s, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// dispatch 统一校验消息并注入日志器后发送
|
|
||||||
func (m *Manager) dispatch(ctx context.Context, s Sender, msg *Message) error {
|
|
||||||
if msg == nil {
|
|
||||||
return ErrInvalidMessage
|
|
||||||
}
|
|
||||||
if err := msg.Validate(); err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrInvalidMessage, err)
|
|
||||||
}
|
|
||||||
logger := m.Logger()
|
|
||||||
start := time.Now()
|
|
||||||
err := s.Send(WithLogger(ctx, logger), msg)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf(ctx, "mailx: send via %q failed in %v: %v", s.Name(), elapsed, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger.Infof(ctx, "mailx: sent via %q to %v in %v", s.Name(), msg.To, elapsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
-486
@@ -1,486 +0,0 @@
|
|||||||
package mailx_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewManagerEmpty(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
if _, err := m.Sender(""); err == nil {
|
|
||||||
t.Fatal("Sender() on empty manager should error")
|
|
||||||
}
|
|
||||||
if err := m.Send(context.Background(), mailx.NewMessage().To("a@b.com").Subject("s").Build()); err == nil {
|
|
||||||
t.Fatal("Send() on empty manager should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterSetsDefault(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
if err := m.Register(newMockSender("a")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.Register(newMockSender("b")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s, err := m.Sender("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if s.Name() != "a" {
|
|
||||||
t.Errorf("default sender = %q, want a", s.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterDuplicate(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("a"))
|
|
||||||
err := m.Register(newMockSender("a"))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "already registered") {
|
|
||||||
t.Fatalf("err = %v, want already registered", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterNil(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
if err := m.Register(nil); err == nil {
|
|
||||||
t.Fatal("Register(nil) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterEmptyName(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
if err := m.Register(newMockSender("")); err == nil {
|
|
||||||
t.Fatal("Register(empty name) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnregister(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("a"))
|
|
||||||
_ = m.Register(newMockSender("b"))
|
|
||||||
|
|
||||||
m.Unregister("a")
|
|
||||||
s, err := m.Sender("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if s.Name() != "b" {
|
|
||||||
t.Errorf("fallback default = %q, want b", s.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注销最后一个后不再有默认通道
|
|
||||||
m.Unregister("b")
|
|
||||||
if _, err := m.Sender(""); err == nil {
|
|
||||||
t.Fatal("expected error after unregistering all senders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetDefault(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("a"))
|
|
||||||
_ = m.Register(newMockSender("b"))
|
|
||||||
|
|
||||||
if err := m.SetDefault("b"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s, _ := m.Sender("")
|
|
||||||
if s.Name() != "b" {
|
|
||||||
t.Errorf("default = %q, want b", s.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.SetDefault("nope"); err == nil {
|
|
||||||
t.Fatal("SetDefault(unknown) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSenderLookup(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("a"))
|
|
||||||
|
|
||||||
if _, err := m.Sender("a"); err != nil {
|
|
||||||
t.Fatalf("Sender(a) = %v", err)
|
|
||||||
}
|
|
||||||
if _, err := m.Sender("nope"); err == nil {
|
|
||||||
t.Fatal("Sender(unknown) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSend(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
mock := newMockSender("mock")
|
|
||||||
_ = m.Register(mock)
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Body("b").Build()
|
|
||||||
if err := m.Send(context.Background(), msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if mock.count() != 1 {
|
|
||||||
t.Fatalf("send count = %d, want 1", mock.count())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendWithRoutesByName(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
a := newMockSender("a")
|
|
||||||
b := newMockSender("b")
|
|
||||||
_ = m.Register(a)
|
|
||||||
_ = m.Register(b)
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("x@y.com").Subject("s").Build()
|
|
||||||
if err := m.SendWith(context.Background(), "b", msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if a.count() != 0 || b.count() != 1 {
|
|
||||||
t.Fatalf("a=%d b=%d, want a=0 b=1", a.count(), b.count())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.SendWith(context.Background(), "nope", msg); err == nil {
|
|
||||||
t.Fatal("SendWith(unknown) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendBy(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
mock := newMockSender("temp")
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("x@y.com").Subject("s").Build()
|
|
||||||
if err := m.SendBy(context.Background(), mock, msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if mock.count() != 1 {
|
|
||||||
t.Fatalf("count = %d, want 1", mock.count())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.SendBy(context.Background(), nil, msg); err == nil {
|
|
||||||
t.Fatal("SendBy(nil) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendValidation(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("mock"))
|
|
||||||
|
|
||||||
if err := m.Send(context.Background(), nil); err == nil {
|
|
||||||
t.Fatal("Send(nil) should error")
|
|
||||||
}
|
|
||||||
if err := m.Send(context.Background(), &mailx.Message{Subject: "s"}); err == nil {
|
|
||||||
t.Fatal("message without recipients should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendErrorPropagated(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
mock := newMockSender("mock")
|
|
||||||
mock.err = errors.New("boom")
|
|
||||||
_ = m.Register(mock)
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
err := m.Send(context.Background(), msg)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
|
||||||
t.Fatalf("err = %v, want containing boom", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendInjectsLogger(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
mock := newMockSender("mock")
|
|
||||||
_ = m.Register(mock)
|
|
||||||
m.SetLogger(recordingLogger{})
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := m.Send(ctx, msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送时 context 应带有管理器注入的 logger(通道可通过 LoggerFromContext 取到)
|
|
||||||
if _, ok := mock.lastLogger().(recordingLogger); !ok {
|
|
||||||
t.Fatalf("injected logger type = %T, want recordingLogger", mock.lastLogger())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryMethods(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("a"))
|
|
||||||
_ = m.Register(newMockSender("b"))
|
|
||||||
|
|
||||||
if !m.Registered("a") || !m.Registered("b") {
|
|
||||||
t.Error("Registered() should be true for registered senders")
|
|
||||||
}
|
|
||||||
if m.Registered("nope") {
|
|
||||||
t.Error("Registered() should be false for unknown sender")
|
|
||||||
}
|
|
||||||
|
|
||||||
names := m.Names()
|
|
||||||
if len(names) != 2 {
|
|
||||||
t.Fatalf("Names() = %v, want 2", names)
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, n := range names {
|
|
||||||
seen[n] = true
|
|
||||||
}
|
|
||||||
if !seen["a"] || !seen["b"] {
|
|
||||||
t.Errorf("Names() missing a/b: %v", names)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.Default() != "a" {
|
|
||||||
t.Errorf("Default() = %q, want a", m.Default())
|
|
||||||
}
|
|
||||||
_ = m.SetDefault("b")
|
|
||||||
if m.Default() != "b" {
|
|
||||||
t.Errorf("Default() = %q, want b", m.Default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrSentinel(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("mock"))
|
|
||||||
|
|
||||||
// 空 manager 且未注册任何通道时,查默认通道返回 ErrSenderNotFound
|
|
||||||
if _, err := mailx.NewManager().Sender(""); !errors.Is(err, mailx.ErrSenderNotFound) {
|
|
||||||
t.Errorf("Sender() on empty manager = %v, want ErrSenderNotFound", err)
|
|
||||||
}
|
|
||||||
// 未注册的名称返回 ErrSenderNotFound
|
|
||||||
if _, err := m.Sender("nope"); !errors.Is(err, mailx.ErrSenderNotFound) {
|
|
||||||
t.Errorf("Sender(unknown) = %v, want ErrSenderNotFound", err)
|
|
||||||
}
|
|
||||||
// 消息校验失败返回 ErrInvalidMessage
|
|
||||||
if err := m.Send(context.Background(), &mailx.Message{Subject: "s"}); !errors.Is(err, mailx.ErrInvalidMessage) {
|
|
||||||
t.Errorf("Send(no recipients) = %v, want ErrInvalidMessage", err)
|
|
||||||
}
|
|
||||||
// 非法配置返回 ErrInvalidConfig
|
|
||||||
if err := m.Register(nil); !errors.Is(err, mailx.ErrInvalidConfig) {
|
|
||||||
t.Errorf("Register(nil) = %v, want ErrInvalidConfig", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentRegisterUnregister 验证并发注册/注销不 panic 且不丢数据
|
|
||||||
func TestConcurrentRegisterUnregister(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("base"))
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
const n = 30
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
name := fmt.Sprintf("sender-%d", i)
|
|
||||||
_ = m.RegisterNamed(name, newMockSender("mock"))
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
_ = m.SendWith(ctx, name, msg)
|
|
||||||
m.Unregister(name)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
if !m.Registered("base") {
|
|
||||||
t.Error("base sender should still be registered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConcurrentSetLogger(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("mock"))
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
const n = 100
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
if i%2 == 0 {
|
|
||||||
m.SetLogger(recordingLogger{})
|
|
||||||
} else {
|
|
||||||
_ = m.Send(ctx, msg) // 内部读取 Logger,验证并发读写不 panic/不竞态
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
// Logger() 应始终返回非 nil 且类型正确
|
|
||||||
if _, ok := m.Logger().(recordingLogger); !ok {
|
|
||||||
t.Fatalf("Logger() type = %T, want recordingLogger", m.Logger())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConcurrentSend(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
mock := newMockSender("mock")
|
|
||||||
_ = m.Register(mock)
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
const n = 50
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
if err := m.Send(ctx, msg); err != nil {
|
|
||||||
t.Errorf("Send: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if mock.count() != n {
|
|
||||||
t.Fatalf("count = %d, want %d", mock.count(), n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRegisterNamedSameType 验证同一通道类型可注册多份不同配置的实例
|
|
||||||
func TestRegisterNamedSameType(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
|
|
||||||
main := newMockSender("smtp")
|
|
||||||
backup := newMockSender("smtp")
|
|
||||||
|
|
||||||
if err := m.RegisterNamed("smtp-main", main); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.RegisterNamed("smtp-backup", backup); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 两个实例都注册成功
|
|
||||||
if len(m.Names()) != 2 {
|
|
||||||
t.Fatalf("Names() = %v, want 2 instances", m.Names())
|
|
||||||
}
|
|
||||||
// 类型相同但实例不同
|
|
||||||
infos := m.Senders()
|
|
||||||
if len(infos) != 2 {
|
|
||||||
t.Fatalf("Senders() = %v, want 2", infos)
|
|
||||||
}
|
|
||||||
for _, info := range infos {
|
|
||||||
if info.Type != "smtp" {
|
|
||||||
t.Errorf("SenderInfo.Type = %q, want smtp", info.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按实例名路由发送到具体配置
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
if err := m.SendWith(context.Background(), "smtp-backup", msg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if main.count() != 0 || backup.count() != 1 {
|
|
||||||
t.Errorf("main=%d backup=%d, want main=0 backup=1", main.count(), backup.count())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRegisterNamedDuplicateInstance 验证实例名冲突时报错
|
|
||||||
func TestRegisterNamedDuplicateInstance(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.RegisterNamed("smtp-main", newMockSender("smtp"))
|
|
||||||
err := m.RegisterNamed("smtp-main", newMockSender("smtp"))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "already registered") {
|
|
||||||
t.Fatalf("err = %v, want already registered", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRegisterNamedEmptyName 验证空实例名时报错
|
|
||||||
func TestRegisterNamedEmptyName(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
if err := m.RegisterNamed("", newMockSender("smtp")); !errors.Is(err, mailx.ErrInvalidConfig) {
|
|
||||||
t.Fatalf("err = %v, want ErrInvalidConfig", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRegisterSameTypeDirect 验证 Register 用通道类型名注册时,同类型只允许一个(默认实例)
|
|
||||||
func TestRegisterSameTypeDirect(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.Register(newMockSender("smtp"))
|
|
||||||
if err := m.Register(newMockSender("smtp")); err == nil {
|
|
||||||
t.Fatal("Register(same type) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendersInfo 验证 Senders() 返回实例名与类型
|
|
||||||
func TestSendersInfo(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.RegisterNamed("smtp-main", newMockSender("smtp"))
|
|
||||||
_ = m.RegisterNamed("aliyun-prod", newMockSender("aliyun"))
|
|
||||||
|
|
||||||
infos := m.Senders()
|
|
||||||
want := map[string]string{"smtp-main": "smtp", "aliyun-prod": "aliyun"}
|
|
||||||
got := map[string]string{}
|
|
||||||
for _, i := range infos {
|
|
||||||
got[i.Name] = i.Type
|
|
||||||
}
|
|
||||||
for k, v := range want {
|
|
||||||
if got[k] != v {
|
|
||||||
t.Errorf("Senders()[%s] = %q, want %q (all: %+v)", k, got[k], v, infos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Errorf("Senders() count = %d, want %d", len(got), len(want))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnregisterNamedInstance 验证按实例名注销后不再路由
|
|
||||||
func TestUnregisterNamedInstance(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
main := newMockSender("smtp")
|
|
||||||
backup := newMockSender("smtp")
|
|
||||||
_ = m.RegisterNamed("smtp-main", main)
|
|
||||||
_ = m.RegisterNamed("smtp-backup", backup)
|
|
||||||
|
|
||||||
m.Unregister("smtp-main")
|
|
||||||
if m.Registered("smtp-main") {
|
|
||||||
t.Error("smtp-main should be unregistered")
|
|
||||||
}
|
|
||||||
if !m.Registered("smtp-backup") {
|
|
||||||
t.Error("smtp-backup should remain registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注销的实例不可再路由
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
if err := m.SendWith(context.Background(), "smtp-main", msg); !errors.Is(err, mailx.ErrSenderNotFound) {
|
|
||||||
t.Errorf("SendWith(unregistered) = %v, want ErrSenderNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnregisterLastFallback 验证注销默认实例后自动回退到其他实例
|
|
||||||
func TestUnregisterLastFallback(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.RegisterNamed("a", newMockSender("smtp"))
|
|
||||||
_ = m.RegisterNamed("b", newMockSender("aliyun"))
|
|
||||||
|
|
||||||
if m.Default() != "a" {
|
|
||||||
t.Fatalf("Default() = %q, want a", m.Default())
|
|
||||||
}
|
|
||||||
m.Unregister("a")
|
|
||||||
if m.Default() != "b" {
|
|
||||||
t.Errorf("Default() after unregister = %q, want b", m.Default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSetDefaultNamedInstance 验证 SetDefault 支持实例名
|
|
||||||
func TestSetDefaultNamedInstance(t *testing.T) {
|
|
||||||
m := mailx.NewManager()
|
|
||||||
_ = m.RegisterNamed("smtp-main", newMockSender("smtp"))
|
|
||||||
_ = m.RegisterNamed("smtp-backup", newMockSender("smtp"))
|
|
||||||
|
|
||||||
if err := m.SetDefault("smtp-backup"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s, _ := m.Sender("")
|
|
||||||
if s.Name() != "smtp" {
|
|
||||||
t.Errorf("default sender type = %q, want smtp", s.Name())
|
|
||||||
}
|
|
||||||
// 确认默认实例是 backup(发送到 backup)
|
|
||||||
backup, _ := m.Sender("smtp-backup")
|
|
||||||
if _, ok := backup.(*mockSender); !ok || m.Default() != "smtp-backup" {
|
|
||||||
t.Errorf("Default() = %q, want smtp-backup", m.Default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-218
@@ -1,218 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Message 邮件消息
|
|
||||||
type Message struct {
|
|
||||||
From string // 发件人(可含显示名,如 "张三" <a@b.com>)
|
|
||||||
To []string // 收件人(可含显示名)
|
|
||||||
Cc []string // 抄送(可含显示名)
|
|
||||||
Bcc []string // 密送(可含显示名)
|
|
||||||
Subject string // 主题
|
|
||||||
TextBody string // 纯文本正文(可选,推荐设置以便纯文本客户端阅读)
|
|
||||||
Body string // HTML 正文(可选,若同时设置 TextBody 则邮件含纯文本与 HTML 两个版本)
|
|
||||||
Headers map[string]string // 自定义邮件头(可选),如 {"List-Unsubscribe": "<...>"}
|
|
||||||
ReplyTo string // 回复地址
|
|
||||||
Attachments []Attachment // 附件
|
|
||||||
Inline []InlineImage // 内嵌图片(HTML 中用 <img src="cid:..."> 引用)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachment 附件(普通附件,下载式)
|
|
||||||
type Attachment struct {
|
|
||||||
Name string // 附件名称(可选,默认取 Path 的文件名)
|
|
||||||
Path string // 附件文件路径(Path 与 Data 二选一)
|
|
||||||
Data []byte // 附件内容(Path 与 Data 二选一)
|
|
||||||
}
|
|
||||||
|
|
||||||
// InlineImage 内嵌图片(HTML 邮件正文中引用的图片,显示在正文内)
|
|
||||||
type InlineImage struct {
|
|
||||||
CID string // Content-ID,HTML 中用 src="cid:<CID>" 引用
|
|
||||||
Name string // 图片名称(可选,默认取 Path 的文件名或 "inline")
|
|
||||||
Path string // 图片文件路径(Path 与 Data 二选一)
|
|
||||||
Data []byte // 图片内容(Path 与 Data 二选一)
|
|
||||||
MIMEType string // MIME 类型(可选,如 image/png;留空则由扩展名推断或默认 image/octet-stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单封邮件的生产建议上限
|
|
||||||
const (
|
|
||||||
MaxRecipients = 50 // 收件人 + 抄送 + 密送的总上限,防止滥用
|
|
||||||
MaxAttachments = 20 // 附件数量上限
|
|
||||||
MaxMessageSize = 25 * 1024 * 1024 // 整封邮件(正文 + 附件)的最大字节数,默认 25MB
|
|
||||||
MaxHeaderCount = 20 // 自定义头数量上限
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validate 校验消息必填项与基本约束
|
|
||||||
func (m *Message) Validate() error {
|
|
||||||
if len(m.To) == 0 {
|
|
||||||
return fmt.Errorf("%w: requires at least one recipient", ErrInvalidMessage)
|
|
||||||
}
|
|
||||||
if m.Subject == "" {
|
|
||||||
return fmt.Errorf("%w: requires a subject", ErrInvalidMessage)
|
|
||||||
}
|
|
||||||
if m.From != "" && !IsValidAddress(m.From) {
|
|
||||||
return fmt.Errorf("%w: invalid From address %q", ErrInvalidMessage, m.From)
|
|
||||||
}
|
|
||||||
for _, addr := range m.allRecipients() {
|
|
||||||
if !IsValidAddress(addr) {
|
|
||||||
return fmt.Errorf("%w: invalid recipient address %q", ErrInvalidMessage, addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total := len(m.To) + len(m.Cc) + len(m.Bcc)
|
|
||||||
if total > MaxRecipients {
|
|
||||||
return fmt.Errorf("%w: too many recipients (%d > %d)", ErrInvalidMessage, total, MaxRecipients)
|
|
||||||
}
|
|
||||||
if len(m.Attachments) > MaxAttachments {
|
|
||||||
return fmt.Errorf("%w: too many attachments (%d > %d)", ErrInvalidMessage, len(m.Attachments), MaxAttachments)
|
|
||||||
}
|
|
||||||
for _, inl := range m.Inline {
|
|
||||||
if inl.CID == "" {
|
|
||||||
return fmt.Errorf("%w: inline image requires a CID", ErrInvalidMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(m.Headers) > MaxHeaderCount {
|
|
||||||
return fmt.Errorf("%w: too many custom headers (%d > %d)", ErrInvalidMessage, len(m.Headers), MaxHeaderCount)
|
|
||||||
}
|
|
||||||
if size := m.size(); size > MaxMessageSize {
|
|
||||||
return fmt.Errorf("%w: message too large (%d > %d bytes)", ErrInvalidMessage, size, MaxMessageSize)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// size 估算整封邮件的字节数(正文 + 附件 + 内嵌图片)
|
|
||||||
func (m *Message) size() int {
|
|
||||||
n := len(m.Body) + len(m.TextBody)
|
|
||||||
for _, att := range m.Attachments {
|
|
||||||
n += len(att.Data)
|
|
||||||
}
|
|
||||||
for _, inl := range m.Inline {
|
|
||||||
n += len(inl.Data)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// allRecipients 汇总所有接收方地址
|
|
||||||
func (m *Message) allRecipients() []string {
|
|
||||||
out := make([]string, 0, len(m.To)+len(m.Cc)+len(m.Bcc))
|
|
||||||
out = append(out, m.To...)
|
|
||||||
out = append(out, m.Cc...)
|
|
||||||
out = append(out, m.Bcc...)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// MessageBuilder 消息构建器,支持链式调用
|
|
||||||
type MessageBuilder struct {
|
|
||||||
msg *Message
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessage 创建消息构建器
|
|
||||||
func NewMessage() *MessageBuilder {
|
|
||||||
return &MessageBuilder{msg: &Message{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// From 设置发件人(可含显示名,如 "张三" <a@b.com>)
|
|
||||||
func (b *MessageBuilder) From(from string) *MessageBuilder {
|
|
||||||
b.msg.From = from
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// To 添加收件人
|
|
||||||
func (b *MessageBuilder) To(to ...string) *MessageBuilder {
|
|
||||||
b.msg.To = append(b.msg.To, to...)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cc 添加抄送
|
|
||||||
func (b *MessageBuilder) Cc(cc ...string) *MessageBuilder {
|
|
||||||
b.msg.Cc = append(b.msg.Cc, cc...)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bcc 添加密送
|
|
||||||
func (b *MessageBuilder) Bcc(bcc ...string) *MessageBuilder {
|
|
||||||
b.msg.Bcc = append(b.msg.Bcc, bcc...)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subject 设置主题
|
|
||||||
func (b *MessageBuilder) Subject(subject string) *MessageBuilder {
|
|
||||||
b.msg.Subject = subject
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body 设置 HTML 正文(等价于 HTML)。
|
|
||||||
// 如需同时提供纯文本版本给不支持 HTML 的客户端,请再调用 Text。
|
|
||||||
func (b *MessageBuilder) Body(body string) *MessageBuilder {
|
|
||||||
b.msg.Body = body
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text 设置纯文本正文(可选,便于纯文本邮件客户端阅读)
|
|
||||||
func (b *MessageBuilder) Text(text string) *MessageBuilder {
|
|
||||||
b.msg.TextBody = text
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML 设置正文(HTML)
|
|
||||||
func (b *MessageBuilder) HTML(html string) *MessageBuilder {
|
|
||||||
b.msg.Body = html
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplyTo 设置回复地址
|
|
||||||
func (b *MessageBuilder) ReplyTo(replyTo string) *MessageBuilder {
|
|
||||||
b.msg.ReplyTo = replyTo
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header 设置一个自定义邮件头(如 "List-Unsubscribe"、"X-Mailer")。
|
|
||||||
// 与标准头(From/To/Subject 等)重名时,自定义值不会覆盖标准头。
|
|
||||||
func (b *MessageBuilder) Header(key, value string) *MessageBuilder {
|
|
||||||
if b.msg.Headers == nil {
|
|
||||||
b.msg.Headers = make(map[string]string)
|
|
||||||
}
|
|
||||||
b.msg.Headers[key] = value
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach 添加文件附件(按路径)
|
|
||||||
func (b *MessageBuilder) Attach(path string) *MessageBuilder {
|
|
||||||
b.msg.Attachments = append(b.msg.Attachments, Attachment{
|
|
||||||
Name: fileBaseName(path),
|
|
||||||
Path: path,
|
|
||||||
})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// AttachBytes 添加内存附件(按字节内容)
|
|
||||||
func (b *MessageBuilder) AttachBytes(name string, data []byte) *MessageBuilder {
|
|
||||||
b.msg.Attachments = append(b.msg.Attachments, Attachment{
|
|
||||||
Name: name,
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// InlineImage 添加内嵌图片(按路径),HTML 中用 <img src="cid:<cid>">
|
|
||||||
func (b *MessageBuilder) InlineImage(cid, path string) *MessageBuilder {
|
|
||||||
b.msg.Inline = append(b.msg.Inline, InlineImage{
|
|
||||||
CID: cid,
|
|
||||||
Name: fileBaseName(path),
|
|
||||||
Path: path,
|
|
||||||
})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// InlineImageBytes 添加内存内嵌图片,HTML 中用 <img src="cid:<cid>">
|
|
||||||
func (b *MessageBuilder) InlineImageBytes(cid, name string, data []byte) *MessageBuilder {
|
|
||||||
b.msg.Inline = append(b.msg.Inline, InlineImage{
|
|
||||||
CID: cid,
|
|
||||||
Name: name,
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build 生成消息
|
|
||||||
func (b *MessageBuilder) Build() *Message {
|
|
||||||
return b.msg
|
|
||||||
}
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
package mailx_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// manyValidAddresses 生成 n 个合法地址
|
|
||||||
func manyValidAddresses(n int) []string {
|
|
||||||
out := make([]string, n)
|
|
||||||
for i := range out {
|
|
||||||
out[i] = fmt.Sprintf("user%d@example.com", i)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// manyHeaders 生成 n 个自定义头
|
|
||||||
func manyHeaders(n int) map[string]string {
|
|
||||||
out := make(map[string]string, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out[fmt.Sprintf("X-Test-%d", i)] = "v"
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessageBuilder(t *testing.T) {
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From("sender@example.com").
|
|
||||||
To("a@example.com", "b@example.com").
|
|
||||||
Cc("cc@example.com").
|
|
||||||
Bcc("bcc@example.com").
|
|
||||||
Subject("subject").
|
|
||||||
Body("body").
|
|
||||||
ReplyTo("reply@example.com").
|
|
||||||
Attach("dir/file.txt").
|
|
||||||
AttachBytes("data.txt", []byte("hello")).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
if msg.From != "sender@example.com" {
|
|
||||||
t.Errorf("From = %q", msg.From)
|
|
||||||
}
|
|
||||||
if len(msg.To) != 2 || msg.To[0] != "a@example.com" || msg.To[1] != "b@example.com" {
|
|
||||||
t.Errorf("To = %v", msg.To)
|
|
||||||
}
|
|
||||||
if len(msg.Cc) != 1 || msg.Cc[0] != "cc@example.com" {
|
|
||||||
t.Errorf("Cc = %v", msg.Cc)
|
|
||||||
}
|
|
||||||
if len(msg.Bcc) != 1 || msg.Bcc[0] != "bcc@example.com" {
|
|
||||||
t.Errorf("Bcc = %v", msg.Bcc)
|
|
||||||
}
|
|
||||||
if msg.Subject != "subject" || msg.Body != "body" || msg.ReplyTo != "reply@example.com" {
|
|
||||||
t.Errorf("fields mismatch: %+v", msg)
|
|
||||||
}
|
|
||||||
if len(msg.Attachments) != 2 {
|
|
||||||
t.Fatalf("Attachments len = %d, want 2", len(msg.Attachments))
|
|
||||||
}
|
|
||||||
if msg.Attachments[0].Name != "file.txt" || msg.Attachments[0].Path != "dir/file.txt" {
|
|
||||||
t.Errorf("attachment[0] = %+v", msg.Attachments[0])
|
|
||||||
}
|
|
||||||
if msg.Attachments[1].Name != "data.txt" || string(msg.Attachments[1].Data) != "hello" {
|
|
||||||
t.Errorf("attachment[1] = %+v", msg.Attachments[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessageBuilderHTML(t *testing.T) {
|
|
||||||
msg := mailx.NewMessage().HTML("<h1>hi</h1>").Build()
|
|
||||||
if msg.Body != "<h1>hi</h1>" {
|
|
||||||
t.Errorf("HTML() did not set Body, got %q", msg.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessageBuilderAppendTo(t *testing.T) {
|
|
||||||
b := mailx.NewMessage().To("a@example.com")
|
|
||||||
b.To("b@example.com") // 追加而非覆盖
|
|
||||||
msg := b.Build()
|
|
||||||
if len(msg.To) != 2 {
|
|
||||||
t.Fatalf("To = %v, want 2 recipients", msg.To)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessageValidate(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
msg *mailx.Message
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{"valid", &mailx.Message{To: []string{"a@b.com"}, Subject: "s"}, ""},
|
|
||||||
{"no recipients", &mailx.Message{Subject: "s"}, "recipient"},
|
|
||||||
{"no subject", &mailx.Message{To: []string{"a@b.com"}}, "subject"},
|
|
||||||
{"empty", &mailx.Message{}, "recipient"},
|
|
||||||
{"too many recipients", &mailx.Message{To: manyValidAddresses(mailx.MaxRecipients + 1), Subject: "s"}, "too many recipients"},
|
|
||||||
{"invalid recipient", &mailx.Message{To: []string{"not-an-email"}, Subject: "s"}, "invalid recipient"},
|
|
||||||
{"too many attachments", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Attachments: make([]mailx.Attachment, mailx.MaxAttachments+1)}, "too many attachments"},
|
|
||||||
{"too many headers", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Headers: manyHeaders(mailx.MaxHeaderCount + 1)}, "too many custom headers"},
|
|
||||||
{"message too large", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Body: strings.Repeat("x", mailx.MaxMessageSize+1)}, "message too large"},
|
|
||||||
{"inline without cid", &mailx.Message{To: []string{"a@b.com"}, Subject: "s", Inline: []mailx.InlineImage{{Data: []byte{1}}}}, "requires a CID"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
err := tc.msg.Validate()
|
|
||||||
if tc.wantErr == "" {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Validate() = %v, want nil", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
||||||
t.Fatalf("Validate() = %v, want containing %q", err, tc.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
package mailx_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAddressHelpers(t *testing.T) {
|
|
||||||
// 纯地址
|
|
||||||
if !mailx.IsValidAddress("a@example.com") {
|
|
||||||
t.Error("IsValidAddress(a@example.com) = false")
|
|
||||||
}
|
|
||||||
// 显示名
|
|
||||||
if !mailx.IsValidAddress(`"张三" <a@example.com>`) {
|
|
||||||
t.Error("IsValidAddress(with display name) = false")
|
|
||||||
}
|
|
||||||
// 非法
|
|
||||||
if mailx.IsValidAddress("not-an-email") {
|
|
||||||
t.Error("IsValidAddress(not-an-email) = true")
|
|
||||||
}
|
|
||||||
if mailx.IsValidAddress("") {
|
|
||||||
t.Error("IsValidAddress(empty) = true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractEmail 提取纯地址
|
|
||||||
addr, err := mailx.ExtractEmail(`"张三" <a@example.com>`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if addr != "a@example.com" {
|
|
||||||
t.Errorf("ExtractEmail = %q, want a@example.com", addr)
|
|
||||||
}
|
|
||||||
// ExtractEmail 纯地址原样返回
|
|
||||||
addr, err = mailx.ExtractEmail("a@example.com")
|
|
||||||
if err != nil || addr != "a@example.com" {
|
|
||||||
t.Errorf("ExtractEmail(plain) = %q, %v", addr, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddressList(t *testing.T) {
|
|
||||||
addrs, err := mailx.AddressList(`"A" <a@e.com>, b@e.com; c@e.com`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(addrs) != 3 {
|
|
||||||
t.Fatalf("AddressList len = %d, want 3", len(addrs))
|
|
||||||
}
|
|
||||||
if addrs[0].Address != "a@e.com" || addrs[1].Address != "b@e.com" || addrs[2].Address != "c@e.com" {
|
|
||||||
t.Errorf("unexpected addresses: %+v", addrs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromBytes(t *testing.T) {
|
|
||||||
m := mailx.NewMessage().
|
|
||||||
From("a@e.com").
|
|
||||||
To("b@e.com").
|
|
||||||
Subject("s").
|
|
||||||
Text("t").
|
|
||||||
HTML("<p>h</p>").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
data, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := mailx.FromBytes(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.From != "a@e.com" || got.Subject != "s" || got.Body != "<p>h</p>" {
|
|
||||||
t.Errorf("FromBytes mismatch: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromBytesInvalid(t *testing.T) {
|
|
||||||
if _, err := mailx.FromBytes([]byte("not json")); err == nil {
|
|
||||||
t.Fatal("FromBytes(invalid) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromMap(t *testing.T) {
|
|
||||||
m := map[string]any{
|
|
||||||
"from": "a@e.com",
|
|
||||||
"to": "b@e.com, c@e.com",
|
|
||||||
"cc": []string{"d@e.com"},
|
|
||||||
"subject": "hello",
|
|
||||||
"text": "plain",
|
|
||||||
"html": "<b>hi</b>",
|
|
||||||
"replyto": "r@e.com",
|
|
||||||
}
|
|
||||||
msg, err := mailx.FromMap(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if msg.From != "a@e.com" || msg.Subject != "hello" {
|
|
||||||
t.Errorf("FromMap fields: %+v", msg)
|
|
||||||
}
|
|
||||||
if len(msg.To) != 2 || msg.To[0] != "b@e.com" || msg.To[1] != "c@e.com" {
|
|
||||||
t.Errorf("FromMap To: %v", msg.To)
|
|
||||||
}
|
|
||||||
if len(msg.Cc) != 1 || msg.Cc[0] != "d@e.com" {
|
|
||||||
t.Errorf("FromMap Cc: %v", msg.Cc)
|
|
||||||
}
|
|
||||||
if msg.TextBody != "plain" || msg.Body != "<b>hi</b>" {
|
|
||||||
t.Errorf("FromMap bodies: text=%q html=%q", msg.TextBody, msg.Body)
|
|
||||||
}
|
|
||||||
if msg.ReplyTo != "r@e.com" {
|
|
||||||
t.Errorf("FromMap ReplyTo: %q", msg.ReplyTo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromMapNil(t *testing.T) {
|
|
||||||
if _, err := mailx.FromMap(nil); err == nil {
|
|
||||||
t.Fatal("FromMap(nil) should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromMapAttachments(t *testing.T) {
|
|
||||||
m := map[string]any{
|
|
||||||
"to": "a@e.com",
|
|
||||||
"attachments": []any{
|
|
||||||
map[string]any{"name": "x.txt", "path": "tmp/x.txt"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
msg, err := mailx.FromMap(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(msg.Attachments) != 1 || msg.Attachments[0].Name != "x.txt" {
|
|
||||||
t.Errorf("FromMap attachments: %+v", msg.Attachments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFromMapAttachmentNoName 验证附件 map 缺 name 键时不 panic,且 name 从 path 推导
|
|
||||||
func TestFromMapAttachmentNoName(t *testing.T) {
|
|
||||||
m := map[string]any{
|
|
||||||
"to": "a@e.com",
|
|
||||||
"attachments": []any{
|
|
||||||
map[string]any{"path": "tmp/report.pdf"}, // 无 name
|
|
||||||
},
|
|
||||||
}
|
|
||||||
msg, err := mailx.FromMap(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(msg.Attachments) != 1 {
|
|
||||||
t.Fatalf("attachments = %+v, want 1", msg.Attachments)
|
|
||||||
}
|
|
||||||
if msg.Attachments[0].Name != "report.pdf" {
|
|
||||||
t.Errorf("Name = %q, want derived report.pdf", msg.Attachments[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFromMapAttachmentTypeError 验证 attachments 类型非法时返回错误而非 panic
|
|
||||||
func TestFromMapAttachmentTypeError(t *testing.T) {
|
|
||||||
m := map[string]any{
|
|
||||||
"to": "a@e.com",
|
|
||||||
"attachments": "not-a-list", // 非法类型
|
|
||||||
}
|
|
||||||
if _, err := mailx.FromMap(m); err == nil {
|
|
||||||
t.Fatal("FromMap with invalid attachments type should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsHTML(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
in string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"<p>hi</p>", true},
|
|
||||||
{"<h1>hello</h1>", true},
|
|
||||||
{"<div class=\"a\">x</div>", true},
|
|
||||||
{"a < b > c", false}, // 普通比较,不应误判
|
|
||||||
{"plain text", false}, // 纯文本
|
|
||||||
{"<123>", false}, // 无有效标签名
|
|
||||||
{"<!-- comment -->", false}, // 注释
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
if got := mailx.IsHTML(c.in); got != c.want {
|
|
||||||
t.Errorf("IsHTML(%q) = %v, want %v", c.in, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFromMapAttachmentPaths 验证 attachments 为 []string(路径列表)
|
|
||||||
func TestFromMapAttachmentPaths(t *testing.T) {
|
|
||||||
m := map[string]any{
|
|
||||||
"to": "a@e.com",
|
|
||||||
"attachments": []string{"tmp/a.txt", "tmp/b.txt"},
|
|
||||||
}
|
|
||||||
msg, err := mailx.FromMap(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(msg.Attachments) != 2 {
|
|
||||||
t.Fatalf("attachments = %+v, want 2", msg.Attachments)
|
|
||||||
}
|
|
||||||
if msg.Attachments[0].Name != "a.txt" || msg.Attachments[0].Path != "tmp/a.txt" {
|
|
||||||
t.Errorf("attachments[0] = %+v", msg.Attachments[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFromMapToListVariants 验证收件人多种输入形式
|
|
||||||
func TestFromMapToListVariants(t *testing.T) {
|
|
||||||
// []any 形式
|
|
||||||
m := map[string]any{"to": []any{"a@e.com", "b@e.com"}}
|
|
||||||
msg, err := mailx.FromMap(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(msg.To) != 2 || msg.To[0] != "a@e.com" {
|
|
||||||
t.Errorf("To = %v", msg.To)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非法类型
|
|
||||||
m = map[string]any{"to": 123}
|
|
||||||
if _, err := mailx.FromMap(m); err == nil {
|
|
||||||
t.Fatal("To with invalid type should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFromMapBodyFallback 验证 html 优先于 body
|
|
||||||
func TestFromMapBodyFallback(t *testing.T) {
|
|
||||||
m := map[string]any{
|
|
||||||
"to": "a@e.com",
|
|
||||||
"body": "plain body",
|
|
||||||
"html": "<b>html body</b>",
|
|
||||||
}
|
|
||||||
msg, err := mailx.FromMap(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if msg.Body != "<b>html body</b>" {
|
|
||||||
t.Errorf("Body = %q, want html preferred", msg.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFromBytesAllFields 验证 JSON 反序列化完整字段
|
|
||||||
func TestFromBytesAllFields(t *testing.T) {
|
|
||||||
data := []byte(`{
|
|
||||||
"from": "a@e.com",
|
|
||||||
"to": ["b@e.com"],
|
|
||||||
"cc": ["c@e.com"],
|
|
||||||
"bcc": ["d@e.com"],
|
|
||||||
"subject": "s",
|
|
||||||
"textbody": "t",
|
|
||||||
"body": "<p>h</p>",
|
|
||||||
"replyto": "r@e.com",
|
|
||||||
"attachments": [{"name": "a.txt", "data": "aGk="}],
|
|
||||||
"inline": [{"cid": "cid1", "name": "i.png", "data": "aGk="}]
|
|
||||||
}`)
|
|
||||||
msg, err := mailx.FromBytes(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if msg.From != "a@e.com" || len(msg.To) != 1 || msg.To[0] != "b@e.com" {
|
|
||||||
t.Errorf("fields: %+v", msg)
|
|
||||||
}
|
|
||||||
if len(msg.Cc) != 1 || len(msg.Bcc) != 1 {
|
|
||||||
t.Errorf("cc/bcc: %+v", msg)
|
|
||||||
}
|
|
||||||
if msg.TextBody != "t" || msg.Body != "<p>h</p>" || msg.ReplyTo != "r@e.com" {
|
|
||||||
t.Errorf("body fields: %+v", msg)
|
|
||||||
}
|
|
||||||
if len(msg.Attachments) != 1 || msg.Attachments[0].Name != "a.txt" {
|
|
||||||
t.Errorf("attachments: %+v", msg.Attachments)
|
|
||||||
}
|
|
||||||
if len(msg.Inline) != 1 || msg.Inline[0].CID != "cid1" {
|
|
||||||
t.Errorf("inline: %+v", msg.Inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package mailx_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockSender 记录收到的消息与注入的 logger,用于测试 Manager,不产生真实网络请求
|
|
||||||
type mockSender struct {
|
|
||||||
name string
|
|
||||||
err error // 非 nil 时 Send 返回该错误
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
msgs []*mailx.Message
|
|
||||||
logger mailx.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMockSender(name string) *mockSender {
|
|
||||||
return &mockSender{name: name}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockSender) Name() string { return m.name }
|
|
||||||
|
|
||||||
func (m *mockSender) Send(ctx context.Context, msg *mailx.Message) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.logger = mailx.LoggerFromContext(ctx)
|
|
||||||
if m.err != nil {
|
|
||||||
return m.err
|
|
||||||
}
|
|
||||||
m.msgs = append(m.msgs, msg)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockSender) count() int {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return len(m.msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockSender) lastLogger() mailx.Logger {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.logger
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ mailx.Sender = (*mockSender)(nil)
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hhhhhhhhhhh
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package mailx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 解析HTML资源,响应资源链接
|
||||||
|
func ParseHtmlResource(html string) ([]string, error) {
|
||||||
|
resp := []string{}
|
||||||
|
|
||||||
|
b := bytes.NewBufferString(html)
|
||||||
|
doc, err := goquery.NewDocumentFromReader(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到所有的 css 标签,并且打印它们的 href 属性
|
||||||
|
doc.Find("link").Each(func(i int, s *goquery.Selection) {
|
||||||
|
// 忽略dns预请求
|
||||||
|
r, ok := s.Attr("rel")
|
||||||
|
if ok && r == "dns-prefetch" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
href, ok := s.Attr("href")
|
||||||
|
if ok && href != "" {
|
||||||
|
resp = append(resp, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有的 script 标签,并且打印它们的 src 属性
|
||||||
|
doc.Find("script").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有的 img 标签,并且打印它们的 src 属性
|
||||||
|
doc.Find("img").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
data_src, ok := s.Attr("data-src")
|
||||||
|
if ok && data_src != "" {
|
||||||
|
resp = append(resp, data_src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有的 video 标签,并且打印它们的 src 属性
|
||||||
|
doc.Find("video").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
data_src, ok := s.Attr("data-src")
|
||||||
|
if ok && data_src != "" {
|
||||||
|
resp = append(resp, data_src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有的 audio 标签,并且打印它们的 src 属性
|
||||||
|
doc.Find("audio").Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, ok := s.Attr("src")
|
||||||
|
|
||||||
|
if ok && src != "" {
|
||||||
|
resp = append(resp, src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package mailx_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.yun.ink/pkg/mailx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseHtmlResource(t *testing.T) {
|
||||||
|
html := `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Document</title>
|
||||||
|
<link rel="stylesheet" href="./assets/css/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header flex-center">
|
||||||
|
<img src="./assets/img/ab-pay-logo.png" alt="" class="logo" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="code-main">
|
||||||
|
<div class="exception-title">您的收款方式审核失败</div>
|
||||||
|
|
||||||
|
<div class="exception-content">
|
||||||
|
尊敬的ABpay用户,您好!
|
||||||
|
您的收款方式审核失败,失败原因人脸识别失败,请检查您的面部是否被遮挡或处于模糊状态,并再次尝试。
|
||||||
|
如非本人操作,请立即修改密码或联系客服。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="exception-ignore">这是一条自动发送的消息,请勿回复</div>
|
||||||
|
|
||||||
|
<li class="exception-team-tips">ABpay开发者团队服务</li>
|
||||||
|
|
||||||
|
<section class="contact-us">
|
||||||
|
<div class="contact-us-info">
|
||||||
|
本邮件由系统自动发出请勿回复,如需要了解更多服务,欢迎访问ABpay官方网
|
||||||
|
还可以通以下方式联系我们
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="./assets/img/contact-us-qrcode.png" alt="" class="qr-code" />
|
||||||
|
<div class="contact-tel">
|
||||||
|
客服电话:<span class="contact-tel-number">400-278-2890</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-created-version">2024 ABpay.com.cn . All rightes reserved</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
res, err := mailx.ParseHtmlResource(html)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log(res)
|
||||||
|
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
package mailx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 邮件发送的封装
|
||||||
|
// 1. 支持文本
|
||||||
|
// 2. 支持文件
|
||||||
|
|
||||||
|
type Mailx struct {
|
||||||
|
user string
|
||||||
|
password string
|
||||||
|
host string
|
||||||
|
port string
|
||||||
|
auth smtp.Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
Name string
|
||||||
|
ContentType string
|
||||||
|
WithFile bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Form string
|
||||||
|
To []string
|
||||||
|
Cc []string
|
||||||
|
Bcc []string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
// ContentType string
|
||||||
|
ReplyTo string
|
||||||
|
Attachment []Attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMailx(user, password, host, port string) *Mailx {
|
||||||
|
m := &Mailx{
|
||||||
|
user: user,
|
||||||
|
password: password,
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
m.auth = smtp.PlainAuth("", user, password, host)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailx) Send(message Message) error {
|
||||||
|
// .Auth()
|
||||||
|
buffer := bytes.NewBuffer(nil)
|
||||||
|
boundary := "YunBoundaryYun"
|
||||||
|
|
||||||
|
Header := make(map[string]string)
|
||||||
|
// Header["From"] = "BOP<" + message.Form + ">"
|
||||||
|
Header["From"] = m.user
|
||||||
|
|
||||||
|
if len(message.To) > 0 {
|
||||||
|
str := ""
|
||||||
|
for _, val := range message.To {
|
||||||
|
name := ""
|
||||||
|
s := strings.Split(val, "@")
|
||||||
|
if len(s) > 0 {
|
||||||
|
name = s[0]
|
||||||
|
}
|
||||||
|
str = str + "," + name + "<" + val + ">"
|
||||||
|
}
|
||||||
|
Header["To"] = strings.Trim(str, ",")
|
||||||
|
// Header["To"] = strings.Join(message.To, ",")
|
||||||
|
}
|
||||||
|
if len(message.Cc) > 0 {
|
||||||
|
str := ""
|
||||||
|
for _, val := range message.Cc {
|
||||||
|
name := ""
|
||||||
|
s := strings.Split(val, "@")
|
||||||
|
if len(s) > 0 {
|
||||||
|
name = s[0]
|
||||||
|
}
|
||||||
|
str = str + "," + name + "<" + val + ">"
|
||||||
|
}
|
||||||
|
Header["Cc"] = strings.Trim(str, ",")
|
||||||
|
// Header["Cc"] = strings.Join(message.Cc, ",")
|
||||||
|
}
|
||||||
|
if len(message.Bcc) > 0 {
|
||||||
|
str := ""
|
||||||
|
for _, val := range message.Bcc {
|
||||||
|
name := ""
|
||||||
|
s := strings.Split(val, "@")
|
||||||
|
if len(s) > 0 {
|
||||||
|
name = s[0]
|
||||||
|
}
|
||||||
|
str = str + "," + name + "<" + val + ">"
|
||||||
|
}
|
||||||
|
Header["Bcc"] = strings.Trim(str, ",")
|
||||||
|
// Header["Bcc"] = strings.Join(message.Bcc, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
Header["Subject"] = message.Subject
|
||||||
|
Header["Content-Type"] = "multipart/mixed; charset=UTF-8; boundary=" + boundary
|
||||||
|
Header["Date"] = time.Now().String()
|
||||||
|
Header["Reply-To"] = message.ReplyTo
|
||||||
|
|
||||||
|
Header["X-Priority"] = "3"
|
||||||
|
m.writeHeader(buffer, Header)
|
||||||
|
|
||||||
|
body := "--" + boundary + "\r\n"
|
||||||
|
// body += "Content-Type: text/plain; charset=UTF-8 \r\n"
|
||||||
|
body += "Content-Type: text/html;charset=utf-8\r\n"
|
||||||
|
body += "Content-Transfer-Encoding:quoted-printable\r\n\r\n"
|
||||||
|
// body += "<html><body><h1>huang</h1><h2>xin</h2></body></html>\r\n"
|
||||||
|
body += "<html><body>" + message.Body + "</body></html>\r\n"
|
||||||
|
// body += "--" + boundary + "--\r\n\r\n"
|
||||||
|
buffer.WriteString(body)
|
||||||
|
|
||||||
|
for _, value := range message.Attachment {
|
||||||
|
newBuf := bytes.NewBuffer(nil)
|
||||||
|
err := m.writeFile(newBuf, value.Name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("file err:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f_name := path.Base(value.Name)
|
||||||
|
attachment := "--" + boundary + "\r\n"
|
||||||
|
attachment += "Content-Transfer-Encoding:base64\r\n"
|
||||||
|
attachment += "Content-Disposition:attachment;filename=" + f_name + "\r\n"
|
||||||
|
attachment += "Content-Type: application/octet-stream;charset=utf-8;name=" + f_name + "\r\n"
|
||||||
|
// attachment += "Contment-Type:" + message.attachment.contentType + ";name=\"" + message.attachment.name + "\"\r\n"
|
||||||
|
buffer.WriteString(attachment)
|
||||||
|
|
||||||
|
buffer.WriteString(newBuf.String())
|
||||||
|
|
||||||
|
buffer.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Form == "" {
|
||||||
|
message.Form = m.user
|
||||||
|
}
|
||||||
|
|
||||||
|
imgBuf := bytes.NewBuffer(nil)
|
||||||
|
err := m.writeFile(imgBuf, "./asset/余额宝.png")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("file err: %w", err)
|
||||||
|
}
|
||||||
|
f_name:= "f_name"
|
||||||
|
attachment := "--" + boundary + "\r\n"
|
||||||
|
attachment += "Content-Transfer-Encoding:base64\r\n"
|
||||||
|
attachment += "Content-ID:myimage \r\n"
|
||||||
|
attachment += "Content-Disposition:inline;filename=" + f_name + ".png \r\n"
|
||||||
|
attachment += "Content-Type:image/png \r\n"
|
||||||
|
buffer.WriteString(attachment)
|
||||||
|
|
||||||
|
buffer.WriteString(imgBuf.String())
|
||||||
|
|
||||||
|
buffer.WriteString("\r\n--" + boundary + "--\r\n")
|
||||||
|
|
||||||
|
b := buffer.Bytes()
|
||||||
|
err = smtp.SendMail(m.host+":"+m.port, m.auth, message.Form, message.To, b)
|
||||||
|
fmt.Println("发送结束:", err)
|
||||||
|
fmt.Println(string(b))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 格式化header
|
||||||
|
func (m *Mailx) writeHeader(buffer *bytes.Buffer, Header map[string]string) string {
|
||||||
|
header := ""
|
||||||
|
// header := "Content-Type: multipart/mixed;charset=UTF-8;boundary=\"YunBoundaryYun\" \r\n"
|
||||||
|
for key, value := range Header {
|
||||||
|
if value != "" {
|
||||||
|
header += key + ": " + value + "\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header += "\r\n"
|
||||||
|
buffer.WriteString(header)
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化文件
|
||||||
|
func (m *Mailx) writeFile(buffer *bytes.Buffer, fileName string) error {
|
||||||
|
file, err := os.ReadFile(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := make([]byte, base64.StdEncoding.EncodedLen(len(file)))
|
||||||
|
base64.StdEncoding.Encode(payload, file)
|
||||||
|
buffer.WriteString("\r\n")
|
||||||
|
for index, line := 0, len(payload); index < line; index++ {
|
||||||
|
buffer.WriteByte(payload[index])
|
||||||
|
if (index+1)%76 == 0 {
|
||||||
|
buffer.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package mailx_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mailx "code.yun.ink/pkg/mailx/old"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMail(t *testing.T) {
|
||||||
|
mail := mailx.NewMailx("support@email.blueoceanpay.com", "SupporT2017", "smtpdm-ap-southeast-1.aliyun.com", "80")
|
||||||
|
|
||||||
|
msg := mailx.Message{
|
||||||
|
// Form: "support@email.blueoceanpay.com",
|
||||||
|
To: []string{"huangxinyun520@gmail.com", "995116474@qq.com"},
|
||||||
|
// Cc: []string{"287852692@qq.com"},
|
||||||
|
// Bcc: []string{"1362716835@qq.com"},
|
||||||
|
Subject: "test mail",
|
||||||
|
Body: "<img src=\"cid:myimage\">dasdsadsasda<br>dasdsadsadsa",
|
||||||
|
Attachment: []mailx.Attachment{
|
||||||
|
{
|
||||||
|
Name: "asset/hhh.txt",
|
||||||
|
ContentType: "",
|
||||||
|
WithFile: true,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// Name: "/code/statistic/origin.xlsx",
|
||||||
|
// ContentType: "",
|
||||||
|
// WithFile: true,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// Name: "/code/statistic/out2.xlsx",
|
||||||
|
// ContentType: "",
|
||||||
|
// WithFile: true,
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mail.Send(msg)
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQQ(t *testing.T) {
|
||||||
|
|
||||||
|
// 发件人邮箱
|
||||||
|
from := "995116474@qq.com"
|
||||||
|
// 授权码,而非密码
|
||||||
|
authCode := "xxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
// 收件人邮箱,可以是多个收件人
|
||||||
|
to := []string{"yun@yun.ink"}
|
||||||
|
// 邮件服务器信息
|
||||||
|
smtpHost := "smtp.qq.com"
|
||||||
|
smtpPort := "587" // 或使用465,根据你的SMTP服务器要求设置
|
||||||
|
|
||||||
|
mail := mailx.NewMailx(from, authCode, smtpHost, smtpPort)
|
||||||
|
|
||||||
|
msg := mailx.Message{
|
||||||
|
To: to,
|
||||||
|
Subject: "test mail",
|
||||||
|
Body: "测试",
|
||||||
|
}
|
||||||
|
err := mail.Send(msg)
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -1,315 +1,6 @@
|
|||||||
# mailx
|
|
||||||
|
|
||||||
多通道邮件发送库,统一接口、简单易用,支持 SMTP / 阿里云 / AWS SES / Mailgun 等多种通道,可自由扩展。
|
# 需优化事项
|
||||||
|
|
||||||
## 特性
|
1. 需要优化日志输出
|
||||||
|
|
||||||
- **多通道接入**:`smtp`、`aliyun`、`aws`、`mailgun` 开箱即用,实现 `mailx.Sender` 接口即可扩展新通道
|
|
||||||
- **链式消息构建**:`mailx.NewMessage()` 流式拼接邮件内容
|
|
||||||
- **通道管理器**:注册多个通道,发送时按名称路由或临时指定配置
|
|
||||||
- **发送时指定配置**:无需提前初始化,可在调用 `Send` 时传入任意通道配置
|
|
||||||
- **日志可插拔**:定义轻量 `Logger` 接口,可适配 loggerx / zap / logrus 等任意日志库
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go get code.yun.ink/pkg/mailx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
> 第一次使用?推荐先跑通最简示例 `examples/quickstart`(逐行注释),
|
|
||||||
> 再阅读下面的 API 说明,最后看 `examples/` 里的进阶示例。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 克隆仓库后直接运行最简示例(需把其中 SMTP 配置换成你的)
|
|
||||||
cd examples/quickstart && go run main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### 方式一:单个通道直接发送
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
|
||||||
"code.yun.ink/pkg/mailx/smtp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com",
|
|
||||||
Port: 587,
|
|
||||||
User: "sender@qq.com",
|
|
||||||
Password: "authorization-code",
|
|
||||||
From: "sender@qq.com", // 可选,默认取 User
|
|
||||||
}).Send(ctx, mailx.NewMessage().
|
|
||||||
To("receiver@example.com").
|
|
||||||
Subject("Hello").
|
|
||||||
HTML("<h1>Hello world</h1>").
|
|
||||||
Build())
|
|
||||||
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式二:多通道管理器
|
|
||||||
|
|
||||||
注册多个通道,发送时按名称切换,适合业务侧多通道路由/降级。
|
|
||||||
|
|
||||||
```go
|
|
||||||
mgr := mailx.NewManager()
|
|
||||||
mgr.Register(smtp.New(smtp.Config{Host: "smtp.qq.com", Port: 587, User: "u", Password: "p"}))
|
|
||||||
mgr.Register(aliyun.New(aliyun.Config{
|
|
||||||
AccessKeyID: "ak", AccessKeySecret: "sk", AccountName: "noreply@example.com",
|
|
||||||
}))
|
|
||||||
mgr.Register(aws.New(aws.Config{
|
|
||||||
AccessKeyID: "ak", AccessKeySecret: "sk", Region: "ap-northeast-1", Sender: "noreply@example.com",
|
|
||||||
}))
|
|
||||||
mgr.Register(mailgun.New(mailgun.Config{APIKey: "key", Domain: "mg.example.com", Sender: "noreply@example.com"}))
|
|
||||||
|
|
||||||
// 可选:指定默认通道,不设置则使用第一个注册的通道
|
|
||||||
mgr.SetDefault("aliyun")
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From("noreply@example.com").
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("Hello").
|
|
||||||
Body("hi").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
mgr.Send(ctx, msg) // 使用默认通道
|
|
||||||
mgr.SendWith(ctx, "aws", msg) // 使用指定通道
|
|
||||||
```
|
|
||||||
|
|
||||||
**同一通道类型注册多份不同配置**:用 `RegisterNamed` 指定实例名,即可注册多个 smtp 实例(如主备切换)。
|
|
||||||
|
|
||||||
```go
|
|
||||||
mgr.RegisterNamed("smtp-main", smtp.New(smtp.Config{Host: "smtp.qq.com", Port: 465, User: "a@qq.com", Password: "main"}))
|
|
||||||
mgr.RegisterNamed("smtp-backup", smtp.New(smtp.Config{Host: "smtp.163.com", Port: 465, User: "a@163.com", Password: "backup"}))
|
|
||||||
|
|
||||||
mgr.SendWith(ctx, "smtp-main", msg) // 用主 SMTP
|
|
||||||
mgr.SendWith(ctx, "smtp-backup", msg) // 用备用 SMTP
|
|
||||||
```
|
|
||||||
|
|
||||||
> 说明:`Register(s)` 以通道类型名(`s.Name()`)作为实例名,同一类型仅能注册一个;需要多配置时用 `RegisterNamed(name, s)`。
|
|
||||||
|
|
||||||
### 方式三:发送时临时指定配置
|
|
||||||
|
|
||||||
通道无需提前注册,发送时直接传入带配置的通道实例。
|
|
||||||
|
|
||||||
```go
|
|
||||||
err := mgr.SendBy(ctx, mailgun.New(mailgun.Config{
|
|
||||||
APIKey: "another-key", Domain: "mg2.example.com", Sender: "noreply@example.com",
|
|
||||||
}), msg)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 消息构建 API
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `From(addr)` | 发件人(可含显示名,如 `"张三" <a@b.com>`) |
|
|
||||||
| `To(addr...)` / `Cc(...)` / `Bcc(...)` | 收件人 / 抄送 / 密送(可含显示名) |
|
|
||||||
| `Subject(s)` | 主题 |
|
|
||||||
| `Text(s)` | 纯文本正文(可选,推荐配合 HTML 使用) |
|
|
||||||
| `Body(s)` / `HTML(s)` | 正文(HTML) |
|
|
||||||
| `ReplyTo(addr)` | 回复地址 |
|
|
||||||
| `Attach(path)` / `AttachBytes(name, data)` | 普通附件(路径 / 内存字节) |
|
|
||||||
| `InlineImage(cid, path)` / `InlineImageBytes(cid, name, data)` | 内嵌图片,HTML 中用 `<img src="cid:<cid>">` 引用 |
|
|
||||||
| `Header(key, value)` | 自定义邮件头(如 `List-Unsubscribe`、`X-Mailer`),标准头不可覆盖 |
|
|
||||||
| `Build()` | 生成 `*Message` |
|
|
||||||
|
|
||||||
### 内嵌图片示例
|
|
||||||
|
|
||||||
```go
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
To("user@example.com").
|
|
||||||
Subject("Welcome").
|
|
||||||
HTML(`<h1>Hi</h1><img src="cid:logo1">`).
|
|
||||||
InlineImageBytes("logo1", "logo.png", pngBytes). // 或 InlineImage("logo1", "logo.png")
|
|
||||||
Build()
|
|
||||||
```
|
|
||||||
|
|
||||||
> 说明:SMTP 与 Mailgun 支持内嵌图片;AWS SES / 阿里云 DirectMail 的 SendEmail 不支持内嵌图片,若使用会返回明确错误。
|
|
||||||
|
|
||||||
### 从配置/DTO 快捷构建
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 从 map 构建(便于接入配置/HTTP 请求体)
|
|
||||||
msg, _ := mailx.FromMap(map[string]any{
|
|
||||||
"from": "a@e.com", "to": "b@e.com, c@e.com",
|
|
||||||
"subject": "hi", "text": "plain", "html": "<b>hi</b>",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 从 JSON 构建
|
|
||||||
msg, _ := mailx.FromBytes(jsonData)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 地址工具
|
|
||||||
|
|
||||||
```go
|
|
||||||
mailx.IsValidAddress(`"张三" <a@b.com>`) // true,支持显示名
|
|
||||||
mailx.ExtractEmail(`"张三" <a@b.com>`) // "a@b.com",SMTP 命令需纯地址
|
|
||||||
mailx.AddressList("a@e.com, b@e.com; c@e.com") // 兼容逗号/分号分隔
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
所有错误均可通过 `errors.Is` 精确判断类型:
|
|
||||||
|
|
||||||
```go
|
|
||||||
err := mgr.SendWith(ctx, "nope", msg)
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, mailx.ErrSenderNotFound):
|
|
||||||
// 通道未注册,尝试其他通道
|
|
||||||
case errors.Is(err, mailx.ErrInvalidConfig):
|
|
||||||
// 配置缺失/非法
|
|
||||||
case errors.Is(err, mailx.ErrInvalidMessage):
|
|
||||||
// 消息校验失败
|
|
||||||
case errors.Is(err, mailx.ErrSendFailed):
|
|
||||||
// 发送过程中通道返回错误
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 扩展新通道
|
|
||||||
|
|
||||||
实现 `mailx.Sender` 接口即可:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Sender interface {
|
|
||||||
Name() string // 通道唯一名称
|
|
||||||
Send(ctx context.Context, msg *mailx.Message) error
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
package mychannel
|
|
||||||
|
|
||||||
type MyChannel struct{ cfg Config }
|
|
||||||
|
|
||||||
func New(cfg Config) *MyChannel { return &MyChannel{cfg: cfg} }
|
|
||||||
func (c *MyChannel) Name() string { return "mychannel" }
|
|
||||||
func (c *MyChannel) Send(ctx context.Context, msg *mailx.Message) error {
|
|
||||||
// 实现发送逻辑
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 日志
|
|
||||||
|
|
||||||
通道默认不输出日志;可通过两种方式注入:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 1. 管理器全局注入
|
|
||||||
mgr.SetLogger(myLogger) // 实现 mailx.Logger 接口
|
|
||||||
|
|
||||||
// 2. 通过 context 注入
|
|
||||||
ctx = mailx.WithLogger(ctx, myLogger)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 通道配置一览
|
|
||||||
|
|
||||||
| 通道 | 配置结构 | 必填 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| smtp | `smtp.Config{Host, Port, User, Password, From, ReplyTo, Encryption, Timeout}` | Host/Port/User/Password |
|
|
||||||
| aliyun | `aliyun.Config{AccessKeyID, AccessKeySecret, Endpoint, AccountName, ReplyAddress, Timeout}` | AccessKeyID/AccessKeySecret/AccountName |
|
|
||||||
| aws | `aws.Config{AccessKeyID, AccessKeySecret, Region, Sender, Timeout}` | Sender(AWS 需预先验证发件地址) |
|
|
||||||
| mailgun | `mailgun.Config{APIKey, Domain, Sender, Timeout}` | APIKey/Domain |
|
|
||||||
|
|
||||||
### SMTP 加密
|
|
||||||
|
|
||||||
`smtp.Config.Encryption` 支持以下模式,默认 `auto`(按端口自动选择):
|
|
||||||
|
|
||||||
| 模式 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `auto` | 端口 465 走 SSL,其余端口走 STARTTLS |
|
|
||||||
| `ssl` | 隐式 TLS(端口 465) |
|
|
||||||
| `tls` | STARTTLS 升级加密(端口 587/25) |
|
|
||||||
| `none` | 明文(仅内网/测试,不推荐) |
|
|
||||||
|
|
||||||
## 超时控制
|
|
||||||
|
|
||||||
每个通道都支持 `Timeout` 配置,防止发送阻塞,避免 goroutine 泄漏:
|
|
||||||
|
|
||||||
- 默认超时 30s
|
|
||||||
- 若调用方通过 `context.WithTimeout/WithDeadline` 传入更早的 deadline,则以更早者为准
|
|
||||||
- 各通道具体实现:SMTP 为连接与投递的整体 deadline;阿里云/AWS/Mailgun 通过 API 超时 + ctx 取消兜底
|
|
||||||
|
|
||||||
```go
|
|
||||||
client := smtp.New(smtp.Config{
|
|
||||||
Host: "smtp.qq.com", Port: 465, User: "u", Password: "p",
|
|
||||||
Timeout: 10 * time.Second, // 单次发送 10s 超时
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manager 实例管理
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 注册(实例名 = 通道类型名)与命名注册(可同类型多份)
|
|
||||||
mgr.Register(s) // 实例名取 s.Name()
|
|
||||||
mgr.RegisterNamed("smtp-backup", s) // 自定义实例名
|
|
||||||
|
|
||||||
// 注销与默认实例
|
|
||||||
mgr.Unregister("smtp-backup") // 注销后自动回退默认实例
|
|
||||||
mgr.SetDefault("smtp-main") // 设置默认实例
|
|
||||||
|
|
||||||
// 遍历与查询
|
|
||||||
mgr.Registered("smtp-backup") // 实例是否已注册
|
|
||||||
mgr.Names() // 所有实例名
|
|
||||||
mgr.Senders() // []SenderInfo{Name 实例名, Type 通道类型}
|
|
||||||
mgr.Default() // 当前默认实例名
|
|
||||||
```
|
|
||||||
|
|
||||||
遍历示例:
|
|
||||||
|
|
||||||
```go
|
|
||||||
for _, info := range mgr.Senders() {
|
|
||||||
fmt.Println(info.Name, info.Type) // 如 "smtp-main" "smtp"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 生产建议
|
|
||||||
|
|
||||||
- **复用通道实例**:各通道内部惰性初始化并复用 SDK 客户端/连接池,业务侧应复用 `New` 出来的单例,避免每次新建
|
|
||||||
- **消息校验**:`Message.Validate` 自动限制收件人总数(To+Cc+Bcc)≤50、附件数 ≤20、自定义头 ≤20、整封邮件大小 ≤25MB(`MaxMessageSize`),防止滥用配额
|
|
||||||
- **并发安全**:`Manager` 与所有通道实例均线程安全,可安全地在多个 goroutine 中共享
|
|
||||||
- **日志**:生产环境建议实现并注入 `Logger`。通过 `Manager` 发送时,框架会自动记录通道名、收件人、发送耗时与失败原因
|
|
||||||
- **自定义邮件头**:营销邮件常用 `List-Unsubscribe` 退订头、`X-Mailer` 标识等,用 `Header(key, value)` 设置(标准头不可覆盖)
|
|
||||||
|
|
||||||
## 示例代码
|
|
||||||
|
|
||||||
仓库提供由浅入深的可运行示例,位于 `examples/` 目录(详见 [`examples/README.md`](./examples/README.md)):
|
|
||||||
|
|
||||||
| 示例 | 难度 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `examples/quickstart` | 入门 | 最简 SMTP 发送,逐行注释,适合第一次接触 |
|
|
||||||
| `examples/basic` | 入门 | 单通道完整用法:HTML+附件+内嵌图片+显示名 |
|
|
||||||
| `examples/manager` | 入门 | 多通道管理器:注册路由、主备切换、临时指定配置 |
|
|
||||||
| `examples/with_env` | 进阶 | 用环境变量管理多通道凭据,避免密钥写死 |
|
|
||||||
| `examples/advanced` | 进阶 | 主备切换、实例遍历、日志、超时、错误分类 |
|
|
||||||
| `examples/custom_sender` | 进阶 | 实现自定义通道 + 接入自定义 Logger |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 5 分钟上手(最简示例)
|
|
||||||
cd examples/quickstart && go run main.go
|
|
||||||
|
|
||||||
# 其余示例(在项目根目录执行)
|
|
||||||
go run ./examples/basic
|
|
||||||
go run ./examples/manager
|
|
||||||
go run ./examples/with_env # 需先配置 examples/with_env/.env
|
|
||||||
go run ./examples/advanced
|
|
||||||
go run ./examples/custom_sender
|
|
||||||
```
|
|
||||||
|
|
||||||
核心 API 的用法示例(`Example*` 测试)也会在 `go test` 中自动验证,可通过 `go doc` 查看。
|
|
||||||
|
|
||||||
## 其他能力
|
|
||||||
|
|
||||||
- `ParseHTMLResource(html)`:解析 HTML 中引用的 css/js/img 等静态资源地址
|
|
||||||
- `aliyun.SyncStatus(ctx)`:阿里云通道回传发送状态记录
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// Sender 发送通道接口,所有通道都实现该接口。
|
|
||||||
// 实现该接口即可接入任意发送通道(SMTP/阿里云/AWS/Mailgun/自定义网关等),
|
|
||||||
// 并复用 Manager 的注册路由、默认通道、日志注入与消息校验能力。
|
|
||||||
type Sender interface {
|
|
||||||
// Name 返回通道类型名(如 "smtp" / "aliyun" / "aws" / "mailgun")。
|
|
||||||
// 注意:Name 表示通道类型,不代表管理器中的唯一实例;
|
|
||||||
// 同一类型可用 RegisterNamed 注册多份不同配置的实例。
|
|
||||||
Name() string
|
|
||||||
|
|
||||||
// Send 发送一封邮件。ctx 用于超时与取消控制;
|
|
||||||
// 可通过 LoggerFromContext(ctx) 获取调用方注入的日志器。
|
|
||||||
Send(ctx context.Context, msg *Message) error
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# SMTP 邮件发送优化说明
|
||||||
|
|
||||||
|
## 主要优化内容
|
||||||
|
|
||||||
|
### 1. 安全性优化
|
||||||
|
- **敏感信息保护**: 日志输出时过滤密码等敏感信息
|
||||||
|
- **TLS安全配置**: 强制使用TLS 1.2+,配置安全的加密套件
|
||||||
|
- **输入验证**: 严格验证配置参数和邮件内容
|
||||||
|
|
||||||
|
### 2. 功能完善
|
||||||
|
- **STARTTLS支持**: 添加587端口的STARTTLS连接方式
|
||||||
|
- **多种连接方式**: 支持465端口SSL和587端口STARTTLS
|
||||||
|
- **完整收件人支持**: SSL方式现在也支持Cc、Bcc和附件
|
||||||
|
- **文件大小限制**: 附件大小限制为25MB,防止内存溢出
|
||||||
|
- **MIME类型检测**: 自动检测附件的正确MIME类型
|
||||||
|
|
||||||
|
### 3. 错误处理优化
|
||||||
|
- **结构化日志**: 使用Logger接口替代fmt.Println
|
||||||
|
- **详细错误信息**: 提供更具体的错误描述
|
||||||
|
- **重试机制**: 网络失败时自动重试最多3次
|
||||||
|
- **超时控制**: 连接超时设置为30秒
|
||||||
|
|
||||||
|
### 4. 代码质量提升
|
||||||
|
- **消除重复代码**: 提取公共方法处理邮件地址格式化
|
||||||
|
- **标准时间格式**: 使用RFC 1123Z标准时间格式
|
||||||
|
- **流式文件处理**: 大附件使用流式编码,节省内存
|
||||||
|
- **配置验证**: 启动时验证配置完整性
|
||||||
|
|
||||||
|
### 5. 性能优化
|
||||||
|
- **认证复用**: 在SetOption时创建认证,避免重复创建
|
||||||
|
- **连接超时**: 设置合理的连接超时时间
|
||||||
|
- **内存优化**: 大文件流式处理,避免全部加载到内存
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### 连接方式自动选择
|
||||||
|
```go
|
||||||
|
// 根据端口自动选择连接方式
|
||||||
|
switch port {
|
||||||
|
case "465":
|
||||||
|
// 使用直接SSL连接
|
||||||
|
case "587":
|
||||||
|
// 使用STARTTLS
|
||||||
|
default:
|
||||||
|
// 根据IsSSL配置选择
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整的邮件头支持
|
||||||
|
- MIME-Version: 1.0
|
||||||
|
- 标准时间格式
|
||||||
|
- 正确的Content-Type设置
|
||||||
|
- 支持Reply-To字段
|
||||||
|
|
||||||
|
### 附件处理增强
|
||||||
|
- 文件大小检查
|
||||||
|
- MIME类型自动检测
|
||||||
|
- 流式base64编码
|
||||||
|
- 76字符换行符合RFC标准
|
||||||
|
|
||||||
|
### 错误处理和日志
|
||||||
|
- 结构化错误信息
|
||||||
|
- 重试机制with指数退避
|
||||||
|
- 详细的调试日志
|
||||||
|
- 安全的配置日志输出
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
```go
|
||||||
|
smtp := NewSmtp()
|
||||||
|
smtp.SetOption(ctx, func(opt *interfaces.Options) {
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.gmail.com",
|
||||||
|
Port: "587",
|
||||||
|
Username: "user@gmail.com",
|
||||||
|
Password: "app-password",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
message := interfaces.Message{
|
||||||
|
To: []string{"recipient@example.com"},
|
||||||
|
Subject: "Test Email",
|
||||||
|
Body: "<h1>Hello World</h1>",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := smtp.Send(ctx, message)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 企业邮箱
|
||||||
|
```go
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.exmail.qq.com",
|
||||||
|
Port: "587",
|
||||||
|
Username: "admin@company.com",
|
||||||
|
Password: "password",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL连接
|
||||||
|
```go
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.gmail.com",
|
||||||
|
Port: "465",
|
||||||
|
Username: "user@gmail.com",
|
||||||
|
Password: "app-password",
|
||||||
|
IsSSL: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试覆盖
|
||||||
|
|
||||||
|
- 配置验证测试
|
||||||
|
- 消息验证测试
|
||||||
|
- 邮件地址格式化测试
|
||||||
|
- 超时设置测试
|
||||||
|
- 性能基准测试
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
- 保持与原有接口的完全兼容
|
||||||
|
- 支持所有主流邮件服务商
|
||||||
|
- 支持企业邮箱系统
|
||||||
|
- 向后兼容旧的配置方式
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. 使用应用专用密码而不是账户密码
|
||||||
|
2. 在生产环境中启用TLS证书验证
|
||||||
|
3. 定期轮换邮件服务密码
|
||||||
|
4. 监控邮件发送日志以检测异常
|
||||||
|
5. 限制附件大小和类型以防止滥用
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
func ExampleUsage() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 创建SMTP实例
|
||||||
|
smtpClient := NewSmtp()
|
||||||
|
|
||||||
|
// 配置SMTP设置
|
||||||
|
err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) {
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.gmail.com",
|
||||||
|
Port: "587", // STARTTLS
|
||||||
|
Username: "your-email@gmail.com",
|
||||||
|
Password: "your-app-password",
|
||||||
|
IsSSL: false, // 使用STARTTLS而不是直接SSL
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to configure SMTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建邮件消息
|
||||||
|
message := interfaces.Message{
|
||||||
|
Form: "sender@example.com",
|
||||||
|
To: []string{"recipient1@example.com", "recipient2@example.com"},
|
||||||
|
Cc: []string{"cc@example.com"},
|
||||||
|
Bcc: []string{"bcc@example.com"},
|
||||||
|
Subject: "测试邮件 - Enhanced SMTP",
|
||||||
|
Body: "<h1>这是一封测试邮件</h1><p>支持HTML格式和多种功能。</p>",
|
||||||
|
ReplyTo: "noreply@example.com",
|
||||||
|
Attachment: []interfaces.Attachment{
|
||||||
|
{Content: "path/to/attachment.pdf"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送邮件
|
||||||
|
if err := smtpClient.Send(ctx, message); err != nil {
|
||||||
|
log.Fatalf("Failed to send email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Email sent successfully!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSL方式示例(465端口)
|
||||||
|
func ExampleSSLUsage() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
smtpClient := NewSmtp()
|
||||||
|
|
||||||
|
err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) {
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.gmail.com",
|
||||||
|
Port: "465", // SSL
|
||||||
|
Username: "your-email@gmail.com",
|
||||||
|
Password: "your-app-password",
|
||||||
|
IsSSL: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to configure SMTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := interfaces.Message{
|
||||||
|
To: []string{"recipient@example.com"},
|
||||||
|
Subject: "SSL邮件测试",
|
||||||
|
Body: "这是通过SSL连接发送的邮件",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := smtpClient.Send(ctx, message); err != nil {
|
||||||
|
log.Fatalf("Failed to send email: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业邮箱示例
|
||||||
|
func ExampleEnterpriseEmail() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
smtpClient := NewSmtp()
|
||||||
|
|
||||||
|
// 企业邮箱通常使用587端口和STARTTLS
|
||||||
|
err := smtpClient.SetOption(ctx, func(opt *interfaces.Options) {
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.exmail.qq.com", // 腾讯企业邮箱
|
||||||
|
Port: "587",
|
||||||
|
Username: "admin@yourcompany.com",
|
||||||
|
Password: "your-password",
|
||||||
|
IsSSL: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to configure SMTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := interfaces.Message{
|
||||||
|
Form: "系统通知 <admin@yourcompany.com>",
|
||||||
|
To: []string{"employee@yourcompany.com"},
|
||||||
|
Subject: "系统维护通知",
|
||||||
|
Body: `
|
||||||
|
<div style="font-family: Arial, sans-serif;">
|
||||||
|
<h2>系统维护通知</h2>
|
||||||
|
<p>尊敬的用户:</p>
|
||||||
|
<p>我们将于今晚进行系统维护,预计维护时间为2小时。</p>
|
||||||
|
<p>维护期间系统将暂停服务,给您带来的不便敬请谅解。</p>
|
||||||
|
<br>
|
||||||
|
<p>技术团队</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := smtpClient.Send(ctx, message); err != nil {
|
||||||
|
log.Fatalf("Failed to send email: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "<html><body>Test</body></html>",
|
||||||
|
expected: interfaces.ContentTypeHTML,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTML with div tag",
|
||||||
|
body: "<div>Test content</div>",
|
||||||
|
expected: interfaces.ContentTypeHTML,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTML with img tag",
|
||||||
|
body: "Check this image: <img src='test.jpg'>",
|
||||||
|
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: "<h1>Test</h1>",
|
||||||
|
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: "<img src='cid:test'>",
|
||||||
|
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: "<h1>HTML Content</h1><img src='cid:test-img'>",
|
||||||
|
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: "<h1>Benchmark</h1><p>Performance test content</p>",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+530
-533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSmtpEnhanced(t *testing.T) {
|
||||||
|
smtp := NewSmtp()
|
||||||
|
|
||||||
|
// 测试配置验证
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 测试空配置
|
||||||
|
err := smtp.SetOption(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for empty config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试不完整配置
|
||||||
|
err = smtp.SetOption(ctx, func(opt *interfaces.Options) {
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
// 缺少用户名和密码
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for incomplete config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试完整配置
|
||||||
|
err = smtp.SetOption(ctx, func(opt *interfaces.Options) {
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: "587",
|
||||||
|
Username: "test@example.com",
|
||||||
|
Password: "password",
|
||||||
|
IsSSL: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error for valid config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageValidation(t *testing.T) {
|
||||||
|
smtp := NewSmtp()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 设置有效配置
|
||||||
|
smtp.SetOption(ctx, func(opt *interfaces.Options) {
|
||||||
|
opt.Smtp = &interfaces.EmailConfigDataSmtp{
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: "587",
|
||||||
|
Username: "test@example.com",
|
||||||
|
Password: "password",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测试空收件人
|
||||||
|
err := smtp.validateMessage(interfaces.Message{
|
||||||
|
Subject: "Test",
|
||||||
|
Body: "Test body",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for empty recipients")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试空主题
|
||||||
|
err = smtp.validateMessage(interfaces.Message{
|
||||||
|
To: []string{"recipient@example.com"},
|
||||||
|
Body: "Test body",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for empty subject")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有效消息
|
||||||
|
err = smtp.validateMessage(interfaces.Message{
|
||||||
|
To: []string{"recipient@example.com"},
|
||||||
|
Subject: "Test",
|
||||||
|
Body: "Test body",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error for valid message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailAddressFormatting(t *testing.T) {
|
||||||
|
smtp := NewSmtp()
|
||||||
|
|
||||||
|
addresses := []string{"user1@example.com", "user2@test.com"}
|
||||||
|
formatted := smtp.formatEmailAddresses(addresses)
|
||||||
|
|
||||||
|
expected := "user1<user1@example.com>,user2<user2@test.com>"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Errorf("Expected %s, got %s", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeout(t *testing.T) {
|
||||||
|
smtp := NewSmtp()
|
||||||
|
|
||||||
|
// 测试默认超时
|
||||||
|
if smtp.timeout != DefaultTimeout {
|
||||||
|
t.Errorf("Expected default timeout %v, got %v", DefaultTimeout, smtp.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试自定义超时
|
||||||
|
customTimeout := 60 * time.Second
|
||||||
|
smtp.timeout = customTimeout
|
||||||
|
if smtp.timeout != customTimeout {
|
||||||
|
t.Errorf("Expected custom timeout %v, got %v", customTimeout, smtp.timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基准测试
|
||||||
|
func BenchmarkEmailAddressFormatting(b *testing.B) {
|
||||||
|
smtp := NewSmtp()
|
||||||
|
addresses := []string{"user1@example.com", "user2@test.com", "user3@demo.com"}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
smtp.formatEmailAddresses(addresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
package smtp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mailx "code.yun.ink/pkg/mailx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildMIME(t *testing.T) {
|
|
||||||
s := New(Config{Host: "h", Port: 25, From: "default@example.com", ReplyTo: "dr@example.com"})
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From("sender@example.com").
|
|
||||||
To("to@example.com").
|
|
||||||
Cc("cc@example.com").
|
|
||||||
Subject("主题").
|
|
||||||
Body("<p>hi</p>").
|
|
||||||
ReplyTo("msg-reply@example.com").
|
|
||||||
AttachBytes("a.txt", []byte("content")).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
data, err := s.buildMIME(msg, "sender@example.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out := string(data)
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"From: sender@example.com",
|
|
||||||
"To: to@example.com",
|
|
||||||
"Cc: cc@example.com",
|
|
||||||
"Reply-To: msg-reply@example.com",
|
|
||||||
"MIME-Version: 1.0",
|
|
||||||
"multipart/mixed",
|
|
||||||
"text/html; charset=UTF-8",
|
|
||||||
"attachment; filename",
|
|
||||||
"Y29udGVudA==", // base64("content")
|
|
||||||
} {
|
|
||||||
if !strings.Contains(out, want) {
|
|
||||||
t.Errorf("MIME missing %q, got:\n%s", want, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMIMEWithoutBody(t *testing.T) {
|
|
||||||
s := New(Config{})
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
To("to@example.com").
|
|
||||||
Subject("s").
|
|
||||||
AttachBytes("a.txt", []byte("x")).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
data, err := s.buildMIME(msg, "f@example.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out := string(data)
|
|
||||||
if strings.Contains(out, "text/html") {
|
|
||||||
t.Errorf("should not contain html part, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "multipart/mixed") {
|
|
||||||
t.Errorf("should contain multipart/mixed for attachment, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMIMEFromFallback(t *testing.T) {
|
|
||||||
// Message.From 为空时回退到 Config.From
|
|
||||||
s := New(Config{From: "cfg-from@example.com"})
|
|
||||||
msg := mailx.NewMessage().To("t@e.com").Subject("s").Build()
|
|
||||||
data, err := s.buildMIME(msg, "cfg-from@example.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), "From: cfg-from@example.com") {
|
|
||||||
t.Fatalf("From header missing, got:\n%s", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadAttachment(t *testing.T) {
|
|
||||||
s := New(Config{})
|
|
||||||
|
|
||||||
// 按路径
|
|
||||||
dir := t.TempDir()
|
|
||||||
p := filepath.Join(dir, "x.txt")
|
|
||||||
if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
name, data, err := s.readAttachment(mailx.Attachment{Path: p})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if name != "x.txt" || string(data) != "abc" {
|
|
||||||
t.Errorf("name=%q data=%q", name, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存字节
|
|
||||||
name, data, err = s.readAttachment(mailx.Attachment{Name: "m.bin", Data: []byte{1, 2}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if name != "m.bin" || !bytes.Equal(data, []byte{1, 2}) {
|
|
||||||
t.Errorf("name=%q data=%v", name, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 空附件报错
|
|
||||||
if _, _, err := s.readAttachment(mailx.Attachment{}); err == nil {
|
|
||||||
t.Fatal("empty attachment should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路径不存在报错
|
|
||||||
if _, _, err := s.readAttachment(mailx.Attachment{Path: filepath.Join(dir, "nope.txt")}); err == nil {
|
|
||||||
t.Fatal("missing file should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEncodeHeader(t *testing.T) {
|
|
||||||
if got := encodeHeader("plain"); got != "plain" {
|
|
||||||
t.Errorf("encodeHeader(plain) = %q", got)
|
|
||||||
}
|
|
||||||
got := encodeHeader("主题")
|
|
||||||
if !strings.HasPrefix(got, "=?UTF-8?q?") || !strings.HasSuffix(got, "?=") {
|
|
||||||
t.Errorf("encodeHeader(主题) = %q, want RFC 2047 encoded", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEffectiveEncryption(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
cfg Config
|
|
||||||
want Encryption
|
|
||||||
}{
|
|
||||||
{"default 587 -> tls", Config{Host: "h", Port: 587}, EncryptionTLS},
|
|
||||||
{"default 465 -> ssl", Config{Host: "h", Port: 465}, EncryptionSSL},
|
|
||||||
{"default 25 -> tls", Config{Host: "h", Port: 25}, EncryptionTLS},
|
|
||||||
{"explicit ssl", Config{Host: "h", Port: 587, Encryption: EncryptionSSL}, EncryptionSSL},
|
|
||||||
{"explicit tls", Config{Host: "h", Port: 465, Encryption: EncryptionTLS}, EncryptionTLS},
|
|
||||||
{"explicit none", Config{Host: "h", Port: 465, Encryption: EncryptionNone}, EncryptionNone},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
if got := New(c.cfg).effectiveEncryption(); got != c.want {
|
|
||||||
t.Errorf("effectiveEncryption() = %q, want %q", got, c.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMIMEInlineImage(t *testing.T) {
|
|
||||||
s := New(Config{})
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
From(`"张三" <sender@example.com>`).
|
|
||||||
To("to@example.com").
|
|
||||||
Subject("s").
|
|
||||||
HTML(`<p>hi <img src="cid:logo1"></p>`).
|
|
||||||
InlineImageBytes("logo1", "logo.png", []byte{0x89, 0x50, 0x4e, 0x47}).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
data, err := s.buildMIME(msg, `"张三" <sender@example.com>`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out := string(data)
|
|
||||||
|
|
||||||
// 显示名应被 RFC2047 编码
|
|
||||||
if !strings.Contains(out, "From: =?UTF-8?q?") {
|
|
||||||
t.Errorf("display name not encoded, got:\n%s", out)
|
|
||||||
}
|
|
||||||
// related 结构 + 内嵌图片
|
|
||||||
if !strings.Contains(out, "multipart/related") {
|
|
||||||
t.Errorf("missing multipart/related, got:\n%s", out)
|
|
||||||
}
|
|
||||||
// textproto 会将 Content-ID 规范化为 Content-Id(RFC 允许,客户端能正确解析)
|
|
||||||
if !strings.Contains(out, "Content-Id: <logo1>") && !strings.Contains(out, "Content-ID: <logo1>") {
|
|
||||||
t.Errorf("missing Content-ID, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "inline; filename") {
|
|
||||||
t.Errorf("missing inline disposition, got:\n%s", out)
|
|
||||||
}
|
|
||||||
// png 签名 base64(0x89 0x50 0x4e 0x47 -> iVBORw==)
|
|
||||||
if !strings.Contains(out, "iVBORw==") {
|
|
||||||
t.Errorf("png data missing, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMIMECustomHeaders(t *testing.T) {
|
|
||||||
s := New(Config{})
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
To("t@e.com").
|
|
||||||
Subject("s").
|
|
||||||
Header("List-Unsubscribe", "<https://example.com/unsub>"). // 自定义头
|
|
||||||
Header("X-Mailer", "mailx").
|
|
||||||
Header("Subject", "should-not-override"). // 标准头,不应覆盖
|
|
||||||
Build()
|
|
||||||
|
|
||||||
data, err := s.buildMIME(msg, "f@e.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out := string(data)
|
|
||||||
|
|
||||||
if !strings.Contains(out, "List-Unsubscribe: <https://example.com/unsub>") {
|
|
||||||
t.Errorf("missing custom header List-Unsubscribe, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "X-Mailer: mailx") {
|
|
||||||
t.Errorf("missing custom header X-Mailer, got:\n%s", out)
|
|
||||||
}
|
|
||||||
// 标准头 Subject 不应被自定义值覆盖
|
|
||||||
if strings.Contains(out, "Subject: should-not-override") {
|
|
||||||
t.Errorf("custom header overrode standard Subject, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "Subject: s") {
|
|
||||||
t.Errorf("standard Subject lost, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadInline(t *testing.T) {
|
|
||||||
s := New(Config{})
|
|
||||||
|
|
||||||
// 按路径读取,MIME 类型由扩展名推断
|
|
||||||
dir := t.TempDir()
|
|
||||||
p := filepath.Join(dir, "img.png")
|
|
||||||
if err := os.WriteFile(p, []byte{0x89, 0x50, 0x4e, 0x47}, 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
name, data, mtype, err := s.readInline(mailx.InlineImage{CID: "cid1", Path: p})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if name != "img.png" || string(data) != string([]byte{0x89, 0x50, 0x4e, 0x47}) {
|
|
||||||
t.Errorf("path inline: name=%q data=%v", name, data)
|
|
||||||
}
|
|
||||||
if mtype != "image/png" {
|
|
||||||
t.Errorf("mtype = %q, want image/png", mtype)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存字节 + 显式 MIME 类型
|
|
||||||
name, data, mtype, err = s.readInline(mailx.InlineImage{CID: "c2", Name: "x.jpg", Data: []byte{1}, MIMEType: "image/jpeg"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if name != "x.jpg" || mtype != "image/jpeg" {
|
|
||||||
t.Errorf("memory inline: name=%q mtype=%q", name, mtype)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 空 inline 报错
|
|
||||||
if _, _, _, err := s.readInline(mailx.InlineImage{}); err == nil {
|
|
||||||
t.Fatal("empty inline should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路径不存在报错
|
|
||||||
if _, _, _, err := s.readInline(mailx.InlineImage{CID: "c", Path: filepath.Join(dir, "nope.png")}); err == nil {
|
|
||||||
t.Fatal("missing inline file should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatAddressHeader(t *testing.T) {
|
|
||||||
// 纯地址原样
|
|
||||||
if got := formatAddressHeader("a@example.com"); got != "a@example.com" {
|
|
||||||
t.Errorf("plain = %q", got)
|
|
||||||
}
|
|
||||||
// 带显示名
|
|
||||||
got := formatAddressHeader(`"张三" <a@example.com>`)
|
|
||||||
if !strings.Contains(got, "=?UTF-8?q?") || !strings.Contains(got, "<a@example.com>") {
|
|
||||||
t.Errorf("display name = %q", got)
|
|
||||||
}
|
|
||||||
// 非法地址回退为 RFC2047 编码
|
|
||||||
if got := formatAddressHeader("not-an-email"); got == "" {
|
|
||||||
t.Errorf("invalid address should fallback, got empty")
|
|
||||||
}
|
|
||||||
// 空字符串
|
|
||||||
if got := formatAddressHeader(""); got != "" {
|
|
||||||
t.Errorf("empty = %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatAddressList(t *testing.T) {
|
|
||||||
got := formatAddressList([]string{"a@e.com", `"B" <b@e.com>`})
|
|
||||||
if !strings.Contains(got, "a@e.com") || !strings.Contains(got, "b@e.com") {
|
|
||||||
t.Errorf("list = %q", got)
|
|
||||||
}
|
|
||||||
// 空列表
|
|
||||||
if got := formatAddressList(nil); got != "" {
|
|
||||||
t.Errorf("empty list = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseNameAndPathExt(t *testing.T) {
|
|
||||||
if got := baseName("/a/b/c.txt"); got != "c.txt" {
|
|
||||||
t.Errorf("baseName unix = %q", got)
|
|
||||||
}
|
|
||||||
if got := baseName(`C:\dir\f.txt`); got != "f.txt" {
|
|
||||||
t.Errorf("baseName win = %q", got)
|
|
||||||
}
|
|
||||||
if got := pathExt("a.png"); got != ".png" {
|
|
||||||
t.Errorf("pathExt = %q", got)
|
|
||||||
}
|
|
||||||
if got := pathExt("noext"); got != "" {
|
|
||||||
t.Errorf("pathExt noext = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMIMEAlternative(t *testing.T) {
|
|
||||||
s := New(Config{})
|
|
||||||
msg := mailx.NewMessage().
|
|
||||||
To("t@e.com").
|
|
||||||
Subject("s").
|
|
||||||
Text("纯文本正文").
|
|
||||||
HTML("<p>html正文</p>").
|
|
||||||
Build()
|
|
||||||
|
|
||||||
data, err := s.buildMIME(msg, "f@e.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out := string(data)
|
|
||||||
|
|
||||||
if !strings.Contains(out, "multipart/alternative") {
|
|
||||||
t.Errorf("should use multipart/alternative, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "text/plain; charset=UTF-8") {
|
|
||||||
t.Errorf("missing text/plain part, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "text/html; charset=UTF-8") {
|
|
||||||
t.Errorf("missing text/html part, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+77
-232
@@ -1,257 +1,102 @@
|
|||||||
package smtp_test
|
package smtp_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx"
|
"code.yun.ink/pkg/mailx/interfaces"
|
||||||
"code.yun.ink/pkg/mailx/smtp"
|
"code.yun.ink/pkg/mailx/smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMail 真实发送到外部 SMTP 服务器,需要网络与有效凭据。
|
|
||||||
// 用于本地联调,CI 中可跳过。
|
|
||||||
func TestMail(t *testing.T) {
|
func TestMail(t *testing.T) {
|
||||||
if testing.Short() {
|
ini := smtp.NewSmtp()
|
||||||
t.Skip("skip real send in short mode")
|
|
||||||
}
|
|
||||||
client := smtp.New(smtp.Config{
|
|
||||||
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
|
||||||
Port: 80,
|
|
||||||
User: "support@email.blueoceanpay.com",
|
|
||||||
Password: "SupporT2017",
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req, err := http.Get("https://baidu.com")
|
// ini, err := sm.SetOption(ctx, interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{
|
||||||
if err != nil {
|
// Username: "support@email.blueoceanpay.com",
|
||||||
t.Fatal(err)
|
// Password: "SupporT2017",
|
||||||
}
|
// ReplyTo: "",
|
||||||
defer req.Body.Close()
|
// Host: "smtpdm-ap-southeast-1.aliyun.com",
|
||||||
|
// Port: "80",
|
||||||
|
// }))
|
||||||
|
// if err != nil {
|
||||||
|
// t.Fatal(err)
|
||||||
|
// }
|
||||||
|
|
||||||
by, err := io.ReadAll(req.Body)
|
err := ini.SetOption(ctx, interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{
|
||||||
|
IsSSL: true,
|
||||||
|
Username: "demo@yunink.net",
|
||||||
|
Password: "aAhVPzHydLoc9Fj8",
|
||||||
|
ReplyTo: "",
|
||||||
|
Host: "smtp.exmail.qq.com",
|
||||||
|
Port: "465",
|
||||||
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
// req, err := http.Get("https://baidu.com")
|
||||||
To("995116474@qq.com").
|
// if err != nil {
|
||||||
Cc("287852692@qq.com").
|
// t.Fatal(err)
|
||||||
Bcc("1362716835@qq.com").
|
// }
|
||||||
ReplyTo("huangxinyun@dreaminglife.cn").
|
// defer req.Body.Close()
|
||||||
Subject("test mail").
|
|
||||||
Body(string(by)).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
err = client.Send(ctx, msg)
|
// by, err := io.ReadAll(req.Body)
|
||||||
|
// if err != nil {
|
||||||
|
// t.Fatal(err)
|
||||||
|
// }
|
||||||
|
|
||||||
|
msg := interfaces.Message{
|
||||||
|
Form: "demo@yunink.net",
|
||||||
|
To: []string{"995116474@qq.com"},
|
||||||
|
Cc: []string{"287852692@qq.com"},
|
||||||
|
Bcc: []string{"1362716835@qq.com"},
|
||||||
|
ReplyTo: "huangxinyun@dreaminglife.cn",
|
||||||
|
Subject: "测试邮件",
|
||||||
|
Body: "这是测试邮件", //string(by),
|
||||||
|
Attachment: []interfaces.Attachment{
|
||||||
|
// {
|
||||||
|
// Name: "/code/statistic/out.xlsx",
|
||||||
|
// ContentType: "",
|
||||||
|
// WithFile: true,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// Name: "/code/statistic/origin.xlsx",
|
||||||
|
// ContentType: "",
|
||||||
|
// WithFile: true,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// Name: "/code/statistic/out2.xlsx",
|
||||||
|
// ContentType: "",
|
||||||
|
// WithFile: true,
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ini.Send(ctx, msg)
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSendFakeServer 使用内存 SMTP 服务器做端到端验证,不依赖外部网络。
|
// func TestQQ(t *testing.T) {
|
||||||
func TestSendFakeServer(t *testing.T) {
|
|
||||||
var got bytes.Buffer
|
|
||||||
host, port := startFakeSMTPServer(t, func(data []byte) { got.Write(data) })
|
|
||||||
|
|
||||||
client := smtp.New(smtp.Config{
|
// // 发件人邮箱
|
||||||
Host: host,
|
// from := "995116474@qq.com"
|
||||||
Port: port,
|
// // 授权码,而非密码
|
||||||
User: "sender@example.com",
|
// authCode := "xxxxxxxxxxxxxxxxxxxxxx"
|
||||||
Password: "secret",
|
// // 收件人邮箱,可以是多个收件人
|
||||||
Encryption: smtp.EncryptionNone, // 假服务器不支持 TLS,使用明文验证投递流程
|
// to := []string{"yun@yun.ink"}
|
||||||
})
|
// // 邮件服务器信息
|
||||||
|
// smtpHost := "smtp.qq.com"
|
||||||
|
// smtpPort := "587" // 或使用465,根据你的SMTP服务器要求设置
|
||||||
|
|
||||||
msg := mailx.NewMessage().
|
// mail := mailx.NewMailx(from, authCode, smtpHost, smtpPort)
|
||||||
From("sender@example.com").
|
|
||||||
To("to@example.com", "to2@example.com").
|
|
||||||
Cc("cc@example.com").
|
|
||||||
Subject("测试主题").
|
|
||||||
Body("<h1>Hello</h1>").
|
|
||||||
ReplyTo("reply@example.com").
|
|
||||||
AttachBytes("a.txt", []byte("attachment-data")).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
if err := client.Send(context.Background(), msg); err != nil {
|
// msg := mailx.Message{
|
||||||
t.Fatalf("Send: %v", err)
|
// To: to,
|
||||||
}
|
// Subject: "test mail",
|
||||||
|
// Body: "测试",
|
||||||
out := got.String()
|
// }
|
||||||
for _, want := range []string{
|
// err := mail.Send(msg)
|
||||||
"From: sender@example.com",
|
// fmt.Println(err)
|
||||||
"To: to@example.com, to2@example.com",
|
// }
|
||||||
"Cc: cc@example.com",
|
|
||||||
"Reply-To: reply@example.com",
|
|
||||||
"Subject: =?UTF-8?q?",
|
|
||||||
"multipart/mixed",
|
|
||||||
"text/html; charset=UTF-8",
|
|
||||||
"attachment; filename",
|
|
||||||
"base64",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(out, want) {
|
|
||||||
t.Errorf("delivered MIME missing %q, got:\n%s", want, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendEmptySender(t *testing.T) {
|
|
||||||
client := smtp.New(smtp.Config{Host: "h", Port: 25})
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
if err := client.Send(context.Background(), msg); err == nil {
|
|
||||||
t.Fatal("Send without sender should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
if got := smtp.New(smtp.Config{}).Name(); got != "smtp" {
|
|
||||||
t.Errorf("Name() = %q, want smtp", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startFakeSMTPServer 启动一个内存 SMTP 服务器,捕获 DATA 阶段内容。
|
|
||||||
func startFakeSMTPServer(t *testing.T, capture func([]byte)) (string, int) {
|
|
||||||
t.Helper()
|
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = ln.Close() })
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
conn, err := ln.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go serveSMTP(conn, capture)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
|
||||||
port, _ := strconv.Atoi(portStr)
|
|
||||||
return host, port
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveSMTP 处理单个 SMTP 连接,支持 EHLO/AUTH PLAIN/MAIL/RCPT/DATA/QUIT。
|
|
||||||
func serveSMTP(conn net.Conn, capture func([]byte)) {
|
|
||||||
defer conn.Close()
|
|
||||||
r := bufio.NewReader(conn)
|
|
||||||
w := bufio.NewWriter(conn)
|
|
||||||
respond := func(line string) {
|
|
||||||
_, _ = w.WriteString(line + "\r\n")
|
|
||||||
_ = w.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
respond("220 fake ESMTP ready")
|
|
||||||
|
|
||||||
inData := false
|
|
||||||
var data bytes.Buffer
|
|
||||||
for {
|
|
||||||
line, err := r.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
line = strings.TrimRight(line, "\r\n")
|
|
||||||
|
|
||||||
if inData {
|
|
||||||
if line == "." {
|
|
||||||
inData = false
|
|
||||||
capture(data.Bytes())
|
|
||||||
data.Reset()
|
|
||||||
respond("250 2.0.0 Ok: queued")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data.WriteString(line + "\r\n")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(line, "EHLO"):
|
|
||||||
respond("250-localhost")
|
|
||||||
respond("250-AUTH PLAIN LOGIN")
|
|
||||||
respond("250 8BITMIME")
|
|
||||||
case strings.HasPrefix(line, "AUTH"):
|
|
||||||
respond("235 2.7.0 Authentication successful")
|
|
||||||
case strings.HasPrefix(line, "MAIL FROM"):
|
|
||||||
respond("250 2.1.0 Ok")
|
|
||||||
case strings.HasPrefix(line, "RCPT TO"):
|
|
||||||
respond("250 2.1.5 Ok")
|
|
||||||
case strings.HasPrefix(line, "DATA"):
|
|
||||||
respond("354 End data with <CR><LF>.<CR><LF>")
|
|
||||||
inData = true
|
|
||||||
case strings.HasPrefix(line, "STARTTLS"):
|
|
||||||
// 假服务器不支持 TLS 升级,明确拒绝,避免客户端挂起
|
|
||||||
respond("454 4.7.0 TLS not available")
|
|
||||||
case strings.HasPrefix(line, "QUIT"):
|
|
||||||
respond("221 2.0.0 Bye")
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
respond("502 5.5.2 Command not recognized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendTimeout 验证当 SMTP 服务器不响应时,deadline 能及时中断发送而非无限阻塞。
|
|
||||||
func TestSendTimeout(t *testing.T) {
|
|
||||||
host, port := startSilentSMTPServer(t)
|
|
||||||
|
|
||||||
client := smtp.New(smtp.Config{
|
|
||||||
Host: host,
|
|
||||||
Port: port,
|
|
||||||
Encryption: smtp.EncryptionNone,
|
|
||||||
Timeout: 300 * time.Millisecond, // 短超时
|
|
||||||
})
|
|
||||||
|
|
||||||
msg := mailx.NewMessage().To("a@b.com").Subject("s").Build()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
err := client.Send(context.Background(), msg)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected timeout error, got nil")
|
|
||||||
}
|
|
||||||
if elapsed > 3*time.Second {
|
|
||||||
t.Fatalf("Send took %v, expected to time out quickly", elapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startSilentSMTPServer 启动一个接受连接但从不响应的服务器,用于测试超时。
|
|
||||||
func startSilentSMTPServer(t *testing.T) (string, int) {
|
|
||||||
t.Helper()
|
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = ln.Close() })
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
conn, err := ln.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 静默:读取请求但从不回复,让客户端因 deadline 超时
|
|
||||||
go func(c net.Conn) {
|
|
||||||
defer c.Close()
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
for {
|
|
||||||
if _, err := c.Read(buf); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(conn)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
|
||||||
port, _ := strconv.Atoi(portStr)
|
|
||||||
return host, port
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
// EmailSendStatus 邮件发送状态
|
|
||||||
type EmailSendStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
EmailSendStatusUnknown EmailSendStatus = 0
|
|
||||||
EmailSendStatusSuccess EmailSendStatus = 1
|
|
||||||
EmailSendStatusInvalidAddress EmailSendStatus = 2
|
|
||||||
EmailSendStatusSpam EmailSendStatus = 3
|
|
||||||
EmailSendStatusFailed EmailSendStatus = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
// EmailSendRecord 邮件发送记录(用于通道回传发送状态)
|
|
||||||
type EmailSendRecord struct {
|
|
||||||
AccountName string // 发件人
|
|
||||||
UpdateTime int64 // 毫秒时间戳
|
|
||||||
Status EmailSendStatus // 状态
|
|
||||||
ToUser string // 收件人
|
|
||||||
Subject string // 邮件主题
|
|
||||||
ErrorMessage string // 错误信息
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package mailx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// htmlTagPattern 匹配 HTML 开标签:< 标签名 [属性...]>
|
|
||||||
// 不匹配注释、DTD、结束标签(</)、处理指令(<?),避免普通文本 "a < b > c" 被误判。
|
|
||||||
var htmlTagPattern = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^>]*)?>`)
|
|
||||||
|
|
||||||
// IsHTML 判断文本是否为 HTML:要求包含至少一个完整的 HTML 开标签。
|
|
||||||
func IsHTML(s string) bool {
|
|
||||||
return htmlTagPattern.MatchString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EscapeHTML 将纯文本转义为 HTML 安全的文本。
|
|
||||||
func EscapeHTML(s string) string {
|
|
||||||
s = strings.ReplaceAll(s, "&", "&")
|
|
||||||
s = strings.ReplaceAll(s, "<", "<")
|
|
||||||
s = strings.ReplaceAll(s, ">", ">")
|
|
||||||
s = strings.ReplaceAll(s, `"`, """)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileBaseName 取路径中的文件名,同时兼容 / 与 \ 分隔符。
|
|
||||||
// 用于从文件路径推导附件/内嵌图片的默认名称。
|
|
||||||
func fileBaseName(p string) string {
|
|
||||||
if i := strings.LastIndexAny(p, `/\`); i >= 0 {
|
|
||||||
p = p[i+1:]
|
|
||||||
}
|
|
||||||
if p == "" || p == "." {
|
|
||||||
return "attachment"
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user