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:
Mechiel Lukkien
2024-03-07 10:07:53 +01:00
parent 8550a5af45
commit 9e7d6b85b7
19 changed files with 1158 additions and 379 deletions

View File

@ -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"

View File

@ -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.