mirror of
https://github.com/mjl-/mox.git
synced 2025-07-14 17:34:37 +03:00
queue: deliver to multiple recipients in a single smtp transaction
transferring the data only once. we only do this when the recipient domains are the same. when queuing, we now take care to set the same NextAttempt timestamp, so queued messages are actually eligable for combined delivery. this adds a DeliverMultiple to the smtp client. for pipelined requests, it will send all RCPT TO (and MAIL and DATA) in one go, and handles the various responses and error conditions, returning either an overal error, or per recipient smtp responses. the results of the smtp LIMITS extension are also available in the smtp client now. this also takes the "LIMITS RCPTMAX" smtp extension into account: if the server only accepts a single recipient, we won't send multiple. if a server doesn't announce a RCPTMAX limit, but still has one (like mox does for non-spf-verified transactions), we'll recognize code 452 and 552 (for historic reasons) as temporary error, and try again in a separate transaction immediately after. we don't yet implement "LIMITS MAILMAX", doesn't seem likely in practice.
This commit is contained in:
@ -28,6 +28,7 @@ package smtpclient
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
@ -132,16 +133,20 @@ type Client struct {
|
||||
botched bool // If set, protocol is out of sync and no further commands can be sent.
|
||||
needRset bool // If set, a new delivery requires an RSET command.
|
||||
|
||||
remoteHelo string // From 220 greeting line.
|
||||
extEcodes bool // Remote server supports sending extended error codes.
|
||||
extStartTLS bool // Remote server supports STARTTLS.
|
||||
ext8bitmime bool
|
||||
extSize bool // Remote server supports SIZE parameter. Must only be used if > 0.
|
||||
maxSize int64 // Max size of email message.
|
||||
extPipelining bool // Remote server supports command pipelining.
|
||||
extSMTPUTF8 bool // Remote server supports SMTPUTF8 extension.
|
||||
extAuthMechanisms []string // Supported authentication mechanisms.
|
||||
extRequireTLS bool // Remote supports REQUIRETLS extension.
|
||||
remoteHelo string // From 220 greeting line.
|
||||
extEcodes bool // Remote server supports sending extended error codes.
|
||||
extStartTLS bool // Remote server supports STARTTLS.
|
||||
ext8bitmime bool
|
||||
extSize bool // Remote server supports SIZE parameter. Must only be used if > 0.
|
||||
maxSize int64 // Max size of email message.
|
||||
extPipelining bool // Remote server supports command pipelining.
|
||||
extSMTPUTF8 bool // Remote server supports SMTPUTF8 extension.
|
||||
extAuthMechanisms []string // Supported authentication mechanisms.
|
||||
extRequireTLS bool // Remote supports REQUIRETLS extension.
|
||||
ExtLimits map[string]string // For LIMITS extension, only if present and valid, with uppercase keys.
|
||||
ExtLimitMailMax int // Max "MAIL" commands in a connection, if > 0.
|
||||
ExtLimitRcptMax int // Max "RCPT" commands in a transaction, if > 0.
|
||||
ExtLimitRcptDomainMax int // Max unique domains in a connection, if > 0.
|
||||
}
|
||||
|
||||
// Error represents a failure to deliver a message.
|
||||
@ -170,6 +175,8 @@ type Error struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
type Response Error
|
||||
|
||||
// Unwrap returns the underlying Err.
|
||||
func (e Error) Unwrap() error {
|
||||
return e.Err
|
||||
@ -744,6 +751,8 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Do
|
||||
}
|
||||
} else if strings.HasPrefix(s, "AUTH ") {
|
||||
c.extAuthMechanisms = strings.Split(s[len("AUTH "):], " ")
|
||||
} else if strings.HasPrefix(s, "LIMITS ") {
|
||||
c.ExtLimits, c.ExtLimitMailMax, c.ExtLimitRcptMax, c.ExtLimitRcptDomainMax = parseLimits([]byte(s[len("LIMITS"):]))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -834,6 +843,79 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Do
|
||||
return
|
||||
}
|
||||
|
||||
// parse text after "LIMITS", including leading space.
|
||||
func parseLimits(b []byte) (map[string]string, int, int, int) {
|
||||
// ../rfc/9422:150
|
||||
var o int
|
||||
// Read next " name=value".
|
||||
pair := func() ([]byte, []byte) {
|
||||
if o >= len(b) || b[o] != ' ' {
|
||||
return nil, nil
|
||||
}
|
||||
o++
|
||||
|
||||
ns := o
|
||||
for o < len(b) {
|
||||
c := b[o]
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' {
|
||||
o++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
es := o
|
||||
if ns == es || o >= len(b) || b[o] != '=' {
|
||||
return nil, nil
|
||||
}
|
||||
o++
|
||||
vs := o
|
||||
for o < len(b) {
|
||||
c := b[o]
|
||||
if c > 0x20 && c < 0x7f && c != ';' {
|
||||
o++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if vs == o {
|
||||
return nil, nil
|
||||
}
|
||||
return b[ns:es], b[vs:o]
|
||||
}
|
||||
limits := map[string]string{}
|
||||
var mailMax, rcptMax, rcptDomainMax int
|
||||
for o < len(b) {
|
||||
name, value := pair()
|
||||
if name == nil {
|
||||
// We skip the entire LIMITS extension for syntax errors. ../rfc/9422:232
|
||||
return nil, 0, 0, 0
|
||||
}
|
||||
k := strings.ToUpper(string(name))
|
||||
if _, ok := limits[k]; ok {
|
||||
// Not specified, but we treat duplicates as error.
|
||||
return nil, 0, 0, 0
|
||||
}
|
||||
limits[k] = string(value)
|
||||
// For individual value syntax errors, we skip that value, leaving the default 0.
|
||||
// ../rfc/9422:254
|
||||
switch string(name) {
|
||||
case "MAILMAX":
|
||||
if v, err := strconv.Atoi(string(value)); err == nil && v > 0 && len(value) <= 6 {
|
||||
mailMax = v
|
||||
}
|
||||
case "RCPTMAX":
|
||||
if v, err := strconv.Atoi(string(value)); err == nil && v > 0 && len(value) <= 6 {
|
||||
rcptMax = v
|
||||
}
|
||||
case "RCPTDOMAINMAX":
|
||||
if v, err := strconv.Atoi(string(value)); err == nil && v > 0 && len(value) <= 6 {
|
||||
rcptDomainMax = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return limits, mailMax, rcptMax, rcptDomainMax
|
||||
}
|
||||
|
||||
func addrIP(addr net.Addr) string {
|
||||
if t, ok := addr.(*net.TCPAddr); ok {
|
||||
return t.IP.String()
|
||||
@ -1019,15 +1101,39 @@ func (c *Client) TLSConnectionState() *tls.ConnectionState {
|
||||
// Returned errors can be of type Error, one of the Err-variables in this package
|
||||
// or other underlying errors, e.g. for i/o. Use errors.Is to check.
|
||||
func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, msgSize int64, msg io.Reader, req8bitmime, reqSMTPUTF8, requireTLS bool) (rerr error) {
|
||||
_, err := c.DeliverMultiple(ctx, mailFrom, []string{rcptTo}, msgSize, msg, req8bitmime, reqSMTPUTF8, requireTLS)
|
||||
return err
|
||||
}
|
||||
|
||||
var errNoRecipientsPipelined = errors.New("no recipients accepted in pipelined transaction")
|
||||
var errNoRecipients = errors.New("no recipients accepted in transaction")
|
||||
|
||||
// DeliverMultiple is like Deliver, but attempts to deliver a message to multiple
|
||||
// recipients. Errors about the entire transaction, such as i/o errors or error
|
||||
// responses to the MAIL FROM or DATA commands, are returned by a non-nil rerr. If
|
||||
// rcptTo has a single recipient, an error to the RCPT TO command is returned in
|
||||
// rerr instead of rcptResps. Otherwise, the SMTP response for each recipient is
|
||||
// returned in rcptResps.
|
||||
//
|
||||
// The caller should take extLimit* into account when sending. And recognize
|
||||
// recipient response code "452" to mean that a recipient limit was reached,
|
||||
// another transaction can be attempted immediately after instead of marking the
|
||||
// delivery attempt as failed. Also code "552" must be treated like temporary error
|
||||
// code "452" for historic reasons.
|
||||
func (c *Client) DeliverMultiple(ctx context.Context, mailFrom string, rcptTo []string, msgSize int64, msg io.Reader, req8bitmime, reqSMTPUTF8, requireTLS bool) (rcptResps []Response, rerr error) {
|
||||
defer c.recover(&rerr)
|
||||
|
||||
if len(rcptTo) == 0 {
|
||||
return nil, fmt.Errorf("need at least one recipient")
|
||||
}
|
||||
|
||||
if c.origConn == nil {
|
||||
return ErrClosed
|
||||
return nil, ErrClosed
|
||||
} else if c.botched {
|
||||
return ErrBotched
|
||||
return nil, ErrBotched
|
||||
} else if c.needRset {
|
||||
if err := c.Reset(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@ -1077,45 +1183,122 @@ func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, ms
|
||||
// RCPT TO: ../rfc/5321:1916
|
||||
// DATA: ../rfc/5321:1992
|
||||
lineMailFrom := fmt.Sprintf("MAIL FROM:<%s>%s%s%s%s", mailFrom, mailSize, bodyType, smtputf8Arg, requiretlsArg)
|
||||
lineRcptTo := fmt.Sprintf("RCPT TO:<%s>", rcptTo)
|
||||
|
||||
// We are going into a transaction. We'll clear this when done.
|
||||
c.needRset = true
|
||||
|
||||
if c.extPipelining {
|
||||
c.cmds = []string{"mailfrom", "rcptto", "data"}
|
||||
c.cmds = make([]string, 1+len(rcptTo)+1)
|
||||
c.cmds[0] = "mailfrom"
|
||||
for i := range rcptTo {
|
||||
c.cmds[1+i] = "rcptto"
|
||||
}
|
||||
c.cmds[len(c.cmds)-1] = "data"
|
||||
c.cmdStart = time.Now()
|
||||
// todo future: write in a goroutine to prevent potential deadlock if remote does not consume our writes before expecting us to read. could potentially happen with greylisting and a small tcp send window?
|
||||
c.xbwriteline(lineMailFrom)
|
||||
c.xbwriteline(lineRcptTo)
|
||||
c.xbwriteline("DATA")
|
||||
c.xflush()
|
||||
|
||||
// We read the response to RCPT TO and DATA without panic on read error. Servers
|
||||
// Write and read in separte goroutines. Otherwise, writing a large recipient list
|
||||
// could block when a server doesn't read more commands before we read their
|
||||
// response.
|
||||
errc := make(chan error, 1)
|
||||
// Make sure we don't return before we're done writing to the connection.
|
||||
defer func() {
|
||||
if errc != nil {
|
||||
<-errc
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(lineMailFrom)
|
||||
b.WriteString("\r\n")
|
||||
for _, rcpt := range rcptTo {
|
||||
b.WriteString("RCPT TO:<")
|
||||
b.WriteString(rcpt)
|
||||
b.WriteString(">\r\n")
|
||||
}
|
||||
b.WriteString("DATA\r\n")
|
||||
_, err := c.w.Write(b.Bytes())
|
||||
if err == nil {
|
||||
err = c.w.Flush()
|
||||
}
|
||||
errc <- err
|
||||
}()
|
||||
|
||||
// Read response to MAIL FROM.
|
||||
mfcode, mfsecode, mffirstLine, mfmoreLines := c.xread()
|
||||
|
||||
// We read the response to RCPT TOs and DATA without panic on read error. Servers
|
||||
// may be aborting the connection after a failed MAIL FROM, e.g. outlook when it
|
||||
// has blocklisted your IP. We don't want the read for the response to RCPT TO to
|
||||
// cause a read error as it would result in an unhelpful error message and a
|
||||
// temporary instead of permanent error code.
|
||||
|
||||
mfcode, mfsecode, mffirstLine, mfmoreLines := c.xread()
|
||||
rtcode, rtsecode, rtfirstLine, rtmoreLines, rterr := c.read()
|
||||
// Read responses to RCPT TO.
|
||||
rcptResps = make([]Response, len(rcptTo))
|
||||
nok := 0
|
||||
for i := 0; i < len(rcptTo); i++ {
|
||||
code, secode, firstLine, moreLines, err := c.read()
|
||||
// 552 should be treated as temporary historically, ../rfc/5321:3576
|
||||
permanent := code/100 == 5 && code != smtp.C552MailboxFull
|
||||
rcptResps[i] = Response{permanent, code, secode, "rcptto", firstLine, moreLines, err}
|
||||
if code == smtp.C250Completed {
|
||||
nok++
|
||||
}
|
||||
}
|
||||
|
||||
// Read response to DATA.
|
||||
datacode, datasecode, datafirstLine, datamoreLines, dataerr := c.read()
|
||||
|
||||
writeerr := <-errc
|
||||
errc = nil
|
||||
|
||||
// If MAIL FROM failed, it's an error for the entire transaction. We may have been
|
||||
// blocked.
|
||||
if mfcode != smtp.C250Completed {
|
||||
if writeerr != nil || dataerr != nil {
|
||||
c.botched = true
|
||||
}
|
||||
c.xerrorf(mfcode/100 == 5, mfcode, mfsecode, mffirstLine, mfmoreLines, "%w: got %d, expected 2xx", ErrStatus, mfcode)
|
||||
}
|
||||
if rterr != nil {
|
||||
panic(rterr)
|
||||
}
|
||||
if rtcode != smtp.C250Completed {
|
||||
c.xerrorf(rtcode/100 == 5, rtcode, rtsecode, rtfirstLine, rtmoreLines, "%w: got %d, expected 2xx", ErrStatus, rtcode)
|
||||
|
||||
// If there was an i/o error writing the commands, there is no point continuing.
|
||||
if writeerr != nil {
|
||||
c.xbotchf(0, "", "", nil, "writing pipelined mail/rcpt/data: %w", writeerr)
|
||||
}
|
||||
|
||||
// If the data command had an i/o or protocol error, it's also a failure for the
|
||||
// entire transaction.
|
||||
if dataerr != nil {
|
||||
panic(dataerr)
|
||||
}
|
||||
|
||||
// If we didn't have any successful recipient, there is no point in continuing.
|
||||
if nok == 0 {
|
||||
// Servers may return success for a DATA without valid recipients. Write a dot to
|
||||
// end DATA and restore the connection to a known state.
|
||||
// ../rfc/2920:328
|
||||
if datacode == smtp.C354Continue {
|
||||
_, doterr := fmt.Fprintf(c.w, ".\r\n")
|
||||
if doterr == nil {
|
||||
doterr = c.w.Flush()
|
||||
}
|
||||
if doterr == nil {
|
||||
_, _, _, _, doterr = c.read()
|
||||
}
|
||||
if doterr != nil {
|
||||
c.botched = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(rcptTo) == 1 {
|
||||
panic(Error(rcptResps[0]))
|
||||
}
|
||||
c.xerrorf(false, 0, "", "", nil, "%w", errNoRecipientsPipelined)
|
||||
}
|
||||
|
||||
if datacode != smtp.C354Continue {
|
||||
c.xerrorf(datacode/100 == 5, datacode, datasecode, datafirstLine, datamoreLines, "%w: got %d, expected 354", ErrStatus, datacode)
|
||||
}
|
||||
|
||||
} else {
|
||||
c.cmds[0] = "mailfrom"
|
||||
c.cmdStart = time.Now()
|
||||
@ -1125,12 +1308,35 @@ func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, ms
|
||||
c.xerrorf(code/100 == 5, code, secode, firstLine, moreLines, "%w: got %d, expected 2xx", ErrStatus, code)
|
||||
}
|
||||
|
||||
c.cmds[0] = "rcptto"
|
||||
c.cmdStart = time.Now()
|
||||
c.xwriteline(lineRcptTo)
|
||||
code, secode, firstLine, moreLines = c.xread()
|
||||
if code != smtp.C250Completed {
|
||||
c.xerrorf(code/100 == 5, code, secode, firstLine, moreLines, "%w: got %d, expected 2xx", ErrStatus, code)
|
||||
rcptResps = make([]Response, len(rcptTo))
|
||||
nok := 0
|
||||
for i, rcpt := range rcptTo {
|
||||
c.cmds[0] = "rcptto"
|
||||
c.cmdStart = time.Now()
|
||||
c.xwriteline(fmt.Sprintf("RCPT TO:<%s>", rcpt))
|
||||
code, secode, firstLine, moreLines = c.xread()
|
||||
if i > 0 && (code == smtp.C452StorageFull || code == smtp.C552MailboxFull) {
|
||||
// Remote doesn't accept more recipients for this transaction. Don't send more, give
|
||||
// remaining recipients the same error result.
|
||||
for j := i; j < len(rcptTo); j++ {
|
||||
rcptResps[j] = Response{false, code, secode, "rcptto", firstLine, moreLines, fmt.Errorf("no more recipients accepted in transaction")}
|
||||
}
|
||||
break
|
||||
}
|
||||
var err error
|
||||
if code == smtp.C250Completed {
|
||||
nok++
|
||||
} else {
|
||||
err = fmt.Errorf("%w: got %d, expected 2xx", ErrStatus, code)
|
||||
}
|
||||
rcptResps[i] = Response{code/100 == 5, code, secode, "rcptto", firstLine, moreLines, err}
|
||||
}
|
||||
|
||||
if nok == 0 {
|
||||
if len(rcptTo) == 1 {
|
||||
panic(Error(rcptResps[0]))
|
||||
}
|
||||
c.xerrorf(false, 0, "", "", nil, "%w", errNoRecipients)
|
||||
}
|
||||
|
||||
c.cmds[0] = "data"
|
||||
|
@ -39,6 +39,7 @@ func TestClient(t *testing.T) {
|
||||
mlog.SetConfig(map[string]slog.Level{"": mlog.LevelTrace})
|
||||
|
||||
type options struct {
|
||||
// Server behaviour.
|
||||
pipelining bool
|
||||
ecodes bool
|
||||
maxSize int
|
||||
@ -47,7 +48,11 @@ func TestClient(t *testing.T) {
|
||||
smtputf8 bool
|
||||
requiretls bool
|
||||
ehlo bool
|
||||
auths []string // Allowed mechanisms.
|
||||
|
||||
nodeliver bool // For server, whether client will attempt a delivery.
|
||||
|
||||
// Client behaviour.
|
||||
tlsMode TLSMode
|
||||
tlsPKIX bool
|
||||
roots *x509.CertPool
|
||||
@ -55,9 +60,8 @@ func TestClient(t *testing.T) {
|
||||
need8bitmime bool
|
||||
needsmtputf8 bool
|
||||
needsrequiretls bool
|
||||
auths []string // Allowed mechanisms.
|
||||
|
||||
nodeliver bool // For server, whether client will attempt a delivery.
|
||||
recipients []string // If nil, mjl@mox.example is used.
|
||||
resps []Response // Checked only if non-nil.
|
||||
}
|
||||
|
||||
// Make fake cert, and make it trusted.
|
||||
@ -68,6 +72,13 @@ func TestClient(t *testing.T) {
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
|
||||
cleanupResp := func(resps []Response) []Response {
|
||||
for i, r := range resps {
|
||||
resps[i] = Response{Code: r.Code, Secode: r.Secode}
|
||||
}
|
||||
return resps
|
||||
}
|
||||
|
||||
test := func(msg string, opts options, auth func(l []string, cs *tls.ConnectionState) (sasl.Client, error), expClientErr, expDeliverErr, expServerErr error) {
|
||||
t.Helper()
|
||||
|
||||
@ -89,6 +100,7 @@ func TestClient(t *testing.T) {
|
||||
}()
|
||||
fail := func(format string, args ...any) {
|
||||
err := fmt.Errorf("server: %w", fmt.Errorf(format, args...))
|
||||
log.Errorx("failure", err)
|
||||
if err != nil && expServerErr != nil && (errors.Is(err, expServerErr) || errors.As(err, reflect.New(reflect.ValueOf(expServerErr).Type()).Interface())) {
|
||||
err = nil
|
||||
}
|
||||
@ -158,6 +170,7 @@ func TestClient(t *testing.T) {
|
||||
if opts.auths != nil {
|
||||
writeline("250-AUTH " + strings.Join(opts.auths, " "))
|
||||
}
|
||||
writeline("250-LIMITS MAILMAX=10 RCPTMAX=100 RCPTDOMAINMAX=1")
|
||||
writeline("250 UNKNOWN") // To be ignored.
|
||||
}
|
||||
|
||||
@ -255,8 +268,18 @@ func TestClient(t *testing.T) {
|
||||
if expClientErr == nil && !opts.nodeliver {
|
||||
readline("MAIL FROM:")
|
||||
writeline("250 ok")
|
||||
readline("RCPT TO:")
|
||||
writeline("250 ok")
|
||||
n := len(opts.recipients)
|
||||
if n == 0 {
|
||||
n = 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
readline("RCPT TO:")
|
||||
resp := "250 ok"
|
||||
if i < len(opts.resps) {
|
||||
resp = fmt.Sprintf("%d maybe", opts.resps[i].Code)
|
||||
}
|
||||
writeline(resp)
|
||||
}
|
||||
readline("DATA")
|
||||
writeline("354 continue")
|
||||
reader := smtp.NewDataReader(br)
|
||||
@ -269,8 +292,14 @@ func TestClient(t *testing.T) {
|
||||
|
||||
readline("MAIL FROM:")
|
||||
writeline("250 ok")
|
||||
readline("RCPT TO:")
|
||||
writeline("250 ok")
|
||||
for i := 0; i < n; i++ {
|
||||
readline("RCPT TO:")
|
||||
resp := "250 ok"
|
||||
if i < len(opts.resps) {
|
||||
resp = fmt.Sprintf("%d maybe", opts.resps[i].Code)
|
||||
}
|
||||
writeline(resp)
|
||||
}
|
||||
readline("DATA")
|
||||
writeline("354 continue")
|
||||
reader = smtp.NewDataReader(br)
|
||||
@ -294,6 +323,7 @@ func TestClient(t *testing.T) {
|
||||
}()
|
||||
fail := func(format string, args ...any) {
|
||||
err := fmt.Errorf("client: %w", fmt.Errorf(format, args...))
|
||||
log.Errorx("failure", err)
|
||||
result <- err
|
||||
panic("stop")
|
||||
}
|
||||
@ -305,18 +335,26 @@ func TestClient(t *testing.T) {
|
||||
result <- nil
|
||||
return
|
||||
}
|
||||
err = client.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
|
||||
if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
|
||||
fail("first deliver: got err %v, expected %v", err, expDeliverErr)
|
||||
rcptTo := opts.recipients
|
||||
if len(rcptTo) == 0 {
|
||||
rcptTo = []string{"mjl@mox.example"}
|
||||
}
|
||||
resps, err := client.DeliverMultiple(ctx, "postmaster@mox.example", rcptTo, int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
|
||||
if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) && !reflect.DeepEqual(err, expDeliverErr) {
|
||||
fail("first deliver: got err %#v (%s), expected %#v (%s)", err, err, expDeliverErr, expDeliverErr)
|
||||
} else if opts.resps != nil && !reflect.DeepEqual(cleanupResp(resps), opts.resps) {
|
||||
fail("first deliver: got resps %v, expected %v", resps, opts.resps)
|
||||
}
|
||||
if err == nil {
|
||||
err = client.Reset()
|
||||
if err != nil {
|
||||
fail("reset: %v", err)
|
||||
}
|
||||
err = client.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
|
||||
if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
|
||||
fail("second deliver: got err %v, expected %v", err, expDeliverErr)
|
||||
resps, err = client.DeliverMultiple(ctx, "postmaster@mox.example", rcptTo, int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
|
||||
if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) && !reflect.DeepEqual(err, expDeliverErr) {
|
||||
fail("second deliver: got err %#v (%s), expected %#v (%s)", err, err, expDeliverErr, expDeliverErr)
|
||||
} else if opts.resps != nil && !reflect.DeepEqual(cleanupResp(resps), opts.resps) {
|
||||
fail("second: got resps %v, expected %v", resps, opts.resps)
|
||||
}
|
||||
}
|
||||
err = client.Close()
|
||||
@ -369,9 +407,58 @@ test
|
||||
test(msg, options{ehlo: true, eightbitmime: true}, nil, nil, nil, nil)
|
||||
test(msg, options{ehlo: true, eightbitmime: false, need8bitmime: true, nodeliver: true}, nil, nil, Err8bitmimeUnsupported, nil)
|
||||
test(msg, options{ehlo: true, smtputf8: false, needsmtputf8: true, nodeliver: true}, nil, nil, ErrSMTPUTF8Unsupported, nil)
|
||||
test(msg, options{ehlo: true, starttls: true, tlsMode: TLSRequiredStartTLS, tlsPKIX: true, tlsHostname: dns.Domain{ASCII: "mismatch.example"}, nodeliver: true}, nil, ErrTLS, nil, &net.OpError{}) // Server TLS handshake is a net.OpError with "remote error" as text.
|
||||
|
||||
// Server TLS handshake is a net.OpError with "remote error" as text.
|
||||
test(msg, options{ehlo: true, starttls: true, tlsMode: TLSRequiredStartTLS, tlsPKIX: true, tlsHostname: dns.Domain{ASCII: "mismatch.example"}, nodeliver: true}, nil, ErrTLS, nil, &net.OpError{})
|
||||
|
||||
test(msg, options{ehlo: true, maxSize: len(msg) - 1, nodeliver: true}, nil, nil, ErrSize, nil)
|
||||
|
||||
// Multiple recipients, not pipelined.
|
||||
multi1 := options{
|
||||
ehlo: true,
|
||||
pipelining: true,
|
||||
ecodes: true,
|
||||
recipients: []string{"mjl@mox.example", "mjl2@mox.example", "mjl3@mox.example"},
|
||||
resps: []Response{
|
||||
{Code: smtp.C250Completed},
|
||||
{Code: smtp.C250Completed},
|
||||
{Code: smtp.C250Completed},
|
||||
},
|
||||
}
|
||||
test(msg, multi1, nil, nil, nil, nil)
|
||||
multi1.pipelining = true
|
||||
test(msg, multi1, nil, nil, nil, nil)
|
||||
|
||||
// Multiple recipients with 452 and other error, not pipelined
|
||||
multi2 := options{
|
||||
ehlo: true,
|
||||
ecodes: true,
|
||||
recipients: []string{"xmjl@mox.example", "xmjl2@mox.example", "xmjl3@mox.example"},
|
||||
resps: []Response{
|
||||
{Code: smtp.C250Completed},
|
||||
{Code: smtp.C554TransactionFailed}, // Will continue when not pipelined.
|
||||
{Code: smtp.C452StorageFull}, // Will stop sending further recipients.
|
||||
},
|
||||
}
|
||||
test(msg, multi2, nil, nil, nil, nil)
|
||||
multi2.pipelining = true
|
||||
test(msg, multi2, nil, nil, nil, nil)
|
||||
multi2.pipelining = false
|
||||
multi2.resps[2].Code = smtp.C552MailboxFull
|
||||
test(msg, multi2, nil, nil, nil, nil)
|
||||
multi2.pipelining = true
|
||||
test(msg, multi2, nil, nil, nil, nil)
|
||||
|
||||
// Single recipient with error and pipelining is an error.
|
||||
multi3 := options{
|
||||
ehlo: true,
|
||||
pipelining: true,
|
||||
ecodes: true,
|
||||
recipients: []string{"xmjl@mox.example"},
|
||||
resps: []Response{{Code: smtp.C452StorageFull}},
|
||||
}
|
||||
test(msg, multi3, nil, nil, Error{Code: smtp.C452StorageFull, Command: "rcptto", Line: "452 maybe"}, nil)
|
||||
|
||||
authPlain := func(l []string, cs *tls.ConnectionState) (sasl.Client, error) {
|
||||
return sasl.NewClientPlain("test", "test"), nil
|
||||
}
|
||||
@ -669,6 +756,69 @@ func TestErrors(t *testing.T) {
|
||||
panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
|
||||
}
|
||||
})
|
||||
|
||||
// If we try multiple recipients and first is 452, it is an error and a
|
||||
// non-pipelined deliver will be aborted.
|
||||
run(t, func(s xserver) {
|
||||
s.writeline("220 mox.example")
|
||||
s.readline("EHLO")
|
||||
s.writeline("250 mox.example")
|
||||
s.readline("MAIL FROM:")
|
||||
s.writeline("250 ok")
|
||||
s.readline("RCPT TO:")
|
||||
s.writeline("451 not now")
|
||||
s.readline("RCPT TO:")
|
||||
s.writeline("451 not now")
|
||||
s.readline("QUIT")
|
||||
s.writeline("250 ok")
|
||||
}, func(conn net.Conn) {
|
||||
c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
msg := ""
|
||||
_, err = c.DeliverMultiple(ctx, "postmaster@other.example", []string{"mjl@mox.example", "mjl@mox.example"}, int64(len(msg)), strings.NewReader(msg), false, false, false)
|
||||
var xerr Error
|
||||
if err == nil || !errors.Is(err, errNoRecipients) || !errors.As(err, &xerr) || xerr.Permanent {
|
||||
panic(fmt.Errorf("got %#v (%s) expected errNoRecipients with non-Permanent", err, err))
|
||||
}
|
||||
c.Close()
|
||||
})
|
||||
|
||||
// If we try multiple recipients and first is 452, it is an error and a pipelined
|
||||
// deliver will abort an allowed DATA.
|
||||
run(t, func(s xserver) {
|
||||
s.writeline("220 mox.example")
|
||||
s.readline("EHLO")
|
||||
s.writeline("250-mox.example")
|
||||
s.writeline("250 PIPELINING")
|
||||
s.readline("MAIL FROM:")
|
||||
s.writeline("250 ok")
|
||||
s.readline("RCPT TO:")
|
||||
s.writeline("451 not now")
|
||||
s.readline("RCPT TO:")
|
||||
s.writeline("451 not now")
|
||||
s.readline("DATA")
|
||||
s.writeline("354 ok")
|
||||
s.readline(".")
|
||||
s.writeline("503 no recipient")
|
||||
s.readline("QUIT")
|
||||
s.writeline("250 ok")
|
||||
}, func(conn net.Conn) {
|
||||
c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
msg := ""
|
||||
_, err = c.DeliverMultiple(ctx, "postmaster@other.example", []string{"mjl@mox.example", "mjl@mox.example"}, int64(len(msg)), strings.NewReader(msg), false, false, false)
|
||||
var xerr Error
|
||||
if err == nil || !errors.Is(err, errNoRecipientsPipelined) || !errors.As(err, &xerr) || xerr.Permanent {
|
||||
panic(fmt.Errorf("got %#v (%s), expected errNoRecipientsPipelined with non-Permanent", err, err))
|
||||
}
|
||||
c.Close()
|
||||
})
|
||||
}
|
||||
|
||||
type xserver struct {
|
||||
@ -740,6 +890,21 @@ func run(t *testing.T, server func(s xserver), client func(conn net.Conn)) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimits(t *testing.T) {
|
||||
check := func(s string, expLimits map[string]string, expMailMax, expRcptMax, expRcptDomainMax int) {
|
||||
t.Helper()
|
||||
limits, mailmax, rcptMax, rcptDomainMax := parseLimits([]byte(s))
|
||||
if !reflect.DeepEqual(limits, expLimits) || mailmax != expMailMax || rcptMax != expRcptMax || rcptDomainMax != expRcptDomainMax {
|
||||
t.Errorf("bad limits, got %v %d %d %d, expected %v %d %d %d, for %q", limits, mailmax, rcptMax, rcptDomainMax, expLimits, expMailMax, expRcptMax, expRcptDomainMax, s)
|
||||
}
|
||||
}
|
||||
check(" unknown=a=b -_1oK=xY", map[string]string{"UNKNOWN": "a=b", "-_1OK": "xY"}, 0, 0, 0)
|
||||
check(" MAILMAX=123 OTHER=ignored RCPTDOMAINMAX=1 RCPTMAX=321", map[string]string{"MAILMAX": "123", "OTHER": "ignored", "RCPTDOMAINMAX": "1", "RCPTMAX": "321"}, 123, 321, 1)
|
||||
check(" MAILMAX=invalid", map[string]string{"MAILMAX": "invalid"}, 0, 0, 0)
|
||||
check(" invalid syntax", nil, 0, 0, 0)
|
||||
check(" DUP=1 DUP=2", nil, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Just a cert that appears valid. SMTP client will not verify anything about it
|
||||
// (that is opportunistic TLS for you, "better some than none"). Let's enjoy this
|
||||
// one moment where it makes life easier.
|
||||
|
Reference in New Issue
Block a user