* fix(smtp): propagate downstream delivery failures * test(smtp): support Go 1.26 textproto errors
328 lines
8.1 KiB
Go
328 lines
8.1 KiB
Go
package send
|
||
|
||
import (
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"errors"
|
||
"fmt"
|
||
"github.com/Jinnrry/pmail/config"
|
||
"github.com/Jinnrry/pmail/dto/parsemail"
|
||
"github.com/Jinnrry/pmail/models"
|
||
"github.com/Jinnrry/pmail/utils/array"
|
||
"github.com/Jinnrry/pmail/utils/async"
|
||
"github.com/Jinnrry/pmail/utils/consts"
|
||
"github.com/Jinnrry/pmail/utils/context"
|
||
"github.com/Jinnrry/pmail/utils/smtp"
|
||
log "github.com/sirupsen/logrus"
|
||
"net"
|
||
"net/textproto"
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
type mxDomain struct {
|
||
domain string
|
||
mxHost string
|
||
}
|
||
|
||
type temporaryMXFallbackError struct {
|
||
lookupErr error
|
||
fallbackErr error
|
||
}
|
||
|
||
func (e *temporaryMXFallbackError) Error() string {
|
||
return fmt.Sprintf("temporary MX lookup failed (%v); fallback delivery failed: %v", e.lookupErr, e.fallbackErr)
|
||
}
|
||
|
||
func (e *temporaryMXFallbackError) Unwrap() error {
|
||
return e.lookupErr
|
||
}
|
||
|
||
// Forward 转发邮件
|
||
func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string, user *models.User) error {
|
||
|
||
log.WithContext(ctx).Debugf("开始转发邮件")
|
||
|
||
b := e.ForwardBuildBytes(ctx, user, forwardAddress)
|
||
|
||
log.WithContext(ctx).Debugf("%s", b)
|
||
|
||
from := user.Account + "@" + config.Instance.Domains[0]
|
||
return forwardData(ctx, config.Instance.Domains[0], b, forwardAddress, from)
|
||
}
|
||
|
||
func ForwardRaw(ctx *context.Context, e *parsemail.Email, rawEmailData []byte, forwardAddress string, user *models.User) error {
|
||
log.WithContext(ctx).Debugf("开始原始邮件转发")
|
||
|
||
from := user.Account + "@" + config.Instance.Domains[0]
|
||
return forwardData(ctx, config.Instance.Domains[0], rawEmailData, forwardAddress, from)
|
||
}
|
||
|
||
func forwardData(ctx *context.Context, fromDomain string, data []byte, forwardAddress string, from string) error {
|
||
var to []*parsemail.User
|
||
to = []*parsemail.User{
|
||
{EmailAddress: forwardAddress},
|
||
}
|
||
|
||
err, _ := doSend(ctx, fromDomain, data, to, from)
|
||
return err
|
||
}
|
||
|
||
func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) {
|
||
|
||
_, fromDomain := e.From.GetDomainAccount()
|
||
|
||
b := e.BuildBytes(ctx, true)
|
||
|
||
var to []*parsemail.User
|
||
to = append(append(append(to, e.To...), e.Cc...), e.Bcc...)
|
||
|
||
return doSend(ctx, fromDomain, b, to, e.From.EmailAddress)
|
||
|
||
}
|
||
|
||
func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemail.User, from string) (error, map[string]error) {
|
||
|
||
// 按域名整理
|
||
toByDomain := map[mxDomain][]*parsemail.User{}
|
||
mxLookupErrors := map[mxDomain]error{}
|
||
for _, s := range to {
|
||
args := strings.Split(s.EmailAddress, "@")
|
||
if len(args) == 2 {
|
||
if args[1] == consts.TEST_DOMAIN {
|
||
// 测试使用
|
||
address := mxDomain{
|
||
domain: "localhost",
|
||
mxHost: "127.0.0.1",
|
||
}
|
||
toByDomain[address] = append(toByDomain[address], s)
|
||
} else {
|
||
//查询dns mx记录
|
||
mxInfo, lookupErr := net.LookupMX(args[1])
|
||
address := mxDomain{
|
||
domain: "smtp." + args[1],
|
||
mxHost: "smtp." + args[1],
|
||
}
|
||
if lookupErr != nil {
|
||
log.WithContext(ctx).Errorf("%s 域名mx记录查询失败,检查邮箱是否存在!", s.EmailAddress)
|
||
}
|
||
if len(mxInfo) > 0 {
|
||
address = mxDomain{
|
||
domain: args[1],
|
||
mxHost: mxInfo[0].Host,
|
||
}
|
||
}
|
||
if lookupErr != nil {
|
||
mxLookupErrors[address] = lookupErr
|
||
}
|
||
toByDomain[address] = append(toByDomain[address], s)
|
||
}
|
||
} else {
|
||
log.WithContext(ctx).Errorf("邮箱地址解析错误! %s", s)
|
||
continue
|
||
}
|
||
}
|
||
|
||
var errEmailAddress []string
|
||
var errEmailAddressMu sync.Mutex
|
||
|
||
errMap := sync.Map{}
|
||
|
||
as := async.New(ctx)
|
||
for domain, tos := range toByDomain {
|
||
domain := domain
|
||
tos := tos
|
||
mxLookupErr := mxLookupErrors[domain]
|
||
as.WaitProcess(func(p any) {
|
||
recordFailure := func(err error) {
|
||
err = deliveryFailureCause(mxLookupErr, err)
|
||
log.WithContext(ctx).Errorf("%v 邮件投递失败%+v", tos, err)
|
||
|
||
errEmailAddressMu.Lock()
|
||
for _, user := range tos {
|
||
errEmailAddress = append(errEmailAddress, user.EmailAddress)
|
||
}
|
||
errEmailAddressMu.Unlock()
|
||
|
||
errMap.Store(domain.domain, err)
|
||
}
|
||
|
||
if domain.domain == "localhost" {
|
||
err := smtp.SendMailUnsafe("", domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data)
|
||
if err != nil {
|
||
recordFailure(err)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 优先尝试25端口,starttls方式投递
|
||
err := smtp.SendMail("", domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data)
|
||
if err == nil {
|
||
return
|
||
}
|
||
// 证书错误,从新选取证书发送
|
||
var certificateErr *tls.CertificateVerificationError
|
||
if errors.As(err, &certificateErr) {
|
||
// 单测使用
|
||
var hostnameErr x509.HostnameError
|
||
if errors.As(certificateErr.Err, &hostnameErr) {
|
||
if hostnameErr.Certificate != nil {
|
||
certificateHostName := hostnameErr.Certificate.DNSNames
|
||
// 重新选取证书发送
|
||
err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data)
|
||
}
|
||
}
|
||
}
|
||
if err == nil {
|
||
return
|
||
}
|
||
if isPermanentSMTPResponse(err) {
|
||
recordFailure(err)
|
||
return
|
||
}
|
||
log.WithContext(ctx).Infof("SMTP STARTTLS on 25 Send Error. %s", err.Error())
|
||
|
||
// 再试用587投递
|
||
err = smtp.SendMailWithTls("", domain.mxHost+":587", nil, from, fromDomain, buildAddress(tos), data)
|
||
if err == nil {
|
||
return
|
||
}
|
||
if isPermanentSMTPResponse(err) {
|
||
recordFailure(err)
|
||
return
|
||
}
|
||
log.WithContext(ctx).Infof("SMTPS on 587 Send Error. %s", err.Error())
|
||
|
||
// 再次尝试465投递
|
||
err = smtp.SendMailWithTls("", domain.mxHost+":465", nil, from, fromDomain, buildAddress(tos), data)
|
||
if err == nil {
|
||
return
|
||
}
|
||
if isPermanentSMTPResponse(err) {
|
||
recordFailure(err)
|
||
return
|
||
}
|
||
log.WithContext(ctx).Infof("SMTPS on 465 Send Error. %s", err.Error())
|
||
|
||
// 最后尝试非安全方式投递
|
||
err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data)
|
||
if err == nil {
|
||
log.WithContext(ctx).Warnf("Send By Unsafe SMTP")
|
||
return
|
||
}
|
||
|
||
recordFailure(err)
|
||
}, nil)
|
||
}
|
||
as.Wait()
|
||
|
||
orgMap := map[string]error{}
|
||
errMap.Range(func(key, value any) bool {
|
||
if value != nil {
|
||
orgMap[key.(string)] = value.(error)
|
||
} else {
|
||
orgMap[key.(string)] = nil
|
||
}
|
||
|
||
return true
|
||
})
|
||
|
||
if len(errEmailAddress) > 0 {
|
||
return errors.New("以下收件人投递失败:" + array.Join(errEmailAddress, ",")), orgMap
|
||
}
|
||
return nil, orgMap
|
||
}
|
||
|
||
func isPermanentSMTPResponse(err error) bool {
|
||
var protocolErr *textproto.Error
|
||
return errors.As(err, &protocolErr) && protocolErr.Code >= 500 && protocolErr.Code <= 599
|
||
}
|
||
|
||
func deliveryFailureCause(mxLookupErr, fallbackErr error) error {
|
||
if fallbackErr == nil || isPermanentSMTPResponse(fallbackErr) {
|
||
return fallbackErr
|
||
}
|
||
|
||
var lookupDNSErr *net.DNSError
|
||
if !errors.As(mxLookupErr, &lookupDNSErr) || (!lookupDNSErr.IsTimeout && !lookupDNSErr.IsTemporary) {
|
||
return fallbackErr
|
||
}
|
||
|
||
var networkErr net.Error
|
||
if !errors.As(fallbackErr, &networkErr) {
|
||
return fallbackErr
|
||
}
|
||
|
||
return &temporaryMXFallbackError{
|
||
lookupErr: mxLookupErr,
|
||
fallbackErr: fallbackErr,
|
||
}
|
||
}
|
||
|
||
func buildAddress(u []*parsemail.User) []string {
|
||
var ret []string
|
||
|
||
for _, user := range u {
|
||
ret = append(ret, user.EmailAddress)
|
||
|
||
}
|
||
|
||
return ret
|
||
}
|
||
|
||
func domainMatch(domain string, dnsNames []string) string {
|
||
if len(dnsNames) == 0 {
|
||
return domain
|
||
}
|
||
|
||
secondMatch := ""
|
||
|
||
for _, name := range dnsNames {
|
||
if strings.Contains(name, "smtp") {
|
||
secondMatch = name
|
||
}
|
||
|
||
if name == domain {
|
||
return name
|
||
}
|
||
if strings.Contains(name, "*") {
|
||
nameArg := strings.Split(name, ".")
|
||
domainArg := strings.Split(domain, ".")
|
||
match := true
|
||
for i := 0; i < len(nameArg); i++ {
|
||
if nameArg[len(nameArg)-1-i] == "*" {
|
||
continue
|
||
}
|
||
if len(domainArg) > i {
|
||
if nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] {
|
||
continue
|
||
}
|
||
}
|
||
match = false
|
||
break
|
||
}
|
||
|
||
for i := 0; i < len(domainArg); i++ {
|
||
if len(nameArg) > i && nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] {
|
||
continue
|
||
}
|
||
if len(nameArg) > i && nameArg[len(nameArg)-1-i] == "*" {
|
||
continue
|
||
}
|
||
|
||
match = false
|
||
break
|
||
}
|
||
if match {
|
||
return domain
|
||
}
|
||
}
|
||
}
|
||
|
||
if secondMatch != "" {
|
||
return strings.ReplaceAll(secondMatch, "*.", "")
|
||
}
|
||
|
||
return strings.ReplaceAll(dnsNames[0], "*.", "")
|
||
}
|