Smail/server/utils/send/send.go
0clickjacking0 1da3e178bf
fix(smtp): 修复下游投递失败后 SMTP DATA 仍返回成功,导致上游误判为已发送 (#356)
* fix(smtp): propagate downstream delivery failures

* test(smtp): support Go 1.26 textproto errors
2026-08-17 10:40:52 +08:00

328 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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], "*.", "")
}