7 cryptorand "crypto/rand"
24 "github.com/mjl-/mox/dns"
25 "github.com/mjl-/mox/mlog"
26 "github.com/mjl-/mox/sasl"
27 "github.com/mjl-/mox/scram"
28 "github.com/mjl-/mox/smtp"
31var zerohost dns.Domain
32var localhost = dns.Domain{ASCII: "localhost"}
34func TestClient(t *testing.T) {
35 ctx := context.Background()
36 log := mlog.New("smtpclient")
38 mlog.SetConfig(map[string]mlog.Level{"": mlog.LevelTrace})
53 tlsHostname dns.Domain
57 auths []string // Allowed mechanisms.
59 nodeliver bool // For server, whether client will attempt a delivery.
62 // Make fake cert, and make it trusted.
63 cert := fakeCert(t, false)
64 roots := x509.NewCertPool()
65 roots.AddCert(cert.Leaf)
66 tlsConfig := tls.Config{
67 Certificates: []tls.Certificate{cert},
70 test := func(msg string, opts options, auths []sasl.Client, expClientErr, expDeliverErr, expServerErr error) {
73 if opts.tlsMode == "" {
74 opts.tlsMode = TLSOpportunistic
77 clientConn, serverConn := net.Pipe()
78 defer serverConn.Close()
80 result := make(chan error, 2)
85 if x != nil && x != "stop" {
89 fail := func(format string, args ...any) {
90 err := fmt.Errorf("server: %w", fmt.Errorf(format, args...))
91 if err != nil && expServerErr != nil && (errors.Is(err, expServerErr) || errors.As(err, reflect.New(reflect.ValueOf(expServerErr).Type()).Interface())) {
98 br := bufio.NewReader(serverConn)
99 readline := func(prefix string) string {
100 s, err := br.ReadString('\n')
102 fail("expected command: %v", err)
104 if !strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) {
105 fail("expected command %q, got: %s", prefix, s)
108 return strings.TrimSuffix(s, "\r\n")
110 writeline := func(s string) {
111 fmt.Fprintf(serverConn, "%s\r\n", s)
116 ehlo := true // Initially we expect EHLO.
121 writeline("250 mox.example")
128 // Client will try again with HELO.
129 writeline("500 bad syntax")
135 writeline("250-mox.example")
137 writeline("250-PIPELINING")
139 if opts.maxSize > 0 {
140 writeline(fmt.Sprintf("250-SIZE %d", opts.maxSize))
143 writeline("250-ENHANCEDSTATUSCODES")
145 if opts.starttls && !haveTLS {
146 writeline("250-STARTTLS")
148 if opts.eightbitmime {
149 writeline("250-8BITMIME")
152 writeline("250-SMTPUTF8")
154 if opts.requiretls && haveTLS {
155 writeline("250-REQUIRETLS")
157 if opts.auths != nil {
158 writeline("250-AUTH " + strings.Join(opts.auths, " "))
160 writeline("250 UNKNOWN") // To be ignored.
163 writeline("220 mox.example ESMTP test")
170 tlsConn := tls.Server(serverConn, &tlsConfig)
171 nctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
173 err := tlsConn.HandshakeContext(nctx)
175 fail("tls handshake: %w", err)
178 br = bufio.NewReader(serverConn)
184 if opts.auths != nil {
185 more := readline("AUTH ")
186 t := strings.SplitN(more, " ", 2)
189 writeline("235 2.7.0 auth ok")
191 writeline("334 " + base64.StdEncoding.EncodeToString([]byte("<123.1234@host>")))
192 readline("") // Proof
193 writeline("235 2.7.0 auth ok")
194 case "SCRAM-SHA-1", "SCRAM-SHA-256":
195 // Cannot fake/hardcode scram interactions.
196 var h func() hash.Hash
197 salt := scram.MakeRandom()
199 if t[0] == "SCRAM-SHA-1" {
201 iterations = 2 * 4096
206 saltedPassword := scram.SaltPassword(h, "test", salt, iterations)
208 clientFirst, err := base64.StdEncoding.DecodeString(t[1])
210 fail("bad base64: %w", err)
212 s, err := scram.NewServer(h, clientFirst)
214 fail("scram new server: %w", err)
216 serverFirst, err := s.ServerFirst(iterations, salt)
218 fail("scram server first: %w", err)
220 writeline("334 " + base64.StdEncoding.EncodeToString([]byte(serverFirst)))
222 xclientFinal := readline("")
223 clientFinal, err := base64.StdEncoding.DecodeString(xclientFinal)
225 fail("bad base64: %w", err)
227 serverFinal, err := s.Finish([]byte(clientFinal), saltedPassword)
229 fail("scram finish: %w", err)
231 writeline("334 " + base64.StdEncoding.EncodeToString([]byte(serverFinal)))
233 writeline("235 2.7.0 auth ok")
235 writeline("501 unknown mechanism")
239 if expClientErr == nil && !opts.nodeliver {
240 readline("MAIL FROM:")
245 writeline("354 continue")
246 reader := smtp.NewDataReader(br)
247 io.Copy(io.Discard, reader)
250 if expDeliverErr == nil {
254 readline("MAIL FROM:")
259 writeline("354 continue")
260 reader = smtp.NewDataReader(br)
261 io.Copy(io.Discard, reader)
271 // todo: should abort tests more properly. on client failures, we may be left with hanging test.
275 if x != nil && x != "stop" {
279 fail := func(format string, args ...any) {
280 err := fmt.Errorf("client: %w", fmt.Errorf(format, args...))
284 c, err := New(ctx, log, clientConn, opts.tlsMode, opts.tlsPKIX, localhost, opts.tlsHostname, Opts{Auth: auths, RootCAs: opts.roots})
285 if (err == nil) != (expClientErr == nil) || err != nil && !errors.As(err, reflect.New(reflect.ValueOf(expClientErr).Type()).Interface()) && !errors.Is(err, expClientErr) {
286 fail("new client: got err %v, expected %#v", err, expClientErr)
292 err = c.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
293 if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
294 fail("first deliver: got err %v, expected %v", err, expDeliverErr)
299 fail("reset: %v", err)
301 err = c.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
302 if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
303 fail("second deliver: got err %v, expected %v", err, expDeliverErr)
308 fail("close client: %v", err)
314 for i := 0; i < 2; i++ {
317 errs = append(errs, err)
325 msg := strings.ReplaceAll(`From: <postmaster@mox.example>
342 tlsMode: TLSRequiredStartTLS,
345 tlsHostname: dns.Domain{ASCII: "mox.example"},
348 needsrequiretls: true,
351 test(msg, options{}, nil, nil, nil, nil)
352 test(msg, allopts, nil, nil, nil, nil)
353 test(msg, options{ehlo: true, eightbitmime: true}, nil, nil, nil, nil)
354 test(msg, options{ehlo: true, eightbitmime: false, need8bitmime: true, nodeliver: true}, nil, nil, Err8bitmimeUnsupported, nil)
355 test(msg, options{ehlo: true, smtputf8: false, needsmtputf8: true, nodeliver: true}, nil, nil, ErrSMTPUTF8Unsupported, nil)
356 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.
357 test(msg, options{ehlo: true, maxSize: len(msg) - 1, nodeliver: true}, nil, nil, ErrSize, nil)
358 test(msg, options{ehlo: true, auths: []string{"PLAIN"}}, []sasl.Client{sasl.NewClientPlain("test", "test")}, nil, nil, nil)
359 test(msg, options{ehlo: true, auths: []string{"CRAM-MD5"}}, []sasl.Client{sasl.NewClientCRAMMD5("test", "test")}, nil, nil, nil)
360 test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-1"}}, []sasl.Client{sasl.NewClientSCRAMSHA1("test", "test")}, nil, nil, nil)
361 test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-256"}}, []sasl.Client{sasl.NewClientSCRAMSHA256("test", "test")}, nil, nil, nil)
362 // todo: add tests for failing authentication, also at various stages in SCRAM
363 test(msg, options{ehlo: true, requiretls: false, needsrequiretls: true, nodeliver: true}, nil, nil, ErrRequireTLSUnsupported, nil)
365 // Set an expired certificate. For non-strict TLS, we should still accept it.
367 cert = fakeCert(t, true)
368 roots = x509.NewCertPool()
369 roots.AddCert(cert.Leaf)
370 tlsConfig = tls.Config{
371 Certificates: []tls.Certificate{cert},
373 test(msg, options{ehlo: true, starttls: true, roots: roots}, nil, nil, nil, nil)
375 // Again with empty cert pool so it isn't trusted in any way.
376 roots = x509.NewCertPool()
377 tlsConfig = tls.Config{
378 Certificates: []tls.Certificate{cert},
380 test(msg, options{ehlo: true, starttls: true, roots: roots}, nil, nil, nil, nil)
383func TestErrors(t *testing.T) {
384 ctx := context.Background()
388 run(t, func(s xserver) {
389 s.writeline("bogus") // Invalid, should be "220 <hostname>".
390 }, func(conn net.Conn) {
391 _, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
393 if err == nil || !errors.Is(err, ErrProtocol) || !errors.As(err, &xerr) || xerr.Permanent {
394 panic(fmt.Errorf("got %#v, expected ErrProtocol without Permanent", err))
398 // Server just closes connection.
399 run(t, func(s xserver) {
401 }, func(conn net.Conn) {
402 _, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
404 if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) || !errors.As(err, &xerr) || xerr.Permanent {
405 panic(fmt.Errorf("got %#v (%v), expected ErrUnexpectedEOF without Permanent", err, err))
409 // Server does not want to speak SMTP.
410 run(t, func(s xserver) {
411 s.writeline("521 not accepting connections")
412 }, func(conn net.Conn) {
413 _, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
415 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
416 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
420 // Server has invalid code in greeting.
421 run(t, func(s xserver) {
422 s.writeline("2200 mox.example") // Invalid, too many digits.
423 }, func(conn net.Conn) {
424 _, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
426 if err == nil || !errors.Is(err, ErrProtocol) || !errors.As(err, &xerr) || xerr.Permanent {
427 panic(fmt.Errorf("got %#v, expected ErrProtocol without Permanent", err))
431 // Server sends multiline response, but with different codes.
432 run(t, func(s xserver) {
433 s.writeline("220 mox.example")
435 s.writeline("250-mox.example")
436 s.writeline("500 different code") // Invalid.
437 }, func(conn net.Conn) {
438 _, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
440 if err == nil || !errors.Is(err, ErrProtocol) || !errors.As(err, &xerr) || xerr.Permanent {
441 panic(fmt.Errorf("got %#v, expected ErrProtocol without Permanent", err))
445 // Server permanently refuses MAIL FROM.
446 run(t, func(s xserver) {
447 s.writeline("220 mox.example")
449 s.writeline("250-mox.example")
450 s.writeline("250 ENHANCEDSTATUSCODES")
451 s.readline("MAIL FROM:")
452 s.writeline("550 5.7.0 not allowed")
453 }, func(conn net.Conn) {
454 c, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
459 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
461 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
462 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
466 // Server temporarily refuses MAIL FROM.
467 run(t, func(s xserver) {
468 s.writeline("220 mox.example")
470 s.writeline("250 mox.example")
471 s.readline("MAIL FROM:")
472 s.writeline("451 bad sender")
473 }, func(conn net.Conn) {
474 c, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
479 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
481 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
482 panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err))
486 // Server temporarily refuses RCPT TO.
487 run(t, func(s xserver) {
488 s.writeline("220 mox.example")
490 s.writeline("250 mox.example")
491 s.readline("MAIL FROM:")
492 s.writeline("250 ok")
493 s.readline("RCPT TO:")
495 }, func(conn net.Conn) {
496 c, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
501 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
503 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
504 panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err))
508 // Server permanently refuses DATA.
509 run(t, func(s xserver) {
510 s.writeline("220 mox.example")
512 s.writeline("250 mox.example")
513 s.readline("MAIL FROM:")
514 s.writeline("250 ok")
515 s.readline("RCPT TO:")
516 s.writeline("250 ok")
518 s.writeline("550 no!")
519 }, func(conn net.Conn) {
520 c, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
525 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
527 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
528 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
532 // TLS is required, so we attempt it regardless of whether it is advertised.
533 run(t, func(s xserver) {
534 s.writeline("220 mox.example")
536 s.writeline("250 mox.example")
537 s.readline("STARTTLS")
538 s.writeline("502 command not implemented")
539 }, func(conn net.Conn) {
540 _, err := New(ctx, log, conn, TLSRequiredStartTLS, true, localhost, dns.Domain{ASCII: "mox.example"}, Opts{})
542 if err == nil || !errors.Is(err, ErrTLS) || !errors.As(err, &xerr) || !xerr.Permanent {
543 panic(fmt.Errorf("got %#v, expected ErrTLS with Permanent", err))
547 // If TLS is available, but we don't want to use it, client should skip it.
548 run(t, func(s xserver) {
549 s.writeline("220 mox.example")
551 s.writeline("250-mox.example")
552 s.writeline("250 STARTTLS")
553 s.readline("MAIL FROM:")
554 s.writeline("451 enough")
555 }, func(conn net.Conn) {
556 c, err := New(ctx, log, conn, TLSSkip, false, localhost, dns.Domain{ASCII: "mox.example"}, Opts{})
561 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
563 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
564 panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err))
568 // A transaction is aborted. If we try another one, we should send a RSET.
569 run(t, func(s xserver) {
570 s.writeline("220 mox.example")
572 s.writeline("250 mox.example")
573 s.readline("MAIL FROM:")
574 s.writeline("250 ok")
575 s.readline("RCPT TO:")
576 s.writeline("451 not now")
578 s.writeline("250 ok")
579 s.readline("MAIL FROM:")
580 s.writeline("250 ok")
581 s.readline("RCPT TO:")
582 s.writeline("250 ok")
584 s.writeline("550 not now")
585 }, func(conn net.Conn) {
586 c, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
592 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
594 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
595 panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err))
599 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
600 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
601 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
605 // Remote closes connection after 550 response to MAIL FROM in pipelined
606 // connection. Should result in permanent error, not temporary read error.
607 // E.g. outlook.com that has your IP blocklisted.
608 run(t, func(s xserver) {
609 s.writeline("220 mox.example")
611 s.writeline("250-mox.example")
612 s.writeline("250 PIPELINING")
613 s.readline("MAIL FROM:")
614 s.writeline("550 ok")
615 }, func(conn net.Conn) {
616 c, err := New(ctx, log, conn, TLSOpportunistic, false, localhost, zerohost, Opts{})
622 err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
624 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
625 panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
635func (s xserver) check(err error, msg string) {
637 panic(fmt.Errorf("%s: %w", msg, err))
641func (s xserver) errorf(format string, args ...any) {
642 panic(fmt.Errorf(format, args...))
645func (s xserver) writeline(line string) {
646 _, err := fmt.Fprintf(s.conn, "%s\r\n", line)
647 s.check(err, "write")
650func (s xserver) readline(prefix string) {
651 line, err := s.br.ReadString('\n')
652 s.check(err, "reading command")
653 if !strings.HasPrefix(strings.ToLower(line), strings.ToLower(prefix)) {
654 s.errorf("expected command %q, got: %s", prefix, line)
658func run(t *testing.T, server func(s xserver), client func(conn net.Conn)) {
661 result := make(chan error, 2)
662 clientConn, serverConn := net.Pipe()
668 result <- fmt.Errorf("server: %v", x)
673 server(xserver{serverConn, bufio.NewReader(serverConn)})
680 result <- fmt.Errorf("client: %v", x)
688 for i := 0; i < 2; i++ {
691 errs = append(errs, err)
695 t.Fatalf("errors: %v", errs)
699// Just a cert that appears valid. SMTP client will not verify anything about it
700// (that is opportunistic TLS for you, "better some than none"). Let's enjoy this
701// one moment where it makes life easier.
702func fakeCert(t *testing.T, expired bool) tls.Certificate {
703 notAfter := time.Now()
705 notAfter = notAfter.Add(-time.Hour)
707 notAfter = notAfter.Add(time.Hour)
710 privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real!
711 template := &x509.Certificate{
712 SerialNumber: big.NewInt(1), // Required field...
713 DNSNames: []string{"mox.example"},
714 NotBefore: time.Now().Add(-time.Hour),
717 localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
719 t.Fatalf("making certificate: %s", err)
721 cert, err := x509.ParseCertificate(localCertBuf)
723 t.Fatalf("parsing generated certificate: %s", err)
725 c := tls.Certificate{
726 Certificate: [][]byte{localCertBuf},