author | Alberto Bertogli
<albertito@blitiri.com.ar> 2016-10-01 22:05:26 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2016-10-09 23:51:04 UTC |
parent | 6dda2fff4b5e511972db2394576b431a19a6c491 |
internal/courier/smtp.go | +8 | -12 |
internal/smtp/smtp.go | +129 | -0 |
internal/smtp/smtp_test.go | +186 | -0 |
diff --git a/internal/courier/smtp.go b/internal/courier/smtp.go index 206bf9f..6cfed97 100644 --- a/internal/courier/smtp.go +++ b/internal/courier/smtp.go @@ -4,13 +4,13 @@ import ( "crypto/tls" "flag" "net" - "net/smtp" "time" "github.com/golang/glog" "golang.org/x/net/idna" "blitiri.com.ar/go/chasquid/internal/envelope" + "blitiri.com.ar/go/chasquid/internal/smtp" "blitiri.com.ar/go/chasquid/internal/trace" ) @@ -65,7 +65,11 @@ retry: // Issue an EHLO with a valid domain; otherwise, some servers like postfix // will complain. - if err = c.Hello(envelope.DomainOf(from)); err != nil { + fromDomain, err := idna.ToASCII(envelope.DomainOf(from)) + if err != nil { + return tr.Errorf("Sender domain not IDNA compliant: %v", err), true + } + if err = c.Hello(fromDomain); err != nil { return tr.Errorf("Error saying hello: %v", err), false } @@ -98,20 +102,12 @@ retry: tr.LazyPrintf("Insecure - not using TLS") } - // TODO: check if the errors we get back are transient or not. - // Go's smtp does not allow us to do this, so leave for when we do it - // ourselves. - // c.Mail will add the <> for us when the address is empty. if from == "<>" { from = "" } - if err = c.Mail(from); err != nil { - return tr.Errorf("MAIL %v", err), false - } - - if err = c.Rcpt(to); err != nil { - return tr.Errorf("RCPT TO %v", err), false + if err = c.MailAndRcpt(from, to); err != nil { + return tr.Errorf("MAIL+RCPT %v", err), false } w, err := c.Data() diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go new file mode 100644 index 0000000..f20c78e --- /dev/null +++ b/internal/smtp/smtp.go @@ -0,0 +1,129 @@ +// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC +// 5321. It extends net/smtp as follows: +// +// - Supports SMTPUTF8, via MailAndRcpt. +// +package smtp + +import ( + "net" + "net/smtp" + "net/textproto" + "unicode" + + "blitiri.com.ar/go/chasquid/internal/envelope" + + "golang.org/x/net/idna" +) + +// A Client represents a client connection to an SMTP server. +type Client struct { + *smtp.Client +} + +func NewClient(conn net.Conn, host string) (*Client, error) { + c, err := smtp.NewClient(conn, host) + if err != nil { + return nil, err + } + return &Client{c}, nil +} + +// cmd sends a command and returns the response over the text connection. +// Based on Go's method of the same name. +func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { + id, err := c.Text.Cmd(format, args...) + if err != nil { + return 0, "", err + } + c.Text.StartResponse(id) + defer c.Text.EndResponse(id) + + return c.Text.ReadResponse(expectCode) +} + +// MailAndRcpt issues MAIL FROM and RCPT TO commands, in sequence. +// It will check the addresses, decide if SMTPUTF8 is needed, and apply the +// necessary transformations. +func (c *Client) MailAndRcpt(from string, to string) error { + from, from_needs, err := c.prepareForSMTPUTF8(from) + if err != nil { + return err + } + + to, to_needs, err := c.prepareForSMTPUTF8(to) + if err != nil { + return err + } + smtputf8Needed := from_needs || to_needs + + cmdStr := "MAIL FROM:<%s>" + if ok, _ := c.Extension("8BITMIME"); ok { + cmdStr += " BODY=8BITMIME" + } + if smtputf8Needed { + cmdStr += " SMTPUTF8" + } + _, _, err = c.cmd(250, cmdStr, from) + if err != nil { + return err + } + + _, _, err = c.cmd(25, "RCPT TO:<%s>", to) + return err +} + +// prepareForSMTPUTF8 prepares the address for SMTPUTF8. +// It returns: +// - The address to use. It is based on addr, and possibly modified to make +// it not need the extension, if the server does not support it. +// - Whether the address needs the extension or not. +// - An error if the address needs the extension, but the client does not +// support it. +func (c *Client) prepareForSMTPUTF8(addr string) (string, bool, error) { + // ASCII address pass through. + if isASCII(addr) { + return addr, false, nil + } + + // Non-ASCII address also pass through if the server supports the + // extension. + // Note there's a chance the server wants the domain in IDNA anyway, but + // it could also require it to be UTF8. We assume that if it supports + // SMTPUTF8 then it knows what its doing. + if ok, _ := c.Extension("SMTPUTF8"); ok { + return addr, true, nil + } + + // Something is not ASCII, and the server does not support SMTPUTF8: + // - If it's the local part, there's no way out and is required. + // - If it's the domain, use IDNA. + user, domain := envelope.Split(addr) + + if !isASCII(user) { + return addr, true, &textproto.Error{599, + "local part is not ASCII but server does not support SMTPUTF8"} + } + + // If it's only the domain, convert to IDNA and move on. + domain, err := idna.ToASCII(domain) + if err != nil { + // The domain is not IDNA compliant, which is odd. + // Fail with a permanent error, not ideal but this should not + // happen. + return addr, true, &textproto.Error{599, + "non-ASCII domain is not IDNA safe"} + } + + return user + "@" + domain, false, nil +} + +// isASCII returns true if all the characters in s are ASCII, false otherwise. +func isASCII(s string) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + } + return true +} diff --git a/internal/smtp/smtp_test.go b/internal/smtp/smtp_test.go new file mode 100644 index 0000000..e6589bf --- /dev/null +++ b/internal/smtp/smtp_test.go @@ -0,0 +1,186 @@ +package smtp + +import ( + "bufio" + "bytes" + "net" + "net/smtp" + "net/textproto" + "strings" + "testing" + "time" +) + +func TestIsASCII(t *testing.T) { + cases := []struct { + str string + ascii bool + }{ + {"", true}, + {"<>", true}, + {"lalala", true}, + {"ñaca", false}, + {"año", false}, + } + for _, c := range cases { + if ascii := isASCII(c.str); ascii != c.ascii { + t.Errorf("%q: expected %v, got %v", c.str, c.ascii, ascii) + } + } +} + +func TestBasic(t *testing.T) { + fake, client := fakeDialog(`> EHLO a_test +< 250-server replies your hello +< 250-SIZE 35651584 +< 250-SMTPUTF8 +< 250-8BITMIME +< 250 HELP +> MAIL FROM:<from@from> BODY=8BITMIME +< 250 MAIL FROM is fine +> RCPT TO:<to@to> +< 250 RCPT TO is fine +`) + + c := &Client{ + Client: &smtp.Client{Text: textproto.NewConn(fake)}} + if err := c.Hello("a_test"); err != nil { + t.Fatalf("Hello failed: %v", err) + } + + if err := c.MailAndRcpt("from@from", "to@to"); err != nil { + t.Fatalf("MailAndRcpt failed: %v", err) + } + + cmds := fake.Client() + if client != cmds { + t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) + } +} + +func TestSMTPUTF8(t *testing.T) { + fake, client := fakeDialog(`> EHLO araña +< 250-chasquid replies your hello +< 250-SIZE 35651584 +< 250-SMTPUTF8 +< 250-8BITMIME +< 250 HELP +> MAIL FROM:<año@ñudo> BODY=8BITMIME SMTPUTF8 +< 250 MAIL FROM is fine +> RCPT TO:<ñaca@ñoño> +< 250 RCPT TO is fine +`) + + c := &Client{ + Client: &smtp.Client{Text: textproto.NewConn(fake)}} + if err := c.Hello("araña"); err != nil { + t.Fatalf("Hello failed: %v", err) + } + + if err := c.MailAndRcpt("año@ñudo", "ñaca@ñoño"); err != nil { + t.Fatalf("MailAndRcpt failed: %v\nDialog: %s", err, fake.Client()) + } + + cmds := fake.Client() + if client != cmds { + t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) + } +} + +func TestSMTPUTF8NotSupported(t *testing.T) { + fake, client := fakeDialog(`> EHLO araña +< 250-chasquid replies your hello +< 250-SIZE 35651584 +< 250-8BITMIME +< 250 HELP +`) + + c := &Client{ + Client: &smtp.Client{Text: textproto.NewConn(fake)}} + if err := c.Hello("araña"); err != nil { + t.Fatalf("Hello failed: %v", err) + } + + if err := c.MailAndRcpt("año@ñudo", "ñaca@ñoño"); err != nil { + terr, ok := err.(*textproto.Error) + if !ok || terr.Code != 599 { + t.Fatalf("MailAndRcpt failed with unexpected error: %v\nDialog: %s", + err, fake.Client()) + } + } + + cmds := fake.Client() + if client != cmds { + t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) + } +} + +func TestFallbackToIDNA(t *testing.T) { + fake, client := fakeDialog(`> EHLO araña +< 250-chasquid replies your hello +< 250-SIZE 35651584 +< 250-8BITMIME +< 250 HELP +> MAIL FROM:<gran@xn--udo-6ma> BODY=8BITMIME +< 250 MAIL FROM is fine +> RCPT TO:<alto@xn--oo-yjab> +< 250 RCPT TO is fine +`) + + c := &Client{ + Client: &smtp.Client{Text: textproto.NewConn(fake)}} + if err := c.Hello("araña"); err != nil { + t.Fatalf("Hello failed: %v", err) + } + + if err := c.MailAndRcpt("gran@ñudo", "alto@ñoño"); err != nil { + terr, ok := err.(*textproto.Error) + if !ok || terr.Code != 599 { + t.Fatalf("MailAndRcpt failed with unexpected error: %v\nDialog: %s", + err, fake.Client()) + } + } + + cmds := fake.Client() + if client != cmds { + t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) + } +} + +type faker struct { + buf *bytes.Buffer + *bufio.ReadWriter +} + +func (f faker) Close() error { return nil } +func (f faker) LocalAddr() net.Addr { return nil } +func (f faker) RemoteAddr() net.Addr { return nil } +func (f faker) SetDeadline(time.Time) error { return nil } +func (f faker) SetReadDeadline(time.Time) error { return nil } +func (f faker) SetWriteDeadline(time.Time) error { return nil } +func (f faker) Client() string { + f.ReadWriter.Writer.Flush() + return f.buf.String() +} + +// Takes a dialog, returns the corresponding faker and expected client +// messages. Ideally we would check this interactively, and it's not that +// difficult, but this is good enough for now. +func fakeDialog(dialog string) (faker, string) { + var client, server string + + for _, l := range strings.Split(dialog, "\n") { + if strings.HasPrefix(l, "< ") { + server += l[2:] + "\r\n" + } else if strings.HasPrefix(l, "> ") { + client += l[2:] + "\r\n" + } + } + + fake := faker{} + fake.buf = &bytes.Buffer{} + fake.ReadWriter = bufio.NewReadWriter( + bufio.NewReader(strings.NewReader(server)), bufio.NewWriter(fake.buf)) + + return fake, client +}