7 cryptorand "crypto/rand"
24 "golang.org/x/exp/slog"
26 "github.com/mjl-/mox/dns"
27 "github.com/mjl-/mox/mlog"
28 "github.com/mjl-/mox/sasl"
29 "github.com/mjl-/mox/scram"
30 "github.com/mjl-/mox/smtp"
33var zerohost dns.Domain
34var localhost = dns.Domain{ASCII: "localhost"}
36func TestClient(t *testing.T) {
37 ctx := context.Background()
38 log := mlog.New("smtpclient", nil)
40 mlog.SetConfig(map[string]slog.Level{"": mlog.LevelTrace})
55 tlsHostname dns.Domain
59 auths []string // Allowed mechanisms.
61 nodeliver bool // For server, whether client will attempt a delivery.
64 // Make fake cert, and make it trusted.
65 cert := fakeCert(t, false)
66 roots := x509.NewCertPool()
67 roots.AddCert(cert.Leaf)
68 tlsConfig := tls.Config{
69 Certificates: []tls.Certificate{cert},
72 test := func(msg string, opts options, auth func(l []string, cs *tls.ConnectionState) (sasl.Client, error), expClientErr, expDeliverErr, expServerErr error) {
75 if opts.tlsMode == "" {
76 opts.tlsMode = TLSOpportunistic
79 clientConn, serverConn := net.Pipe()
80 defer serverConn.Close()
82 result := make(chan error, 2)
87 if x != nil && x != "stop" {
91 fail := func(format string, args ...any) {
92 err := fmt.Errorf("server: %w", fmt.Errorf(format, args...))
93 if err != nil && expServerErr != nil && (errors.Is(err, expServerErr) || errors.As(err, reflect.New(reflect.ValueOf(expServerErr).Type()).Interface())) {
100 br := bufio.NewReader(serverConn)
101 readline := func(prefix string) string {
102 s, err := br.ReadString('\n')
104 fail("expected command: %v", err)
106 if !strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) {
107 fail("expected command %q, got: %s", prefix, s)
110 return strings.TrimSuffix(s, "\r\n")
112 writeline := func(s string) {
113 fmt.Fprintf(serverConn, "%s\r\n", s)
118 ehlo := true // Initially we expect EHLO.
123 writeline("250 mox.example")
130 // Client will try again with HELO.
131 writeline("500 bad syntax")
137 writeline("250-mox.example")
139 writeline("250-PIPELINING")
141 if opts.maxSize > 0 {
142 writeline(fmt.Sprintf("250-SIZE %d", opts.maxSize))
145 writeline("250-ENHANCEDSTATUSCODES")
147 if opts.starttls && !haveTLS {
148 writeline("250-STARTTLS")
150 if opts.eightbitmime {
151 writeline("250-8BITMIME")
154 writeline("250-SMTPUTF8")
156 if opts.requiretls && haveTLS {
157 writeline("250-REQUIRETLS")
159 if opts.auths != nil {
160 writeline("250-AUTH " + strings.Join(opts.auths, " "))
162 writeline("250 UNKNOWN") // To be ignored.
165 writeline("220 mox.example ESMTP test")
172 tlsConn := tls.Server(serverConn, &tlsConfig)
173 nctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
175 err := tlsConn.HandshakeContext(nctx)
177 fail("tls handshake: %w", err)
180 br = bufio.NewReader(serverConn)
186 if opts.auths != nil {
187 more := readline("AUTH ")
188 t := strings.SplitN(more, " ", 2)
191 writeline("235 2.7.0 auth ok")
193 writeline("334 " + base64.StdEncoding.EncodeToString([]byte("<123.1234@host>")))
194 readline("") // Proof
195 writeline("235 2.7.0 auth ok")
196 case "SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-1":
197 // Cannot fake/hardcode scram interactions.
198 var h func() hash.Hash
199 salt := scram.MakeRandom()
202 case "SCRAM-SHA-1-PLUS", "SCRAM-SHA-1":
204 iterations = 2 * 4096
205 case "SCRAM-SHA-256-PLUS", "SCRAM-SHA-256":
209 panic("missing case for scram")
211 var cs *tls.ConnectionState
212 if strings.HasSuffix(t[0], "-PLUS") {
214 writeline("501 scram plus without tls not possible")
220 xcs := serverConn.(*tls.Conn).ConnectionState()
223 saltedPassword := scram.SaltPassword(h, "test", salt, iterations)
225 clientFirst, err := base64.StdEncoding.DecodeString(t[1])
227 fail("bad base64: %w", err)
229 s, err := scram.NewServer(h, clientFirst, cs, cs != nil)
231 fail("scram new server: %w", err)
233 serverFirst, err := s.ServerFirst(iterations, salt)
235 fail("scram server first: %w", err)
237 writeline("334 " + base64.StdEncoding.EncodeToString([]byte(serverFirst)))
239 xclientFinal := readline("")
240 clientFinal, err := base64.StdEncoding.DecodeString(xclientFinal)
242 fail("bad base64: %w", err)
244 serverFinal, err := s.Finish([]byte(clientFinal), saltedPassword)
246 fail("scram finish: %w", err)
248 writeline("334 " + base64.StdEncoding.EncodeToString([]byte(serverFinal)))
250 writeline("235 2.7.0 auth ok")
252 writeline("501 unknown mechanism")
256 if expClientErr == nil && !opts.nodeliver {
257 readline("MAIL FROM:")
262 writeline("354 continue")
263 reader := smtp.NewDataReader(br)
264 io.Copy(io.Discard, reader)
267 if expDeliverErr == nil {
271 readline("MAIL FROM:")
276 writeline("354 continue")
277 reader = smtp.NewDataReader(br)
278 io.Copy(io.Discard, reader)
288 // todo: should abort tests more properly. on client failures, we may be left with hanging test.
292 if x != nil && x != "stop" {
296 fail := func(format string, args ...any) {
297 err := fmt.Errorf("client: %w", fmt.Errorf(format, args...))
301 client, err := New(ctx, log.Logger, clientConn, opts.tlsMode, opts.tlsPKIX, localhost, opts.tlsHostname, Opts{Auth: auth, RootCAs: opts.roots})
302 if (err == nil) != (expClientErr == nil) || err != nil && !errors.As(err, reflect.New(reflect.ValueOf(expClientErr).Type()).Interface()) && !errors.Is(err, expClientErr) {
303 fail("new client: got err %v, expected %#v", err, expClientErr)
309 err = client.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
310 if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
311 fail("first deliver: got err %v, expected %v", err, expDeliverErr)
316 fail("reset: %v", err)
318 err = client.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
319 if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
320 fail("second deliver: got err %v, expected %v", err, expDeliverErr)
325 fail("close client: %v", err)
331 for i := 0; i < 2; i++ {
334 errs = append(errs, err)
342 msg := strings.ReplaceAll(`From: <postmaster@mox.example>
359 tlsMode: TLSRequiredStartTLS,
362 tlsHostname: dns.Domain{ASCII: "mox.example"},
365 needsrequiretls: true,
368 test(msg, options{}, nil, nil, nil, nil)
369 test(msg, allopts, nil, nil, nil, nil)
370 test(msg, options{ehlo: true, eightbitmime: true}, nil, nil, nil, nil)
371 test(msg, options{ehlo: true, eightbitmime: false, need8bitmime: true, nodeliver: true}, nil, nil, Err8bitmimeUnsupported, nil)
372 test(msg, options{ehlo: true, smtputf8: false, needsmtputf8: true, nodeliver: true}, nil, nil, ErrSMTPUTF8Unsupported, nil)
373 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.
374 test(msg, options{ehlo: true, maxSize: len(msg) - 1, nodeliver: true}, nil, nil, ErrSize, nil)
376 authPlain := func(l []string, cs *tls.ConnectionState) (sasl.Client, error) {
377 return sasl.NewClientPlain("test", "test"), nil
379 test(msg, options{ehlo: true, auths: []string{"PLAIN"}}, authPlain, nil, nil, nil)
381 authCRAMMD5 := func(l []string, cs *tls.ConnectionState) (sasl.Client, error) {
382 return sasl.NewClientCRAMMD5("test", "test"), nil
384 test(msg, options{ehlo: true, auths: []string{"CRAM-MD5"}}, authCRAMMD5, nil, nil, nil)
386 // todo: add tests for failing authentication, also at various stages in SCRAM
388 authSCRAMSHA1 := func(l []string, cs *tls.ConnectionState) (sasl.Client, error) {
389 return sasl.NewClientSCRAMSHA1("test", "test", false), nil
391 test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-1"}}, authSCRAMSHA1, nil, nil, nil)
393 authSCRAMSHA1PLUS := func(l []string, cs *tls.ConnectionState) (sasl.Client, error) {
394 return sasl.NewClientSCRAMSHA1PLUS("test", "test", *cs), nil
396 test(msg, options{ehlo: true, starttls: true, auths: []string{"SCRAM-SHA-1-PLUS"}}, authSCRAMSHA1PLUS, nil, nil, nil)
398 authSCRAMSHA256 := func(l []string, cs *tls.ConnectionState) (sasl.Client, error) {
399 return sasl.NewClientSCRAMSHA256("test", "test", false), nil
401 test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-256"}}, authSCRAMSHA256, nil, nil, nil)
403 authSCRAMSHA256PLUS := func(l []string, cs *tls.ConnectionState) (sasl.Client, error) {
404 return sasl.NewClientSCRAMSHA256PLUS("test", "test", *cs), nil
406 test(msg, options{ehlo: true, starttls: true, auths: []string{"SCRAM-SHA-256-PLUS"}}, authSCRAMSHA256PLUS, nil, nil, nil)
408 test(msg, options{ehlo: true, requiretls: false, needsrequiretls: true, nodeliver: true}, nil, nil, ErrRequireTLSUnsupported, nil)
410 // Set an expired certificate. For non-strict TLS, we should still accept it.
412 cert = fakeCert(t, true)
413 roots = x509.NewCertPool()
414 roots.AddCert(cert.Leaf)
415 tlsConfig = tls.Config{
416 Certificates: []tls.Certificate{cert},
418 test(msg, options{ehlo: true, starttls: true, roots: roots}, nil, nil, nil, nil)
420 // Again with empty cert pool so it isn't trusted in any way.
421 roots = x509.NewCertPool()
422 tlsConfig = tls.Config{
423 Certificates: []tls.Certificate{cert},
425 test(msg, options{ehlo: true, starttls: true, roots: roots}, nil, nil, nil, nil)
428func TestErrors(t *testing.T) {
429 ctx := context.Background()
430 log := mlog.New("smtpclient", nil)
433 run(t, func(s xserver) {
434 s.writeline("bogus") // Invalid, should be "220 <hostname>".
435 }, func(conn net.Conn) {
436 _, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
438 if err == nil || !errors.Is(err, ErrProtocol) || !errors.As(err, &xerr) || xerr.Permanent {
439 panic(fmt.Errorf("got %#v, expected ErrProtocol without Permanent", err))
443 // Server just closes connection.
444 run(t, func(s xserver) {
446 }, func(conn net.Conn) {
447 _, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
449 if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) || !errors.As(err, &xerr) || xerr.Permanent {
450 panic(fmt.Errorf("got %#v (%v), expected ErrUnexpectedEOF without Permanent", err, err))
454 // Server does not want to speak SMTP.
455 run(t, func(s xserver) {
456 s.writeline("521 not accepting connections")
457 }, func(conn net.Conn) {
458 _, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
460 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
461 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
465 // Server has invalid code in greeting.
466 run(t, func(s xserver) {
467 s.writeline("2200 mox.example") // Invalid, too many digits.
468 }, func(conn net.Conn) {
469 _, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
471 if err == nil || !errors.Is(err, ErrProtocol) || !errors.As(err, &xerr) || xerr.Permanent {
472 panic(fmt.Errorf("got %#v, expected ErrProtocol without Permanent", err))
476 // Server sends multiline response, but with different codes.
477 run(t, func(s xserver) {
478 s.writeline("220 mox.example")
480 s.writeline("250-mox.example")
481 s.writeline("500 different code") // Invalid.
482 }, func(conn net.Conn) {
483 _, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
485 if err == nil || !errors.Is(err, ErrProtocol) || !errors.As(err, &xerr) || xerr.Permanent {
486 panic(fmt.Errorf("got %#v, expected ErrProtocol without Permanent", err))
490 // Server permanently refuses MAIL FROM.
491 run(t, func(s xserver) {
492 s.writeline("220 mox.example")
494 s.writeline("250-mox.example")
495 s.writeline("250 ENHANCEDSTATUSCODES")
496 s.readline("MAIL FROM:")
497 s.writeline("550 5.7.0 not allowed")
498 }, func(conn net.Conn) {
499 c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
504 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
506 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
507 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
511 // Server temporarily refuses MAIL FROM.
512 run(t, func(s xserver) {
513 s.writeline("220 mox.example")
515 s.writeline("250 mox.example")
516 s.readline("MAIL FROM:")
517 s.writeline("451 bad sender")
518 }, func(conn net.Conn) {
519 c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
524 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
526 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
527 panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err))
531 // Server temporarily refuses RCPT TO.
532 run(t, func(s xserver) {
533 s.writeline("220 mox.example")
535 s.writeline("250 mox.example")
536 s.readline("MAIL FROM:")
537 s.writeline("250 ok")
538 s.readline("RCPT TO:")
540 }, func(conn net.Conn) {
541 c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
546 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
548 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
549 panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err))
553 // Server permanently refuses DATA.
554 run(t, func(s xserver) {
555 s.writeline("220 mox.example")
557 s.writeline("250 mox.example")
558 s.readline("MAIL FROM:")
559 s.writeline("250 ok")
560 s.readline("RCPT TO:")
561 s.writeline("250 ok")
563 s.writeline("550 no!")
564 }, func(conn net.Conn) {
565 c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
570 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
572 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
573 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
577 // TLS is required, so we attempt it regardless of whether it is advertised.
578 run(t, func(s xserver) {
579 s.writeline("220 mox.example")
581 s.writeline("250 mox.example")
582 s.readline("STARTTLS")
583 s.writeline("502 command not implemented")
584 }, func(conn net.Conn) {
585 _, err := New(ctx, log.Logger, conn, TLSRequiredStartTLS, true, localhost, dns.Domain{ASCII: "mox.example"}, Opts{})
587 if err == nil || !errors.Is(err, ErrTLS) || !errors.As(err, &xerr) || !xerr.Permanent {
588 panic(fmt.Errorf("got %#v, expected ErrTLS with Permanent", err))
592 // If TLS is available, but we don't want to use it, client should skip it.
593 run(t, func(s xserver) {
594 s.writeline("220 mox.example")
596 s.writeline("250-mox.example")
597 s.writeline("250 STARTTLS")
598 s.readline("MAIL FROM:")
599 s.writeline("451 enough")
600 }, func(conn net.Conn) {
601 c, err := New(ctx, log.Logger, conn, TLSSkip, false, localhost, dns.Domain{ASCII: "mox.example"}, Opts{})
606 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
608 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
609 panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err))
613 // A transaction is aborted. If we try another one, we should send a RSET.
614 run(t, func(s xserver) {
615 s.writeline("220 mox.example")
617 s.writeline("250 mox.example")
618 s.readline("MAIL FROM:")
619 s.writeline("250 ok")
620 s.readline("RCPT TO:")
621 s.writeline("451 not now")
623 s.writeline("250 ok")
624 s.readline("MAIL FROM:")
625 s.writeline("250 ok")
626 s.readline("RCPT TO:")
627 s.writeline("250 ok")
629 s.writeline("550 not now")
630 }, func(conn net.Conn) {
631 c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
637 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
639 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
640 panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err))
644 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
645 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
646 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
650 // Remote closes connection after 550 response to MAIL FROM in pipelined
651 // connection. Should result in permanent error, not temporary read error.
652 // E.g. outlook.com that has your IP blocklisted.
653 run(t, func(s xserver) {
654 s.writeline("220 mox.example")
656 s.writeline("250-mox.example")
657 s.writeline("250 PIPELINING")
658 s.readline("MAIL FROM:")
659 s.writeline("550 ok")
660 }, func(conn net.Conn) {
661 c, err := New(ctx, log.Logger, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
667 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
669 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
670 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
680func (s xserver) check(err error, msg string) {
682 panic(fmt.Errorf("%s: %w", msg, err))
686func (s xserver) errorf(format string, args ...any) {
687 panic(fmt.Errorf(format, args...))
690func (s xserver) writeline(line string) {
691 _, err := fmt.Fprintf(s.conn, "%s\r\n", line)
692 s.check(err, "write")
695func (s xserver) readline(prefix string) {
696 line, err := s.br.ReadString('\n')
697 s.check(err, "reading command")
698 if !strings.HasPrefix(strings.ToLower(line), strings.ToLower(prefix)) {
699 s.errorf("expected command %q, got: %s", prefix, line)
703func run(t *testing.T, server func(s xserver), client func(conn net.Conn)) {
706 result := make(chan error, 2)
707 clientConn, serverConn := net.Pipe()
713 result <- fmt.Errorf("server: %v", x)
718 server(xserver{serverConn, bufio.NewReader(serverConn)})
725 result <- fmt.Errorf("client: %v", x)
733 for i := 0; i < 2; i++ {
736 errs = append(errs, err)
740 t.Fatalf("errors: %v", errs)
744// Just a cert that appears valid. SMTP client will not verify anything about it
745// (that is opportunistic TLS for you, "better some than none"). Let's enjoy this
746// one moment where it makes life easier.
747func fakeCert(t *testing.T, expired bool) tls.Certificate {
748 notAfter := time.Now()
750 notAfter = notAfter.Add(-time.Hour)
752 notAfter = notAfter.Add(time.Hour)
755 privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real!
756 template := &x509.Certificate{
757 SerialNumber: big.NewInt(1), // Required field...
758 DNSNames: []string{"mox.example"},
759 NotBefore: time.Now().Add(-time.Hour),
762 localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
764 t.Fatalf("making certificate: %s", err)
766 cert, err := x509.ParseCertificate(localCertBuf)
768 t.Fatalf("parsing generated certificate: %s", err)
770 c := tls.Certificate{
771 Certificate: [][]byte{localCertBuf},