更新
This commit is contained in:
@@ -1 +1,3 @@
|
|||||||
*.log
|
*.log
|
||||||
|
.env
|
||||||
|
examples/with_env/.env
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+171
-213
@@ -1,260 +1,218 @@
|
|||||||
|
// Package aliyun 提供阿里云邮件推送(DirectMail)通道。
|
||||||
package aliyun
|
package aliyun
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx/interfaces"
|
mailx "code.yun.ink/pkg/mailx"
|
||||||
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 defaultTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// 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 {
|
||||||
interfaces.DefaultEmail
|
cfg Config
|
||||||
client *dm20151123.Client
|
|
||||||
// params *interfaces.EmialConfigDataAliyun
|
initOnce sync.Once
|
||||||
// logger loggerx.LoggerInterface
|
client dmMailer
|
||||||
|
initErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAliyun() *Aliyun {
|
// New 创建阿里云通道
|
||||||
aliyun := &Aliyun{}
|
func New(cfg Config) *Aliyun {
|
||||||
aliyun.Options = interfaces.DefaultOptions()
|
if cfg.Endpoint == "" {
|
||||||
aliyun.EmailType = interfaces.EmailTypeAliyun
|
cfg.Endpoint = "dm.aliyuncs.com"
|
||||||
return aliyun
|
}
|
||||||
|
return &Aliyun{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Aliyun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
|
// Name 返回通道名称
|
||||||
|
func (a *Aliyun) Name() string { return "aliyun" }
|
||||||
|
|
||||||
for _, o := range opt {
|
// Send 发送一封邮件
|
||||||
o(&l.Options)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Options.Logger.Infof(ctx, "Aliyun:%+v", l.Options.Aliyun)
|
client, err := a.newClient()
|
||||||
|
|
||||||
if l.Options.Aliyun == nil {
|
|
||||||
return nil, errors.New("not aliyun")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工程代码泄露可能会导致 AccessKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考。
|
|
||||||
// 建议使用更安全的 STS 方式,更多鉴权访问方式请参见:https://help.aliyun.com/document_detail/378661.html。
|
|
||||||
config := &openapi.Config{
|
|
||||||
// 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID。
|
|
||||||
AccessKeyId: tea.String(l.Options.Aliyun.AccessId),
|
|
||||||
// 必填,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_SECRET。
|
|
||||||
AccessKeySecret: tea.String(l.Options.Aliyun.AccessKey),
|
|
||||||
}
|
|
||||||
if l.Options.Aliyun.Endpoint == "" {
|
|
||||||
l.Options.Aliyun.Endpoint = "dm.aliyuncs.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoint 请参考 https://api.aliyun.com/product/Dm
|
|
||||||
config.Endpoint = tea.String(l.Options.Aliyun.Endpoint)
|
|
||||||
|
|
||||||
result, err := dm20151123.NewClient(config)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("mailx/aliyun: create client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Aliyun{
|
// 阿里云 DirectMail 的 HtmlBody 必填;纯文本也需以 HTML 形式传递
|
||||||
client: result,
|
htmlBody := msg.Body
|
||||||
}, nil
|
if htmlBody == "" {
|
||||||
}
|
htmlBody = mailx.EscapeHTML(msg.TextBody)
|
||||||
|
|
||||||
func (l *Aliyun) Send(ctx context.Context, params interfaces.Message) error {
|
|
||||||
if l.client == nil {
|
|
||||||
return errors.New("client no init")
|
|
||||||
}
|
}
|
||||||
if len(params.To) > 100 {
|
req := &dm20151123.SingleSendMailRequest{
|
||||||
return errors.New("最多 100 个地址")
|
AccountName: tea.String(a.cfg.AccountName),
|
||||||
}
|
ToAddress: tea.String(strings.Join(msg.To, ",")),
|
||||||
if l.Options.Aliyun.AccountName == "" {
|
Subject: tea.String(msg.Subject),
|
||||||
return errors.New("AccountName 必填")
|
HtmlBody: tea.String(htmlBody),
|
||||||
|
AddressType: tea.Int32(0), // 0:随机账号;1:发信地址
|
||||||
}
|
}
|
||||||
|
|
||||||
toAddress := strings.Join(params.To, ",")
|
replyTo := msg.ReplyTo
|
||||||
|
if replyTo == "" {
|
||||||
singleSendMailRequest := &dm20151123.SingleSendMailRequest{}
|
replyTo = a.cfg.ReplyAddress
|
||||||
|
}
|
||||||
singleSendMailRequest.AccountName = tea.String(l.Options.Aliyun.AccountName)
|
if replyTo != "" {
|
||||||
singleSendMailRequest.ToAddress = tea.String(toAddress) // 目标地址,多个 email 地址可以用逗号分隔,最多 100 个地址(支持邮件组)。
|
req.ReplyToAddress = tea.Bool(false)
|
||||||
singleSendMailRequest.Subject = tea.String(params.Subject)
|
req.ReplyAddress = tea.String(replyTo)
|
||||||
singleSendMailRequest.HtmlBody = tea.String(params.Body)
|
|
||||||
singleSendMailRequest.AddressType = tea.Int32(0) // 地址类型。取值:0:为随机账号1:为发信地址
|
|
||||||
|
|
||||||
if params.ReplyTo != "" {
|
|
||||||
singleSendMailRequest.ReplyToAddress = tea.Bool(false)
|
|
||||||
singleSendMailRequest.ReplyAddress = tea.String(params.ReplyTo)
|
|
||||||
} else {
|
} else {
|
||||||
singleSendMailRequest.ReplyToAddress = tea.Bool(true)
|
req.ReplyToAddress = tea.Bool(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := &util.RuntimeOptions{}
|
// 将 ctx 的 deadline 折算为超时,同时应用配置超时
|
||||||
tryErr := func() (_e error) {
|
runtime := a.runtimeOptions(ctx)
|
||||||
defer func() {
|
if _, err := client.SingleSendMailWithOptions(req, runtime); err != nil {
|
||||||
if r := tea.Recover(recover()); r != nil {
|
logger.Errorf(ctx, "mailx/aliyun: send failed: %v", err)
|
||||||
_e = r
|
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
|
||||||
}
|
|
||||||
}()
|
|
||||||
// 复制代码运行请自行打印 API 的返回值
|
|
||||||
resp, err := l.client.SingleSendMailWithOptions(singleSendMailRequest, runtime)
|
|
||||||
by, _ := json.Marshal(resp)
|
|
||||||
fmt.Printf("resp:%+v err:%+v", string(by), err)
|
|
||||||
l.Options.Logger.Infof(ctx, "resp:%+v err:%+v", resp, err)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
if tryErr != nil {
|
|
||||||
l.Options.Logger.Errorf(ctx, "err:%+v", tryErr)
|
|
||||||
return 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 {
|
|
||||||
// return _err
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
logger.Infof(ctx, "mailx/aliyun: sent to %v subject=%q", msg.To, msg.Subject)
|
||||||
return nil // 实现具体的 Aliyun 发送方法
|
return nil
|
||||||
// 如:return aliyunSDK.SendMail(params)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步状态
|
// newClient 惰性创建并复用阿里云 SDK 客户端(线程安全)。
|
||||||
func (l *Aliyun) SyncStatus(ctx context.Context) (resp []interfaces.EmailSendRecord, err error) {
|
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
|
||||||
|
func (a *Aliyun) newClient() (dmMailer, error) {
|
||||||
start := ""
|
if a.client != nil {
|
||||||
|
return a.client, a.initErr
|
||||||
// 一次同步一天的数据
|
}
|
||||||
for {
|
a.initOnce.Do(func() {
|
||||||
list, next, err := l.getSendStatus(ctx, start)
|
config := &openapi.Config{
|
||||||
l.Options.Logger.Infof(ctx, "list:%+v next:%+v err:%+v", list, next, err)
|
AccessKeyId: tea.String(a.cfg.AccessKeyID),
|
||||||
|
AccessKeySecret: tea.String(a.cfg.AccessKeySecret),
|
||||||
|
Endpoint: tea.String(a.cfg.Endpoint),
|
||||||
|
}
|
||||||
|
c, err := dm20151123.NewClient(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
a.initErr = fmt.Errorf("mailx/aliyun: create client: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.client = c
|
||||||
|
})
|
||||||
|
return a.client, a.initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeOptions 根据配置与 ctx 计算超时,返回 RuntimeOptions
|
||||||
|
func (a *Aliyun) runtimeOptions(ctx context.Context) *util.RuntimeOptions {
|
||||||
|
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 := ""
|
||||||
|
// 分页拉取最近一天的数据
|
||||||
|
for {
|
||||||
|
req := &dm20151123.SenderStatisticsDetailByParamRequest{
|
||||||
|
StartTime: tea.String(time.Now().AddDate(0, 0, -1).Format("2006-01-02 15:04")),
|
||||||
|
EndTime: tea.String(time.Now().Format("2006-01-02 15:04")),
|
||||||
|
Length: tea.Int32(100),
|
||||||
|
}
|
||||||
|
if start != "" {
|
||||||
|
req.NextStart = tea.String(start)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, val := range list {
|
resp, err := client.SenderStatisticsDetailByParamWithOptions(req, runtime)
|
||||||
|
if err != nil {
|
||||||
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(val.LastUpdateTime), time.Local)
|
logger.Errorf(ctx, "mailx/aliyun: sync status failed: %v", err)
|
||||||
|
return nil, fmt.Errorf("mailx/aliyun: sync status: %w", err)
|
||||||
// 0:成功 2:无效地址 3:垃圾邮件 4:失败
|
|
||||||
record := interfaces.EmailSendRecord{
|
|
||||||
AccountName: tea.StringValue(val.AccountName),
|
|
||||||
UpdateTime: t.UnixMilli(),
|
|
||||||
ToUser: tea.StringValue(val.ToAddress),
|
|
||||||
Subject: tea.StringValue(val.Subject),
|
|
||||||
ErrorMessage: tea.StringValue(val.Message),
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if resp == nil || resp.Body == nil || resp.Body.Data == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
start = *next
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
for _, d := range resp.Body.Data.MailDetail {
|
||||||
|
t, _ := time.ParseInLocation("2006-01-02T15:04Z", tea.StringValue(d.LastUpdateTime), time.Local)
|
||||||
}
|
records = append(records, mailx.EmailSendRecord{
|
||||||
|
AccountName: tea.StringValue(d.AccountName),
|
||||||
func (l *Aliyun) getSendStatus(ctx context.Context, start string) (list []*dm20151123.SenderStatisticsDetailByParamResponseBodyDataMailDetail, nextStart *string, err error) {
|
UpdateTime: t.UnixMilli(),
|
||||||
now := time.Now().Local()
|
ToUser: tea.StringValue(d.ToAddress),
|
||||||
senderStatisticsDetailByParamRequest := &dm20151123.SenderStatisticsDetailByParamRequest{
|
Subject: tea.StringValue(d.Subject),
|
||||||
StartTime: tea.String(now.AddDate(0, 0, -1).Format("2006-01-02 15:04")),
|
ErrorMessage: tea.StringValue(d.Message),
|
||||||
EndTime: tea.String(now.Format("2006-01-02 15:04")),
|
Status: mapSendStatus(tea.Int32Value(d.Status)),
|
||||||
Length: tea.Int32(100),
|
})
|
||||||
}
|
|
||||||
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 {
|
if resp.Body.NextStart == nil || *resp.Body.NextStart == "" {
|
||||||
return errors.New("resp.Body.Data is nil")
|
break
|
||||||
}
|
}
|
||||||
|
start = *resp.Body.NextStart
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
list = resp.Body.Data.MailDetail
|
// mapSendStatus 阿里云状态码映射为统一状态
|
||||||
nextStart = resp.Body.NextStart
|
func mapSendStatus(code int32) mailx.EmailSendStatus {
|
||||||
|
switch code {
|
||||||
return nil
|
case 0:
|
||||||
}()
|
return mailx.EmailSendStatusSuccess
|
||||||
|
case 2:
|
||||||
if tryErr != nil {
|
return mailx.EmailSendStatusInvalidAddress
|
||||||
l.Options.Logger.Errorf(ctx, "err:%+v", tryErr)
|
case 3:
|
||||||
return nil, nil, tryErr
|
return mailx.EmailSendStatusSpam
|
||||||
|
case 4:
|
||||||
// var error = &tea.SDKError{}
|
return mailx.EmailSendStatusFailed
|
||||||
// if _t, ok := tryErr.(*tea.SDKError); ok {
|
default:
|
||||||
// error = _t
|
return mailx.EmailSendStatusUnknown
|
||||||
// } 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-51
@@ -5,73 +5,52 @@ 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) {
|
||||||
aliyun := aliyun.NewAliyun()
|
if testing.Short() {
|
||||||
ctx := context.Background()
|
t.Skip("skip real send in short mode")
|
||||||
|
|
||||||
ali, err := aliyun.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 {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
client := aliyun.New(aliyun.Config{
|
||||||
by, err := os.ReadFile("./assets/zh_Hant.html")
|
AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
|
||||||
if err != nil {
|
AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
|
||||||
t.Fatal(err)
|
Endpoint: "dm.aliyuncs.com",
|
||||||
}
|
AccountName: "test@email.aisz.org",
|
||||||
|
ReplyAddress: "287852692@qq.com",
|
||||||
t.Log(string(by))
|
|
||||||
|
|
||||||
err = ali.Send(ctx, interfaces.Message{
|
|
||||||
To: []string{"995116474@qq.com"},
|
|
||||||
Subject: "测试主题",
|
|
||||||
Body: string(by),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
body, err := os.ReadFile("./assets/zh_Hant.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log(string(body))
|
||||||
|
|
||||||
|
err = client.Send(ctx, mailx.NewMessage().
|
||||||
|
To("995116474@qq.com").
|
||||||
|
Subject("测试主题").
|
||||||
|
Body(string(body)).
|
||||||
|
Build())
|
||||||
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) {
|
||||||
// aliyun := &aliyun.Aliyun{}
|
// client := aliyun.New(aliyun.Config{
|
||||||
// ctx := context.Background()
|
// AccessKeyID: "LTAI5tEQ8L8fmDir8udD3CFr",
|
||||||
|
// AccessKeySecret: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
|
||||||
// global.Logger = loggerx.NewLogger(ctx)
|
// Endpoint: "dm.aliyuncs.com",
|
||||||
|
// AccountName: "test@email.aisz.org",
|
||||||
// ali, err := aliyun.InitEmail(ctx, interfaces.EmailConfigData{
|
// ReplyAddress: "287852692@qq.com",
|
||||||
// Aliyun: &interfaces.EmialConfigDataAliyun{
|
|
||||||
// AccessId: "LTAI5tEQ8L8fmDir8udD3CFr",
|
|
||||||
// AccessKey: "llg9M1U56s2SW5PuerlKPvTB1xYhn0",
|
|
||||||
// Endpoint: "dm.aliyuncs.com",
|
|
||||||
// AccountName: "test@email.aisz.org",
|
|
||||||
// ReplyAddress: "287852692@qq.com",
|
|
||||||
// },
|
|
||||||
// })
|
// })
|
||||||
|
// ctx := context.Background()
|
||||||
|
// list, err := client.SyncStatus(ctx)
|
||||||
// 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)
|
||||||
|
|
||||||
// }
|
// }
|
||||||
|
|||||||
+108
-61
@@ -1,90 +1,137 @@
|
|||||||
|
// Package aws 提供 Amazon SES 邮件发送通道。
|
||||||
package aws
|
package aws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx/interfaces"
|
mailx "code.yun.ink/pkg/mailx"
|
||||||
"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 defaultTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// 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 {
|
||||||
interfaces.DefaultEmail
|
cfg Config
|
||||||
// params *interfaces.EmailConfigDataAws
|
|
||||||
|
initOnce sync.Once
|
||||||
|
svc sesiface.SESAPI
|
||||||
|
initErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAws() *Aws {
|
// New 创建 AWS 通道
|
||||||
aws := &Aws{}
|
func New(cfg Config) *Aws {
|
||||||
aws.Options = interfaces.DefaultOptions()
|
if cfg.Region == "" {
|
||||||
aws.EmailType = interfaces.EmailTypeAws
|
cfg.Region = "ap-northeast-1"
|
||||||
return aws
|
}
|
||||||
|
return &Aws{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Aws) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
|
// Name 返回通道名称
|
||||||
|
func (a *Aws) Name() string { return "aws" }
|
||||||
|
|
||||||
for _, o := range opt {
|
// Send 发送一封邮件
|
||||||
o(&l.Options)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Options.Logger.Infof(ctx, "Aws:%+v", l.Options.Aws)
|
svc, err := a.client()
|
||||||
if l.Options.Aws == nil {
|
if err != nil {
|
||||||
return nil, errors.New("not aws")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if l.Options.Aws.Region == "" {
|
input := &ses.SendEmailInput{
|
||||||
l.Options.Aws.Region = "ap-northeast-1"
|
Source: aws.String(sender),
|
||||||
}
|
|
||||||
|
|
||||||
return l, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Aws) Send(ctx context.Context, params interfaces.Message) error {
|
|
||||||
if l.Options.Aws == nil {
|
|
||||||
return errors.New("not init")
|
|
||||||
}
|
|
||||||
// 配置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{
|
Destination: &ses.Destination{
|
||||||
ToAddresses: toAddress,
|
ToAddresses: aws.StringSlice(msg.To),
|
||||||
|
CcAddresses: aws.StringSlice(msg.Cc),
|
||||||
|
BccAddresses: aws.StringSlice(msg.Bcc),
|
||||||
},
|
},
|
||||||
Message: &ses.Message{
|
Message: &ses.Message{
|
||||||
Body: &ses.Body{
|
Subject: &ses.Content{Data: aws.String(msg.Subject), Charset: aws.String("UTF-8")},
|
||||||
Html: &ses.Content{
|
Body: awsBody(msg),
|
||||||
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),
|
}
|
||||||
})
|
if msg.ReplyTo != "" {
|
||||||
|
input.ReplyToAddresses = aws.StringSlice([]string{msg.ReplyTo})
|
||||||
|
}
|
||||||
|
|
||||||
// svc.SendRawEmail()
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
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)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
// client 惰性初始化并复用 SES 客户端(线程安全)。
|
||||||
|
// 若已通过测试或其他方式注入 svc,则直接返回注入的客户端。
|
||||||
|
func (a *Aws) client() (sesiface.SESAPI, error) {
|
||||||
|
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),
|
||||||
|
Credentials: credentials.NewStaticCredentials(a.cfg.AccessKeyID, a.cfg.AccessKeySecret, ""),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.initErr = fmt.Errorf("%w: create aws session: %v", mailx.ErrInvalidConfig, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.svc = ses.New(sess)
|
||||||
|
})
|
||||||
|
return a.svc, a.initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTimeout 叠加配置超时到 ctx(已有更早 deadline 时保持不变)
|
||||||
|
func (a *Aws) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
if a.cfg.Timeout <= 0 {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= a.cfg.Timeout {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, a.cfg.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// awsBody 构造 SES Body:优先使用 Html,其次 Text;两者都有时同时提供
|
||||||
|
func awsBody(msg *mailx.Message) *ses.Body {
|
||||||
|
body := &ses.Body{}
|
||||||
|
if msg.Body != "" {
|
||||||
|
body.Html = &ses.Content{Data: aws.String(msg.Body), Charset: aws.String("UTF-8")}
|
||||||
|
}
|
||||||
|
if msg.TextBody != "" {
|
||||||
|
body.Text = &ses.Content{Data: aws.String(msg.TextBody), Charset: aws.String("UTF-8")}
|
||||||
|
}
|
||||||
|
return body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
+16
-29
@@ -4,43 +4,30 @@ 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) {
|
||||||
// email:
|
if testing.Short() {
|
||||||
// #区域
|
t.Skip("skip real send in short mode")
|
||||||
// AwsRegion: "ap-northeast-1"
|
}
|
||||||
// #秘钥ID
|
client := aws.New(aws.Config{
|
||||||
// AwsAccessKeyId: "AKIAU6GD3MNRHKR4RZG5"
|
AccessKeyID: "AKIAU6GD3MNRHKR4RZG5",
|
||||||
// #秘钥
|
AccessKeySecret: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l",
|
||||||
// AwsSecretAccessKey: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l"
|
Region: "ap-northeast-1",
|
||||||
// #发件人
|
Sender: "chenlihan@dreaminglife.cn",
|
||||||
// Source: "chenlihan@dreaminglife.cn"
|
})
|
||||||
|
|
||||||
a := aws.NewAws()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
err := client.Send(ctx, mailx.NewMessage().
|
||||||
ini, err := a.SetOption(ctx, interfaces.SetAws(&interfaces.EmailConfigDataAws{
|
From("chenlihan@dreaminglife.cn").
|
||||||
AccessId: "AKIAU6GD3MNRHKR4RZG5",
|
To("huangxinyun@dreaminglife.cn").
|
||||||
AccessSecret: "GSdGuFbZlcpVHMODlqeIKr07R/BdTBGeurq0s+4l",
|
Subject("主题").
|
||||||
Region: "ap-northeast-1",
|
Body("Hello").
|
||||||
Sender: "chenlihan@dreaminglife.cn",
|
Build())
|
||||||
}))
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-24
@@ -1,42 +1,99 @@
|
|||||||
|
// 使用示例:展示 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/interfaces"
|
"code.yun.ink/pkg/mailx/aliyun"
|
||||||
|
"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
|
||||||
em, err := ch.GetEmail(interfaces.EmailTypeSmtp)
|
err := smtp.New(smtp.Config{
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
// 使用em进行后续操作
|
|
||||||
em,err = em.SetOption(ctx, interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{
|
|
||||||
Username: "support@email.blueoceanpay.com",
|
|
||||||
Password: "SupporT2017",
|
|
||||||
ReplyTo: "",
|
|
||||||
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
||||||
Port: "80",
|
Port: 80,
|
||||||
|
User: "support@email.blueoceanpay.com",
|
||||||
|
Password: "SupporT2017",
|
||||||
|
From: "yun@blueoceanpay.com",
|
||||||
|
}).Send(ctx, mailx.NewMessage().
|
||||||
|
From(`"蓝海支付" <yun@blueoceanpay.com>`). // 发件人可带显示名
|
||||||
|
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",
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
_ = mgr.Register(aliyun.New(aliyun.Config{
|
||||||
panic(err)
|
AccessKeyID: "your-access-key-id", AccessKeySecret: "your-access-key-secret",
|
||||||
|
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{
|
// ===== 方式三:发送时临时指定配置(无需提前注册) =====
|
||||||
Form: "yun@blueoceanpay.com",
|
if err := mgr.SendBy(ctx, mailgun.New(mailgun.Config{
|
||||||
To: []string{"995116474@qq.com"},
|
APIKey: "another-key", Domain: "mg2.example.com", Sender: "noreply@example.com",
|
||||||
Subject: "Test Email",
|
}), msg); err != nil {
|
||||||
Body: "Hello, this is a test email.",
|
log.Println("mailgun send:", err)
|
||||||
})
|
|
||||||
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,15 @@
|
|||||||
|
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")
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// 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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 复制本文件为 .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
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// 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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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,36 +24,16 @@ 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,13 +58,7 @@ 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=
|
||||||
@@ -75,24 +69,8 @@ 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=
|
||||||
@@ -104,13 +82,10 @@ 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=
|
||||||
@@ -118,24 +93,18 @@ 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=
|
||||||
@@ -143,10 +112,7 @@ 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=
|
||||||
@@ -157,33 +123,17 @@ 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=
|
||||||
@@ -196,8 +146,6 @@ 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=
|
||||||
@@ -244,11 +192,9 @@ 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=
|
||||||
@@ -276,7 +222,6 @@ 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=
|
||||||
@@ -290,7 +235,6 @@ 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=
|
||||||
@@ -306,11 +250,7 @@ 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=
|
||||||
@@ -323,4 +263,3 @@ 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,76 +2,50 @@ package mailx
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// html路径替换
|
// ParseHTMLResource 解析 HTML 中引用的静态资源地址(css/js/img/video/audio)。
|
||||||
func ParseHtmlResource(html string) ([]string, error) {
|
// 返回资源 URL 列表;忽略空属性与 dns-prefetch 预请求。
|
||||||
resp := []string{}
|
func ParseHTMLResource(html string) ([]string, error) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(html))
|
||||||
b := bytes.NewBufferString(html)
|
|
||||||
doc, err := goquery.NewDocumentFromReader(b)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("mailx: parse html: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找到所有的 css 标签,并且打印它们的 href 属性
|
var res []string
|
||||||
doc.Find("link").Each(func(i int, s *goquery.Selection) {
|
collect := func(sel, attr string) {
|
||||||
// 忽略dns预请求
|
doc.Find(sel).Each(func(_ int, s *goquery.Selection) {
|
||||||
r, ok := s.Attr("rel")
|
if v, ok := s.Attr(attr); ok && v != "" {
|
||||||
if ok && r == "dns-prefetch" {
|
res = append(res, v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 != "" {
|
||||||
href, ok := s.Attr("href")
|
res = append(res, 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")
|
||||||
|
|
||||||
// 找到所有的 script 标签,并且打印它们的 src 属性
|
return res, nil
|
||||||
doc.Find("script").Each(func(i int, s *goquery.Selection) {
|
}
|
||||||
src, ok := s.Attr("src")
|
|
||||||
if ok && src != "" {
|
// ParseHtmlResource 是 ParseHTMLResource 的旧名称,保留以兼容历史调用,建议使用新名。
|
||||||
resp = append(resp, src)
|
//
|
||||||
}
|
// Deprecated: 请使用 ParseHTMLResource。
|
||||||
})
|
func ParseHtmlResource(html string) ([]string, error) {
|
||||||
|
return ParseHTMLResource(html)
|
||||||
// 找到所有的 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,71 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -2,7 +2,8 @@ package mailx_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"wallet-pay-api/pkg/mailx"
|
|
||||||
|
mailx "code.yun.ink/pkg/mailx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseHtmlResource(t *testing.T) {
|
func TestParseHtmlResource(t *testing.T) {
|
||||||
@@ -49,7 +50,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
package interfaces
|
|
||||||
|
|
||||||
|
|
||||||
type EmailType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
EmailTypeAliyun EmailType = "aliyun"
|
|
||||||
EmailTypeAws EmailType = "aws"
|
|
||||||
EmailTypeMailgun EmailType = "mailgun"
|
|
||||||
EmailTypeSmtp EmailType = "smtp"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EmialConfigDataMailgun struct {
|
|
||||||
ApiKey string `json:"api_key"` // mailgun api key
|
|
||||||
Domain string `json:"domain"` // mailgun domain
|
|
||||||
Sender string `json:"sender"` // 发件人
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmailConfigDataSmtp struct {
|
|
||||||
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 Message struct {
|
|
||||||
Form string
|
|
||||||
To []string
|
|
||||||
Cc []string
|
|
||||||
Bcc []string
|
|
||||||
Subject string
|
|
||||||
Body string
|
|
||||||
ReplyTo string
|
|
||||||
Attachment []MessageAttachment // 附件
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageAttachment struct {
|
|
||||||
Content string
|
|
||||||
ContentType string
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package interfaces
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type EmailInterface interface {
|
|
||||||
SetOption(ctx context.Context, opt ...Option) (EmailInterface, 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 emailOption
|
|
||||||
EmailType EmailType
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDefaultEmail() *DefaultEmail {
|
|
||||||
return &DefaultEmail{
|
|
||||||
Options: DefaultOptions(),
|
|
||||||
EmailType: "DefaultEmail",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *DefaultEmail) SetOption(ctx context.Context, opt ...Option) (EmailInterface, error) {
|
|
||||||
// 深复制l并且返回新的
|
|
||||||
newL := *l
|
|
||||||
|
|
||||||
for _, o := range opt {
|
|
||||||
o(&newL.Options)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &newL, 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)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package interfaces
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/yuninks/loggerx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type emailOption struct {
|
|
||||||
Logger loggerx.LoggerInterface
|
|
||||||
|
|
||||||
Smtp *EmailConfigDataSmtp `json:"smtp,omitempty"` // smtp
|
|
||||||
Aws *EmailConfigDataAws `json:"aws,omitempty"` // 亚马逊
|
|
||||||
Aliyun *EmialConfigDataAliyun `json:"aliyun,omitempty"` // 阿里云
|
|
||||||
Mailgun *EmialConfigDataMailgun `json:"mailgun,omitempty"` // mailgun
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultOptions() emailOption {
|
|
||||||
ctx := context.Background()
|
|
||||||
return emailOption{
|
|
||||||
Logger: loggerx.NewLogger(ctx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*emailOption)
|
|
||||||
|
|
||||||
// 设置日志
|
|
||||||
func SetLogger(logger loggerx.LoggerInterface) Option {
|
|
||||||
return func(o *emailOption) {
|
|
||||||
o.Logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetSmtp(smtp *EmailConfigDataSmtp) Option {
|
|
||||||
return func(o *emailOption) {
|
|
||||||
o.Smtp = smtp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetAws(aws *EmailConfigDataAws) Option {
|
|
||||||
return func(o *emailOption) {
|
|
||||||
o.Aws = aws
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetAliyun(aliyun *EmialConfigDataAliyun) Option {
|
|
||||||
return func(o *emailOption) {
|
|
||||||
o.Aliyun = aliyun
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetMailgun(mailgun *EmialConfigDataMailgun) Option {
|
|
||||||
return func(o *emailOption) {
|
|
||||||
o.Mailgun = mailgun
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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{})
|
||||||
|
}
|
||||||
+140
-36
@@ -1,58 +1,162 @@
|
|||||||
|
// Package mailgun 提供 Mailgun 邮件发送通道。
|
||||||
package mailgun
|
package mailgun
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx/interfaces"
|
mailx "code.yun.ink/pkg/mailx"
|
||||||
"github.com/mailgun/mailgun-go/v4"
|
"github.com/mailgun/mailgun-go/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// defaultTimeout 单次 API 调用的默认超时
|
||||||
|
const defaultTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// Config Mailgun 通道配置
|
||||||
|
type Config struct {
|
||||||
|
APIKey string // Mailgun API Key
|
||||||
|
Domain string // Mailgun Domain
|
||||||
|
Sender string // 默认发件人(可选,Message.From 优先)
|
||||||
|
Timeout time.Duration // 单次 API 调用的超时,默认 30s;0 表示交给 ctx 控制
|
||||||
|
}
|
||||||
|
|
||||||
|
// mgClient Mailgun 客户端的最小接口(便于测试注入 mock)
|
||||||
|
type mgClient interface {
|
||||||
|
NewMessage(from, subject, text string, to ...string) *mailgun.Message
|
||||||
|
Send(ctx context.Context, m *mailgun.Message) (string, string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailGun Mailgun 发送通道
|
||||||
type MailGun struct {
|
type MailGun struct {
|
||||||
interfaces.DefaultEmail
|
cfg Config
|
||||||
// params *interfaces.EmialConfigDataMailgun
|
|
||||||
mg *mailgun.MailgunImpl
|
initOnce sync.Once
|
||||||
// logger loggerx.LoggerInterface
|
client mgClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMailGun() *MailGun {
|
// New 创建 Mailgun 通道
|
||||||
mailgun := &MailGun{}
|
func New(cfg Config) *MailGun {
|
||||||
mailgun.Options = interfaces.DefaultOptions()
|
return &MailGun{cfg: cfg}
|
||||||
mailgun.EmailType = interfaces.EmailTypeMailgun
|
|
||||||
return mailgun
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *MailGun) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
|
// Name 返回通道名称
|
||||||
|
func (g *MailGun) Name() string { return "mailgun" }
|
||||||
|
|
||||||
for _, o := range opt {
|
// Send 发送一封邮件
|
||||||
o(&l.Options)
|
func (g *MailGun) Send(ctx context.Context, msg *mailx.Message) error {
|
||||||
|
logger := mailx.LoggerFromContext(ctx)
|
||||||
|
|
||||||
|
sender := msg.From
|
||||||
|
if sender == "" {
|
||||||
|
sender = g.cfg.Sender
|
||||||
|
}
|
||||||
|
if sender == "" {
|
||||||
|
return fmt.Errorf("%w: mailgun sender is required", mailx.ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
if g.cfg.Domain == "" || g.cfg.APIKey == "" {
|
||||||
|
return fmt.Errorf("%w: mailgun domain and api key are required", mailx.ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
if l.Options.Mailgun == nil {
|
mg := g.getClient()
|
||||||
return nil, errors.New("not mailgun")
|
text := msg.TextBody
|
||||||
|
if text == "" && !mailx.IsHTML(msg.Body) {
|
||||||
|
text = msg.Body
|
||||||
|
}
|
||||||
|
m := mg.NewMessage(sender, msg.Subject, text, msg.To...)
|
||||||
|
if msg.Body != "" {
|
||||||
|
m.SetHtml(msg.Body)
|
||||||
|
}
|
||||||
|
if msg.ReplyTo != "" {
|
||||||
|
m.SetReplyTo(msg.ReplyTo)
|
||||||
|
}
|
||||||
|
for _, cc := range msg.Cc {
|
||||||
|
m.AddCC(cc)
|
||||||
|
}
|
||||||
|
for _, bcc := range msg.Bcc {
|
||||||
|
m.AddBCC(bcc)
|
||||||
|
}
|
||||||
|
for _, att := range msg.Attachments {
|
||||||
|
if len(att.Data) > 0 {
|
||||||
|
name := att.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "attachment"
|
||||||
|
}
|
||||||
|
m.AddBufferAttachment(name, att.Data)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if att.Path != "" {
|
||||||
|
if _, err := os.Stat(att.Path); err != nil {
|
||||||
|
return fmt.Errorf("mailx/mailgun: attachment %q: %w", att.Path, err)
|
||||||
|
}
|
||||||
|
m.AddAttachment(att.Path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("mailx/mailgun: attachment has neither path nor data (name=%q)", att.Name)
|
||||||
|
}
|
||||||
|
// 内嵌图片(HTML 中用 <img src="cid:...">,mailgun 以 filename 作为 CID)
|
||||||
|
for _, inl := range msg.Inline {
|
||||||
|
if len(inl.Data) > 0 {
|
||||||
|
m.AddReaderInline(inl.CID, nopCloser{bytes.NewReader(inl.Data)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inl.Path != "" {
|
||||||
|
if _, err := os.Stat(inl.Path); err != nil {
|
||||||
|
return fmt.Errorf("mailx/mailgun: inline %q: %w", inl.Path, err)
|
||||||
|
}
|
||||||
|
// AddInline 以文件路径为参数,其 CID 由文件名推导;
|
||||||
|
// 因此这里将 CID 写入同名的临时文件不可行,改用 ReaderInline 读取路径
|
||||||
|
f, err := os.Open(inl.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mailx/mailgun: open inline %q: %w", inl.Path, err)
|
||||||
|
}
|
||||||
|
m.AddReaderInline(inl.CID, f)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("mailx/mailgun: inline image has neither path nor data (cid=%q)", inl.CID)
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Options.Logger.Infof(ctx, "Mailgun:%+v", l.Options.Mailgun)
|
// 超时兜底:mailgun-go 的 Send 已接受 ctx,这里叠加配置超时
|
||||||
|
ctx, cancel := g.withTimeout(ctx)
|
||||||
mg := mailgun.NewMailgun(l.Options.Mailgun.Domain, l.Options.Mailgun.ApiKey)
|
defer cancel()
|
||||||
|
if _, _, err := mg.Send(ctx, m); err != nil {
|
||||||
l.mg = mg
|
logger.Errorf(ctx, "mailx/mailgun: send to %v failed: %v", msg.To, err)
|
||||||
|
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
|
||||||
return l, nil
|
}
|
||||||
|
logger.Infof(ctx, "mailx/mailgun: sent to %v subject=%q", msg.To, msg.Subject)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *MailGun) Send(ctx context.Context, params interfaces.Message) error {
|
// getClient 惰性创建并复用 Mailgun 客户端(线程安全)。
|
||||||
if l.Options.Mailgun == nil {
|
// 若已通过测试或其他方式注入 client,则直接返回注入的客户端。
|
||||||
return errors.New("not init")
|
func (g *MailGun) getClient() mgClient {
|
||||||
|
if g.client != nil {
|
||||||
|
return g.client
|
||||||
}
|
}
|
||||||
|
g.initOnce.Do(func() {
|
||||||
message := l.mg.NewMessage(l.Options.Mailgun.Sender, params.Subject, params.Body, params.To...)
|
g.client = mailgun.NewMailgun(g.cfg.Domain, g.cfg.APIKey)
|
||||||
|
})
|
||||||
resp, id, err := l.mg.Send(ctx, message)
|
return g.client
|
||||||
if err != nil {
|
|
||||||
l.Options.Logger.Errorf(ctx, "Could not send email: %v, resp message: %s, id: %s", err, resp, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withTimeout 叠加配置超时到 ctx(已有更早 deadline 时保持不变)
|
||||||
|
func (g *MailGun) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
if g.cfg.Timeout <= 0 {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= g.cfg.Timeout {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, g.cfg.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nopCloser 包装 io.Reader 为 io.ReadCloser(Close 为空操作)
|
||||||
|
type nopCloser struct {
|
||||||
|
io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nopCloser) Close() error { return nil }
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package mailgun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mailx "code.yun.ink/pkg/mailx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ mailx.Sender = (*MailGun)(nil)
|
||||||
|
|
||||||
|
func TestName(t *testing.T) {
|
||||||
|
if got := New(Config{}).Name(); got != "mailgun" {
|
||||||
|
t.Errorf("Name() = %q, want mailgun", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithoutSender(t *testing.T) {
|
||||||
|
// 缺少 Sender 时应立即报错,不调用 mailgun SDK
|
||||||
|
g := New(Config{})
|
||||||
|
err := g.Send(context.Background(), mailx.NewMessage().
|
||||||
|
To("a@b.com").
|
||||||
|
Subject("s").
|
||||||
|
Build())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send without sender should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package mailgun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mailx "code.yun.ink/pkg/mailx"
|
||||||
|
"github.com/mailgun/mailgun-go/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockMG 实现 mgClient 接口,记录 NewMessage 的参数与 Send 调用
|
||||||
|
type mockMG struct {
|
||||||
|
sendErr error
|
||||||
|
|
||||||
|
// NewMessage 捕获
|
||||||
|
from, subject, text string
|
||||||
|
to []string
|
||||||
|
|
||||||
|
// Send 捕获
|
||||||
|
msgSent bool
|
||||||
|
sentMsg *mailgun.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMG) NewMessage(from, subject, text string, to ...string) *mailgun.Message {
|
||||||
|
m.from, m.subject, m.text = from, subject, text
|
||||||
|
m.to = to
|
||||||
|
return mailgun.NewMessage(from, subject, text, to...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMG) Send(_ context.Context, msg *mailgun.Message) (string, string, error) {
|
||||||
|
m.msgSent = true
|
||||||
|
m.sentMsg = msg
|
||||||
|
if m.sendErr != nil {
|
||||||
|
return "", "", m.sendErr
|
||||||
|
}
|
||||||
|
return "mock-id", "queued", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendSuccess 验证完整发送流程与消息构造参数
|
||||||
|
func TestSendSuccess(t *testing.T) {
|
||||||
|
mock := &mockMG{}
|
||||||
|
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "noreply@example.com"}, client: mock}
|
||||||
|
|
||||||
|
msg := mailx.NewMessage().
|
||||||
|
To("a@example.com", "b@example.com").
|
||||||
|
Cc("cc@example.com").
|
||||||
|
Bcc("bcc@example.com").
|
||||||
|
Subject("hi").
|
||||||
|
Text("plain").
|
||||||
|
HTML("<b>html</b>").
|
||||||
|
ReplyTo("reply@example.com").
|
||||||
|
AttachBytes("a.txt", []byte("data")).
|
||||||
|
InlineImageBytes("cid1", "a.png", []byte{1}).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
if err := g.Send(context.Background(), msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !mock.msgSent {
|
||||||
|
t.Fatal("message not sent")
|
||||||
|
}
|
||||||
|
if mock.from != "noreply@example.com" {
|
||||||
|
t.Errorf("From = %q", mock.from)
|
||||||
|
}
|
||||||
|
if mock.subject != "hi" {
|
||||||
|
t.Errorf("Subject = %q", mock.subject)
|
||||||
|
}
|
||||||
|
if mock.text != "plain" {
|
||||||
|
t.Errorf("Text = %q", mock.text)
|
||||||
|
}
|
||||||
|
if len(mock.to) != 2 || mock.to[0] != "a@example.com" || mock.to[1] != "b@example.com" {
|
||||||
|
t.Errorf("To = %v", mock.to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendFromFallback 验证 msg.From 为空时回退 cfg.Sender
|
||||||
|
func TestSendFromFallback(t *testing.T) {
|
||||||
|
mock := &mockMG{}
|
||||||
|
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "cfg@example.com"}, client: mock}
|
||||||
|
|
||||||
|
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
||||||
|
if err := g.Send(context.Background(), msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mock.from != "cfg@example.com" {
|
||||||
|
t.Errorf("From = %q, want cfg fallback", mock.from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendTextFallback 验证 Body 为纯文本时作为 text 传递
|
||||||
|
func TestSendTextFallback(t *testing.T) {
|
||||||
|
mock := &mockMG{}
|
||||||
|
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
|
||||||
|
|
||||||
|
msg := mailx.NewMessage().To("a@example.com").Subject("s").Body("plain body no html").Build()
|
||||||
|
if err := g.Send(context.Background(), msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mock.text != "plain body no html" {
|
||||||
|
t.Errorf("Text = %q, want fallback to Body", mock.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendHTMLOnly 验证 Body 为 HTML 时不作为 text 传递
|
||||||
|
func TestSendHTMLOnly(t *testing.T) {
|
||||||
|
mock := &mockMG{}
|
||||||
|
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
|
||||||
|
|
||||||
|
msg := mailx.NewMessage().To("a@example.com").Subject("s").HTML("<b>html</b>").Build()
|
||||||
|
if err := g.Send(context.Background(), msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mock.text != "" {
|
||||||
|
t.Errorf("Text = %q, want empty for html body", mock.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendFail 验证发送失败包装为 ErrSendFailed
|
||||||
|
func TestSendFail(t *testing.T) {
|
||||||
|
mock := &mockMG{sendErr: errors.New("mailgun down")}
|
||||||
|
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: mock}
|
||||||
|
|
||||||
|
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
||||||
|
err := g.Send(context.Background(), msg)
|
||||||
|
if err == nil || !errors.Is(err, mailx.ErrSendFailed) {
|
||||||
|
t.Fatalf("err = %v, want ErrSendFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendNoSender 验证缺少 Sender 报错
|
||||||
|
func TestSendNoSender(t *testing.T) {
|
||||||
|
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com"}, client: &mockMG{}}
|
||||||
|
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
||||||
|
err := g.Send(context.Background(), msg)
|
||||||
|
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("err = %v, want ErrInvalidConfig", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendNoDomain 验证缺少 Domain/APIKey 报错
|
||||||
|
func TestSendNoDomain(t *testing.T) {
|
||||||
|
g := &MailGun{cfg: Config{Sender: "s@example.com"}, client: &mockMG{}}
|
||||||
|
msg := mailx.NewMessage().To("a@example.com").Subject("s").Build()
|
||||||
|
err := g.Send(context.Background(), msg)
|
||||||
|
if err == nil || !errors.Is(err, mailx.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("err = %v, want ErrInvalidConfig", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendAttachmentErrors 验证附件边界情况
|
||||||
|
func TestSendAttachmentErrors(t *testing.T) {
|
||||||
|
g := &MailGun{cfg: Config{APIKey: "key", Domain: "mg.example.com", Sender: "s@example.com"}, client: &mockMG{}}
|
||||||
|
|
||||||
|
// 附件既无 path 也无 data
|
||||||
|
msg := mailx.NewMessage().To("a@example.com").Subject("s").
|
||||||
|
AttachBytes("x.txt", []byte{1}).
|
||||||
|
Build()
|
||||||
|
msg.Attachments[0].Data = nil // 清空 data
|
||||||
|
if err := g.Send(context.Background(), msg); err == nil {
|
||||||
|
t.Fatal("attachment without path/data should error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件路径不存在
|
||||||
|
msg = mailx.NewMessage().To("a@example.com").Subject("s").
|
||||||
|
Attach("no-such-file.txt").
|
||||||
|
Build()
|
||||||
|
if err := g.Send(context.Background(), msg); err == nil {
|
||||||
|
t.Fatal("missing attachment file should error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内嵌图片路径不存在
|
||||||
|
msg = mailx.NewMessage().To("a@example.com").Subject("s").
|
||||||
|
InlineImage("cid1", "no-such-img.png").
|
||||||
|
Build()
|
||||||
|
if err := g.Send(context.Background(), msg); err == nil {
|
||||||
|
t.Fatal("missing inline file should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetClient 验证 getClient 惰性复用
|
||||||
|
func TestGetClient(t *testing.T) {
|
||||||
|
g := New(Config{APIKey: "key", Domain: "mg.example.com"})
|
||||||
|
c1 := g.getClient()
|
||||||
|
c2 := g.getClient()
|
||||||
|
if c1 != c2 {
|
||||||
|
t.Error("client should be reused")
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-16
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx/interfaces"
|
"code.yun.ink/pkg/mailx"
|
||||||
"code.yun.ink/pkg/mailx/mailgun"
|
"code.yun.ink/pkg/mailx/mailgun"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,26 +15,23 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSendEmail(t *testing.T) {
|
func TestSendEmail(t *testing.T) {
|
||||||
gun := mailgun.NewMailGun()
|
if testing.Short() {
|
||||||
ctx := context.Background()
|
t.Skip("skip real send in short mode")
|
||||||
|
}
|
||||||
ini, err := gun.SetOption(ctx, interfaces.SetMailgun(&interfaces.EmialConfigDataMailgun{
|
client := mailgun.New(mailgun.Config{
|
||||||
ApiKey: apikey,
|
APIKey: apikey,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Sender: sender,
|
Sender: sender,
|
||||||
}))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
err = ini.Send(ctx, interfaces.Message{
|
|
||||||
To: []string{"995116474@qq.com"},
|
|
||||||
Subject: "test mail",
|
|
||||||
Body: "Hello",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := client.Send(ctx, mailx.NewMessage().
|
||||||
|
To("995116474@qq.com").
|
||||||
|
Subject("test mail").
|
||||||
|
Body("Hello").
|
||||||
|
Build())
|
||||||
if err != nil {
|
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
@@ -0,0 +1,218 @@
|
|||||||
|
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
@@ -0,0 +1,486 @@
|
|||||||
|
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
@@ -0,0 +1,218 @@
|
|||||||
|
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
@@ -0,0 +1,117 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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)
|
||||||
@@ -1 +0,0 @@
|
|||||||
hhhhhhhhhhh
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
-77
@@ -1,77 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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
@@ -1,202 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,315 @@
|
|||||||
|
# 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)`:阿里云通道回传发送状态记录
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+600
-152
@@ -1,185 +1,633 @@
|
|||||||
|
// Package smtp 提供基于 SMTP 协议的邮件发送通道。
|
||||||
|
//
|
||||||
|
// 支持三种连接模式:
|
||||||
|
// - SSL/TLS 加密(如端口 465,Encryption=EncryptionSSL)
|
||||||
|
// - STARTTLS 加密(默认,如端口 587/25)
|
||||||
|
// - 明文(Encryption=EncryptionNone,不推荐)
|
||||||
package smtp
|
package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx/interfaces"
|
mailx "code.yun.ink/pkg/mailx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 邮件发送的封装
|
// Encryption 连接加密模式
|
||||||
// 1. 支持文本
|
type Encryption string
|
||||||
// 2. 支持文件
|
|
||||||
|
|
||||||
|
const (
|
||||||
|
EncryptionAuto Encryption = "auto" // 根据端口自动选择:465 走 SSL,其余走 STARTTLS
|
||||||
|
EncryptionSSL Encryption = "ssl" // 隐式 TLS(端口 465)
|
||||||
|
EncryptionTLS Encryption = "tls" // STARTTLS 升级加密(端口 587/25)
|
||||||
|
EncryptionNone Encryption = "none" // 明文,不加密
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config SMTP 通道配置
|
||||||
|
type Config struct {
|
||||||
|
Host string // SMTP 服务器地址,如 smtp.qq.com
|
||||||
|
Port int // SMTP 端口,如 465 / 587 / 25
|
||||||
|
User string // 账号
|
||||||
|
Password string // 密码或授权码
|
||||||
|
From string // 默认发件人(可选,Message.From 优先)
|
||||||
|
ReplyTo string // 默认回复地址(可选)
|
||||||
|
Encryption Encryption // 连接加密模式,默认 EncryptionAuto
|
||||||
|
TLSSkipVerify bool // 是否跳过 TLS 证书校验(仅测试环境使用,勿在生产开启)
|
||||||
|
Timeout time.Duration // 单次发送的超时(含建连与投递),默认 30s;ctx 已带 deadline 时取较小者
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTimeout 单次 SMTP 发送的默认超时
|
||||||
|
const defaultTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// Smtp SMTP 发送通道
|
||||||
type Smtp struct {
|
type Smtp struct {
|
||||||
interfaces.DefaultEmail
|
cfg Config
|
||||||
// params *interfaces.EmailConfigDataSmtp
|
|
||||||
auth smtp.Auth
|
|
||||||
// logger loggerx.LoggerInterface
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSmtp() *Smtp {
|
// New 创建 SMTP 通道
|
||||||
smtp := &Smtp{}
|
func New(cfg Config) *Smtp {
|
||||||
smtp.Options = interfaces.DefaultOptions()
|
if cfg.Encryption == "" {
|
||||||
smtp.EmailType = interfaces.EmailTypeSmtp
|
cfg.Encryption = EncryptionAuto
|
||||||
return smtp
|
}
|
||||||
|
return &Smtp{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Smtp) SetOption(ctx context.Context, opt ...interfaces.Option) (interfaces.EmailInterface, error) {
|
// Name 返回通道名称
|
||||||
|
func (s *Smtp) Name() string { return "smtp" }
|
||||||
|
|
||||||
for _, o := range opt {
|
// Send 发送一封邮件
|
||||||
o(&l.Options)
|
func (s *Smtp) Send(ctx context.Context, msg *mailx.Message) error {
|
||||||
|
logger := mailx.LoggerFromContext(ctx)
|
||||||
|
|
||||||
|
if s.cfg.Host == "" || s.cfg.Port == 0 {
|
||||||
|
return mailx.ErrInvalidConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Options.Logger.Infof(ctx, "params:%+v", l.Options.Smtp)
|
from := s.firstNonEmpty(msg.From, s.cfg.From, s.cfg.User)
|
||||||
|
if from == "" {
|
||||||
if l.Options.Smtp == nil {
|
return fmt.Errorf("%w: smtp sender is empty", mailx.ErrInvalidConfig)
|
||||||
return nil, errors.New("not smtp")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l.auth = smtp.PlainAuth("", l.Options.Smtp.Username, l.Options.Smtp.Password, l.Options.Smtp.Host)
|
data, err := s.buildMIME(msg, from)
|
||||||
return l, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Smtp) Send(ctx context.Context, message interfaces.Message) error {
|
|
||||||
if l.Options.Smtp == nil {
|
|
||||||
return errors.New("not init")
|
|
||||||
}
|
|
||||||
// .Auth()
|
|
||||||
buffer := bytes.NewBuffer(nil)
|
|
||||||
boundary := "YunBoundaryYun"
|
|
||||||
|
|
||||||
Header := make(map[string]string)
|
|
||||||
// Header["From"] = "BOP<" + message.Form + ">"
|
|
||||||
|
|
||||||
if message.Form != "" {
|
|
||||||
Header["From"] = message.Form
|
|
||||||
} else {
|
|
||||||
Header["From"] = l.Options.Smtp.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
l.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 += message.Body + "\r\n"
|
|
||||||
|
|
||||||
// body += "--" + boundary + "--\r\n\r\n"
|
|
||||||
buffer.WriteString(body)
|
|
||||||
|
|
||||||
for _, value := range message.Attachment {
|
|
||||||
newBuf := bytes.NewBuffer(nil)
|
|
||||||
err := l.writeFile(newBuf, value.Content)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("file err:", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
f_name := path.Base(value.Content)
|
|
||||||
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--" + boundary + "--\r\n")
|
|
||||||
b := buffer.Bytes()
|
|
||||||
err := smtp.SendMail(l.Options.Smtp.Host+":"+l.Options.Smtp.Port, l.auth, l.Options.Smtp.Username, message.To, b)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化header
|
|
||||||
func (l *Smtp) 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 (l *Smtp) writeFile(buffer *bytes.Buffer, fileName string) error {
|
|
||||||
file, err := os.ReadFile(fileName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
payload := make([]byte, base64.StdEncoding.EncodedLen(len(file)))
|
|
||||||
base64.StdEncoding.Encode(payload, file)
|
if err := s.deliver(ctx, from, msg.To, data); err != nil {
|
||||||
buffer.WriteString("\r\n")
|
logger.Errorf(ctx, "mailx/smtp: send to %v failed: %v", msg.To, err)
|
||||||
for index, line := 0, len(payload); index < line; index++ {
|
return fmt.Errorf("%w: %v", mailx.ErrSendFailed, err)
|
||||||
buffer.WriteByte(payload[index])
|
}
|
||||||
if (index+1)%76 == 0 {
|
logger.Infof(ctx, "mailx/smtp: sent to %v subject=%q", msg.To, msg.Subject)
|
||||||
buffer.WriteString("\r\n")
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver 按配置的加密模式发送邮件
|
||||||
|
func (s *Smtp) deliver(ctx context.Context, from string, to []string, data []byte) error {
|
||||||
|
switch s.effectiveEncryption() {
|
||||||
|
case EncryptionSSL:
|
||||||
|
return s.sendOverSSL(ctx, from, to, data)
|
||||||
|
case EncryptionNone:
|
||||||
|
return s.sendOverPlain(ctx, from, to, data)
|
||||||
|
default: // EncryptionTLS / EncryptionAuto(非465)
|
||||||
|
return s.sendOverStartTLS(ctx, from, to, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Smtp) effectiveEncryption() Encryption {
|
||||||
|
switch s.cfg.Encryption {
|
||||||
|
case EncryptionSSL, EncryptionTLS, EncryptionNone:
|
||||||
|
return s.cfg.Encryption
|
||||||
|
default: // auto
|
||||||
|
if s.cfg.Port == 465 {
|
||||||
|
return EncryptionSSL
|
||||||
}
|
}
|
||||||
|
return EncryptionTLS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Smtp) tlsConfig() *tls.Config {
|
||||||
|
return &tls.Config{
|
||||||
|
ServerName: s.cfg.Host,
|
||||||
|
InsecureSkipVerify: s.cfg.TLSSkipVerify, //nolint:gosec // 仅测试环境开启
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialContext 建立 TCP 连接并应用 ctx 超时与 deadline
|
||||||
|
func (s *Smtp) dialContext(ctx context.Context) (net.Conn, error) {
|
||||||
|
dialer := &net.Dialer{Timeout: s.singleTimeout(ctx)}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 建连后设置整体投递 deadline,保证连接上的所有 I/O 不会无限阻塞
|
||||||
|
if err := applyDeadline(conn, s.singleTimeout(ctx)); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOverSSL 直接建立 TLS 连接(端口 465)
|
||||||
|
func (s *Smtp) sendOverSSL(ctx context.Context, from string, to []string, data []byte) error {
|
||||||
|
raw, err := s.dialContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tlsConn := tls.Client(raw, s.tlsConfig())
|
||||||
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := smtp.NewClient(tlsConn, s.cfg.Host)
|
||||||
|
if err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 由 sendWithClient 统一负责 Close
|
||||||
|
return s.sendWithClient(ctx, client, from, to, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOverStartTLS 建立明文连接后用 STARTTLS 升级
|
||||||
|
func (s *Smtp) sendOverStartTLS(ctx context.Context, from string, to []string, data []byte) error {
|
||||||
|
conn, err := s.dialContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := smtp.NewClient(conn, s.cfg.Host)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.StartTLS(s.tlsConfig()); err != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
return fmt.Errorf("starttls: %w", err)
|
||||||
|
}
|
||||||
|
return s.sendWithClient(ctx, client, from, to, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOverPlain 明文发送(不加密,仅用于内网/测试)
|
||||||
|
func (s *Smtp) sendOverPlain(ctx context.Context, from string, to []string, data []byte) error {
|
||||||
|
conn, err := s.dialContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := smtp.NewClient(conn, s.cfg.Host)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.sendWithClient(ctx, client, from, to, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWithClient 基于已建立的 SMTP 客户端完成认证与投递
|
||||||
|
// 底层连接的 deadline 已在 dialContext 阶段设置,覆盖整个投递流程。
|
||||||
|
func (s *Smtp) sendWithClient(_ context.Context, client *smtp.Client, from string, to []string, data []byte) error {
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if s.cfg.User != "" {
|
||||||
|
auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host)
|
||||||
|
if ok, _ := client.Extension("AUTH"); ok {
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("auth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP 命令要求纯邮箱地址(不含显示名),这里统一提取
|
||||||
|
senderAddr, err := mailx.ExtractEmail(from)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: invalid from address %q: %v", mailx.ErrInvalidConfig, from, err)
|
||||||
|
}
|
||||||
|
if err := client.Mail(senderAddr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, addr := range to {
|
||||||
|
recipient, rerr := mailx.ExtractEmail(addr)
|
||||||
|
if rerr != nil {
|
||||||
|
return fmt.Errorf("%w: invalid recipient %q: %v", mailx.ErrInvalidMessage, addr, rerr)
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(recipient); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.Quit(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// singleTimeout 计算单次发送的 I/O 超时:取 cfg.Timeout 与 ctx deadline 中较小者
|
||||||
|
func (s *Smtp) singleTimeout(ctx context.Context) time.Duration {
|
||||||
|
timeout := s.cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultTimeout
|
||||||
|
}
|
||||||
|
if dl, ok := ctx.Deadline(); ok {
|
||||||
|
if remain := time.Until(dl); remain < timeout {
|
||||||
|
timeout = remain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDeadline 为 net.Conn 设置整体 deadline
|
||||||
|
func applyDeadline(conn net.Conn, d time.Duration) error {
|
||||||
|
if d <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return conn.SetDeadline(time.Now().Add(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMIME 使用标准库构造邮件体,按内容自动选择 MIME 结构:
|
||||||
|
// - 仅正文:multipart/alternative(text/plain + text/html)
|
||||||
|
// - 正文 + 内嵌图片:multipart/related,内含 alternative + inline 图片
|
||||||
|
// - 有普通附件:multipart/mixed(可再内含 related)
|
||||||
|
func (s *Smtp) buildMIME(msg *mailx.Message, from string) ([]byte, error) {
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
replyTo := s.firstNonEmpty(msg.ReplyTo, s.cfg.ReplyTo)
|
||||||
|
hasBody := msg.Body != "" || msg.TextBody != ""
|
||||||
|
hasAttachments := len(msg.Attachments) > 0
|
||||||
|
hasInline := len(msg.Inline) > 0
|
||||||
|
|
||||||
|
// ---- 顶部 Header ----
|
||||||
|
header := textproto.MIMEHeader{}
|
||||||
|
header.Set("From", formatAddressHeader(from))
|
||||||
|
header.Set("To", formatAddressList(msg.To))
|
||||||
|
if len(msg.Cc) > 0 {
|
||||||
|
header.Set("Cc", formatAddressList(msg.Cc))
|
||||||
|
}
|
||||||
|
if len(msg.Bcc) > 0 {
|
||||||
|
header.Set("Bcc", formatAddressList(msg.Bcc))
|
||||||
|
}
|
||||||
|
header.Set("Subject", encodeHeader(msg.Subject))
|
||||||
|
header.Set("Date", time.Now().Format(time.RFC1123Z))
|
||||||
|
header.Set("MIME-Version", "1.0")
|
||||||
|
if replyTo != "" {
|
||||||
|
header.Set("Reply-To", formatAddressHeader(replyTo))
|
||||||
|
}
|
||||||
|
// 自定义头(不覆盖标准头)
|
||||||
|
for k, v := range msg.Headers {
|
||||||
|
if k != "" && v != "" && !hasStdHeader(k) {
|
||||||
|
header.Set(k, encodeHeader(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 正文 alternative 部分(先独立生成,便于在各结构中复用) ----
|
||||||
|
var altBytes []byte
|
||||||
|
var altBoundary string
|
||||||
|
if hasBody {
|
||||||
|
altBuf := bytes.NewBuffer(nil)
|
||||||
|
altMP := multipart.NewWriter(altBuf)
|
||||||
|
if err := writeAlternative(altMP, msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
altBytes = altBuf.Bytes()
|
||||||
|
altBoundary = altMP.Boundary()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- related 层(正文 + 内嵌图片) ----
|
||||||
|
var related *multipart.Writer
|
||||||
|
var relatedBytes []byte
|
||||||
|
if hasInline {
|
||||||
|
rbuf := bytes.NewBuffer(nil)
|
||||||
|
related = multipart.NewWriter(rbuf)
|
||||||
|
// 先写 alternative 作为 related 的第一个 part
|
||||||
|
if hasBody {
|
||||||
|
altHdr := textproto.MIMEHeader{}
|
||||||
|
altHdr.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
|
||||||
|
altPart, err := related.CreatePart(altHdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: create related alternative: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := altPart.Write(altBytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: write related alternative: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, inl := range msg.Inline {
|
||||||
|
name, data, mtype, err := s.readInline(inl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inlHdr := textproto.MIMEHeader{}
|
||||||
|
inlHdr.Set("Content-Type", mtype+"; name="+encodeHeader(name))
|
||||||
|
inlHdr.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", name))
|
||||||
|
inlHdr.Set("Content-ID", "<"+inl.CID+">")
|
||||||
|
inlHdr.Set("Content-Transfer-Encoding", "base64")
|
||||||
|
part, err := related.CreatePart(inlHdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: create inline part: %w", err)
|
||||||
|
}
|
||||||
|
bw := base64.NewEncoder(base64.StdEncoding, part)
|
||||||
|
if _, err := bw.Write(data); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: write inline: %w", err)
|
||||||
|
}
|
||||||
|
if err := bw.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: close inline: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := related.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: close related: %w", err)
|
||||||
|
}
|
||||||
|
relatedBytes = rbuf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 顶层 body(mixed / related / alternative) ----
|
||||||
|
var bodyBytes []byte
|
||||||
|
switch {
|
||||||
|
case hasAttachments:
|
||||||
|
mbuf := bytes.NewBuffer(nil)
|
||||||
|
mixed := multipart.NewWriter(mbuf)
|
||||||
|
if hasBody || hasInline {
|
||||||
|
bodyHdr := textproto.MIMEHeader{}
|
||||||
|
if hasInline {
|
||||||
|
bodyHdr.Set("Content-Type", mimeTypeWithBoundary("multipart/related", relatedBoundary(relatedBytes)))
|
||||||
|
} else {
|
||||||
|
bodyHdr.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
|
||||||
|
}
|
||||||
|
bodyPart, err := mixed.CreatePart(bodyHdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: create mixed body part: %w", err)
|
||||||
|
}
|
||||||
|
content := relatedBytes
|
||||||
|
if !hasInline {
|
||||||
|
content = altBytes
|
||||||
|
}
|
||||||
|
if _, err := bodyPart.Write(content); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: write mixed body: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, att := range msg.Attachments {
|
||||||
|
name, data, err := s.readAttachment(att)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
attHdr := textproto.MIMEHeader{}
|
||||||
|
attHdr.Set("Content-Type", "application/octet-stream; name="+encodeHeader(name))
|
||||||
|
attHdr.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
||||||
|
attHdr.Set("Content-Transfer-Encoding", "base64")
|
||||||
|
part, err := mixed.CreatePart(attHdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: create attachment part: %w", err)
|
||||||
|
}
|
||||||
|
bw := base64.NewEncoder(base64.StdEncoding, part)
|
||||||
|
if _, err := bw.Write(data); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: write attachment: %w", err)
|
||||||
|
}
|
||||||
|
if err := bw.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: close attachment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mixed.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailx/smtp: close mixed: %w", err)
|
||||||
|
}
|
||||||
|
bodyBytes = mbuf.Bytes()
|
||||||
|
header.Set("Content-Type", "multipart/mixed; boundary="+mixed.Boundary())
|
||||||
|
case hasInline:
|
||||||
|
header.Set("Content-Type", mimeTypeWithBoundary("multipart/related", relatedBoundary(relatedBytes)))
|
||||||
|
bodyBytes = relatedBytes
|
||||||
|
case hasBody:
|
||||||
|
header.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
|
||||||
|
bodyBytes = altBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一先写 headers,再写 body
|
||||||
|
writeHeaders(buf, header)
|
||||||
|
buf.Write(bodyBytes)
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeTypeWithBoundary 生成带 boundary 的 Content-Type
|
||||||
|
func mimeTypeWithBoundary(mediaType, boundary string) string {
|
||||||
|
return mediaType + "; boundary=" + boundary
|
||||||
|
}
|
||||||
|
|
||||||
|
// relatedBoundary 从 related 内容中提取 boundary
|
||||||
|
func relatedBoundary(relatedBytes []byte) string {
|
||||||
|
for _, line := range bytes.Split(relatedBytes, []byte("\r\n")) {
|
||||||
|
if after, ok := bytes.CutPrefix(line, []byte("Content-Type: multipart/related; boundary=")); ok {
|
||||||
|
return string(after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAddressList 将地址列表编码为头部字符串(显示名 + 地址)
|
||||||
|
func formatAddressList(addrs []string) string {
|
||||||
|
parts := make([]string, 0, len(addrs))
|
||||||
|
for _, a := range addrs {
|
||||||
|
if s := formatAddressHeader(a); s != "" {
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAddressHeader 将单个地址(可含显示名)编码为合法头部值
|
||||||
|
func formatAddressHeader(s string) string {
|
||||||
|
a, err := mail.ParseAddress(s)
|
||||||
|
if err != nil {
|
||||||
|
// 无法解析时回退为原始值(可能带非 ASCII,做 RFC2047 编码)
|
||||||
|
return encodeHeader(s)
|
||||||
|
}
|
||||||
|
if a.Name == "" {
|
||||||
|
return a.Address
|
||||||
|
}
|
||||||
|
return encodeHeader(a.Name) + " <" + a.Address + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasStdHeader 判断是否为邮件标准头(这些由框架统一生成,不允许被自定义头覆盖)
|
||||||
|
func hasStdHeader(k string) bool {
|
||||||
|
switch strings.ToLower(k) {
|
||||||
|
case "from", "to", "cc", "bcc", "subject", "date", "reply-to", "mime-version", "content-type":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAlternative 将正文以 text/plain + text/html 写入 alternative 结构
|
||||||
|
func writeAlternative(mp *multipart.Writer, msg *mailx.Message) error {
|
||||||
|
// 纯文本部分
|
||||||
|
if msg.TextBody != "" {
|
||||||
|
th := textproto.MIMEHeader{}
|
||||||
|
th.Set("Content-Type", "text/plain; charset=UTF-8")
|
||||||
|
th.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
part, err := mp.CreatePart(th)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mailx/smtp: create text part: %w", err)
|
||||||
|
}
|
||||||
|
qp := quotedprintable.NewWriter(part)
|
||||||
|
if _, err := qp.Write([]byte(msg.TextBody)); err != nil {
|
||||||
|
return fmt.Errorf("mailx/smtp: write text: %w", err)
|
||||||
|
}
|
||||||
|
if err := qp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("mailx/smtp: close text: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HTML 部分
|
||||||
|
if msg.Body != "" {
|
||||||
|
hh := textproto.MIMEHeader{}
|
||||||
|
hh.Set("Content-Type", "text/html; charset=UTF-8")
|
||||||
|
hh.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
part, err := mp.CreatePart(hh)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mailx/smtp: create html part: %w", err)
|
||||||
|
}
|
||||||
|
qp := quotedprintable.NewWriter(part)
|
||||||
|
if _, err := qp.Write([]byte(msg.Body)); err != nil {
|
||||||
|
return fmt.Errorf("mailx/smtp: write html: %w", err)
|
||||||
|
}
|
||||||
|
if err := qp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("mailx/smtp: close html: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("mailx/smtp: close alternative: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeaders(buf *bytes.Buffer, hdr textproto.MIMEHeader) {
|
||||||
|
// 固定顺序输出标准头,保证可读性
|
||||||
|
std := []string{"Date", "From", "To", "Cc", "Bcc", "Subject", "Reply-To", "MIME-Version", "Content-Type"}
|
||||||
|
written := make(map[string]bool, len(std))
|
||||||
|
for _, k := range std {
|
||||||
|
if v := hdr.Values(k); len(v) > 0 {
|
||||||
|
buf.WriteString(k + ": " + v[0] + "\r\n")
|
||||||
|
written[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 剩余的自定义头(按 key 排序保证稳定输出)
|
||||||
|
var extra []string
|
||||||
|
for k := range hdr {
|
||||||
|
if !written[k] && !hasStdHeader(k) {
|
||||||
|
extra = append(extra, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(extra)
|
||||||
|
for _, k := range extra {
|
||||||
|
if v := hdr.Values(k); len(v) > 0 {
|
||||||
|
buf.WriteString(k + ": " + v[0] + "\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAttachment 解析附件(路径或内存字节)
|
||||||
|
func (s *Smtp) readAttachment(att mailx.Attachment) (string, []byte, error) {
|
||||||
|
if len(att.Data) > 0 {
|
||||||
|
name := att.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "attachment"
|
||||||
|
}
|
||||||
|
return name, att.Data, nil
|
||||||
|
}
|
||||||
|
if att.Path != "" {
|
||||||
|
data, err := os.ReadFile(att.Path)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("mailx/smtp: read attachment %q: %w", att.Path, err)
|
||||||
|
}
|
||||||
|
name := att.Name
|
||||||
|
if name == "" {
|
||||||
|
name = baseName(att.Path)
|
||||||
|
}
|
||||||
|
return name, data, nil
|
||||||
|
}
|
||||||
|
return "", nil, errors.New("mailx/smtp: attachment has neither path nor data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readInline 解析内嵌图片(路径或内存字节),返回 文件名、数据、MIME 类型
|
||||||
|
func (s *Smtp) readInline(inl mailx.InlineImage) (string, []byte, string, error) {
|
||||||
|
name := inl.Name
|
||||||
|
mtype := inl.MIMEType
|
||||||
|
if mtype == "" {
|
||||||
|
if name != "" {
|
||||||
|
mtype = mime.TypeByExtension(strings.ToLower(pathExt(name)))
|
||||||
|
}
|
||||||
|
if mtype == "" {
|
||||||
|
mtype = "image/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(inl.Data) > 0 {
|
||||||
|
if name == "" {
|
||||||
|
name = "inline"
|
||||||
|
}
|
||||||
|
return name, inl.Data, mtype, nil
|
||||||
|
}
|
||||||
|
if inl.Path != "" {
|
||||||
|
data, err := os.ReadFile(inl.Path)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", fmt.Errorf("mailx/smtp: read inline %q: %w", inl.Path, err)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = baseName(inl.Path)
|
||||||
|
}
|
||||||
|
if mtype == "image/octet-stream" {
|
||||||
|
mtype = mime.TypeByExtension(strings.ToLower(pathExt(inl.Path)))
|
||||||
|
if mtype == "" {
|
||||||
|
mtype = "image/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name, data, mtype, nil
|
||||||
|
}
|
||||||
|
return "", nil, "", errors.New("mailx/smtp: inline image has neither path nor data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathExt 提取路径扩展名(含点),兼容 / 与 \
|
||||||
|
func pathExt(p string) string {
|
||||||
|
p = baseName(p)
|
||||||
|
if i := strings.LastIndexByte(p, '.'); i >= 0 {
|
||||||
|
return p[i:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmpty 返回第一个非空字符串
|
||||||
|
func (s *Smtp) firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeHeader 对非 ASCII 的头部字段做 RFC 2047 编码(ASCII 内容原样返回)
|
||||||
|
func encodeHeader(s string) string {
|
||||||
|
return mime.QEncoding.Encode("UTF-8", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseName 取路径中的文件名,兼容 / 与 \ 分隔符
|
||||||
|
func baseName(p string) string {
|
||||||
|
if i := strings.LastIndexAny(p, `/\`); i >= 0 {
|
||||||
|
return p[i+1:]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+224
-58
@@ -1,30 +1,36 @@
|
|||||||
package smtp_test
|
package smtp_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.yun.ink/pkg/mailx/interfaces"
|
"code.yun.ink/pkg/mailx"
|
||||||
"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) {
|
||||||
sm := smtp.NewSmtp()
|
if testing.Short() {
|
||||||
ctx := context.Background()
|
t.Skip("skip real send in short mode")
|
||||||
|
|
||||||
ini, err := sm.SetOption(ctx, interfaces.SetSmtp(&interfaces.EmailConfigDataSmtp{
|
|
||||||
Username: "support@email.blueoceanpay.com",
|
|
||||||
Password: "SupporT2017",
|
|
||||||
ReplyTo: "",
|
|
||||||
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
|
||||||
Port: "80",
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
client := smtp.New(smtp.Config{
|
||||||
|
Host: "smtpdm-ap-southeast-1.aliyun.com",
|
||||||
|
Port: 80,
|
||||||
|
User: "support@email.blueoceanpay.com",
|
||||||
|
Password: "SupporT2017",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
req, err := http.Get("https://baidu.com")
|
req, err := http.Get("https://baidu.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,55 +43,215 @@ func TestMail(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := interfaces.Message{
|
msg := mailx.NewMessage().
|
||||||
To: []string{"995116474@qq.com"},
|
To("995116474@qq.com").
|
||||||
Cc: []string{"287852692@qq.com"},
|
Cc("287852692@qq.com").
|
||||||
Bcc: []string{"1362716835@qq.com"},
|
Bcc("1362716835@qq.com").
|
||||||
ReplyTo: "huangxinyun@dreaminglife.cn",
|
ReplyTo("huangxinyun@dreaminglife.cn").
|
||||||
Subject: "test mail",
|
Subject("test mail").
|
||||||
Body: string(by),
|
Body(string(by)).
|
||||||
Attachment: []interfaces.MessageAttachment{
|
Build()
|
||||||
// {
|
|
||||||
// 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)
|
err = client.Send(ctx, msg)
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// func TestQQ(t *testing.T) {
|
// TestSendFakeServer 使用内存 SMTP 服务器做端到端验证,不依赖外部网络。
|
||||||
|
func TestSendFakeServer(t *testing.T) {
|
||||||
|
var got bytes.Buffer
|
||||||
|
host, port := startFakeSMTPServer(t, func(data []byte) { got.Write(data) })
|
||||||
|
|
||||||
// // 发件人邮箱
|
client := smtp.New(smtp.Config{
|
||||||
// from := "995116474@qq.com"
|
Host: host,
|
||||||
// // 授权码,而非密码
|
Port: port,
|
||||||
// authCode := "xxxxxxxxxxxxxxxxxxxxxx"
|
User: "sender@example.com",
|
||||||
// // 收件人邮箱,可以是多个收件人
|
Password: "secret",
|
||||||
// to := []string{"yun@yun.ink"}
|
Encryption: smtp.EncryptionNone, // 假服务器不支持 TLS,使用明文验证投递流程
|
||||||
// // 邮件服务器信息
|
})
|
||||||
// smtpHost := "smtp.qq.com"
|
|
||||||
// smtpPort := "587" // 或使用465,根据你的SMTP服务器要求设置
|
|
||||||
|
|
||||||
// mail := mailx.NewMailx(from, authCode, smtpHost, smtpPort)
|
msg := mailx.NewMessage().
|
||||||
|
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()
|
||||||
|
|
||||||
// msg := mailx.Message{
|
if err := client.Send(context.Background(), msg); err != nil {
|
||||||
// To: to,
|
t.Fatalf("Send: %v", err)
|
||||||
// Subject: "test mail",
|
}
|
||||||
// Body: "测试",
|
|
||||||
// }
|
out := got.String()
|
||||||
// err := mail.Send(msg)
|
for _, want := range []string{
|
||||||
// fmt.Println(err)
|
"From: sender@example.com",
|
||||||
// }
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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 // 错误信息
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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