]> Cypherpunks repositories - gostls13.git/commitdiff
smtp: new package
authorEvan Shaw <chickencha@gmail.com>
Thu, 14 Oct 2010 02:07:28 +0000 (22:07 -0400)
committerRuss Cox <rsc@golang.org>
Thu, 14 Oct 2010 02:07:28 +0000 (22:07 -0400)
R=rsc, iant, agl
CC=golang-dev
https://golang.org/cl/2052042

src/pkg/Makefile
src/pkg/smtp/Makefile [new file with mode: 0644]
src/pkg/smtp/auth.go [new file with mode: 0644]
src/pkg/smtp/smtp.go [new file with mode: 0644]
src/pkg/smtp/smtp_test.go [new file with mode: 0644]

index c250fe9e70d0483885ae6a97dc87d14a842a11c3..3bde747d9dc31957c53db4a6e455911768264bac 100644 (file)
@@ -109,6 +109,7 @@ DIRS=\
        runtime\
        runtime/pprof\
        scanner\
+       smtp\
        sort\
        strconv\
        strings\
diff --git a/src/pkg/smtp/Makefile b/src/pkg/smtp/Makefile
new file mode 100644 (file)
index 0000000..0e7d8d5
--- /dev/null
@@ -0,0 +1,12 @@
+# Copyright 2010 The Go Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+include ../../Make.inc
+
+TARG=smtp
+GOFILES=\
+       auth.go\
+       smtp.go\
+
+include ../../Make.pkg
diff --git a/src/pkg/smtp/auth.go b/src/pkg/smtp/auth.go
new file mode 100644 (file)
index 0000000..dd27f8e
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package smtp
+
+import (
+       "os"
+)
+
+// Auth is implemented by an SMTP authentication mechanism.
+type Auth interface {
+       // Start begins an authentication with a server.
+       // It returns the name of the authentication protocol
+       // and optionally data to include in the initial AUTH message
+       // sent to the server. It can return proto == "" to indicate
+       // that the authentication should be skipped.
+       // If it returns a non-nil os.Error, the SMTP client aborts
+       // the authentication attempt and closes the connection.
+       Start(server *ServerInfo) (proto string, toServer []byte, err os.Error)
+
+       // Next continues the authentication. The server has just sent
+       // the fromServer data. If more is true, the server expects a
+       // response, which Next should return as toServer; otherwise
+       // Next should return toServer == nil.
+       // If Next returns a non-nil os.Error, the SMTP client aborts
+       // the authentication attempt and closes the connection.
+       Next(fromServer []byte, more bool) (toServer []byte, err os.Error)
+}
+
+// ServerInfo records information about an SMTP server.
+type ServerInfo struct {
+       Name string   // SMTP server name
+       TLS  bool     // using TLS, with valid certificate for Name
+       Auth []string // advertised authentication mechanisms
+}
+
+type plainAuth struct {
+       identity, username, password string
+       host                         string
+}
+
+// PlainAuth returns an Auth that implements the PLAIN authentication
+// mechanism as defined in RFC 4616.
+// The returned Auth uses the given username and password to authenticate
+// on TLS connections to host and act as identity. Usually identity will be
+// left blank to act as username.
+func PlainAuth(identity, username, password, host string) Auth {
+       return &plainAuth{identity, username, password, host}
+}
+
+func (a *plainAuth) Start(server *ServerInfo) (string, []byte, os.Error) {
+       if !server.TLS {
+               return "", nil, os.NewError("unencrypted connection")
+       }
+       if server.Name != a.host {
+               return "", nil, os.NewError("wrong host name")
+       }
+       resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
+       return "PLAIN", resp, nil
+}
+
+func (a *plainAuth) Next(fromServer []byte, more bool) ([]byte, os.Error) {
+       if more {
+               // We've already sent everything.
+               return nil, os.NewError("unexpected server challenge")
+       }
+       return nil, nil
+}
diff --git a/src/pkg/smtp/smtp.go b/src/pkg/smtp/smtp.go
new file mode 100644 (file)
index 0000000..778d8c8
--- /dev/null
@@ -0,0 +1,295 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321.
+// It also implements the following extensions:
+//     8BITMIME  RFC 1652
+//     AUTH      RFC 2554
+//     STARTTLS  RFC 3207
+// Additional extensions may be handled by clients.
+package smtp
+
+import (
+       "crypto/tls"
+       "encoding/base64"
+       "io"
+       "os"
+       "net"
+       "net/textproto"
+       "strings"
+)
+
+// A Client represents a client connection to an SMTP server.
+type Client struct {
+       // Text is the textproto.Conn used by the Client. It is exported to allow for
+       // clients to add extensions.
+       Text *textproto.Conn
+       // keep a reference to the connection so it can be used to create a TLS
+       // connection later
+       conn net.Conn
+       // whether the Client is using TLS
+       tls        bool
+       serverName string
+       // map of supported extensions
+       ext map[string]string
+       // supported auth mechanisms
+       auth []string
+}
+
+// Dial returns a new Client connected to an SMTP server at addr.
+func Dial(addr string) (*Client, os.Error) {
+       conn, err := net.Dial("tcp", "", addr)
+       if err != nil {
+               return nil, err
+       }
+       host := addr[:strings.Index(addr, ":")]
+       return NewClient(conn, host)
+}
+
+// NewClient returns a new Client using an existing connection and host as a
+// server name to be used when authenticating.
+func NewClient(conn net.Conn, host string) (*Client, os.Error) {
+       text := textproto.NewConn(conn)
+       _, msg, err := text.ReadResponse(220)
+       if err != nil {
+               text.Close()
+               return nil, err
+       }
+       c := &Client{Text: text, conn: conn, serverName: host}
+       if strings.Index(msg, "ESMTP") >= 0 {
+               err = c.ehlo()
+       } else {
+               err = c.helo()
+       }
+       return c, err
+}
+
+// cmd is a convenience function that sends a command and returns the response
+func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, os.Error) {
+       id, err := c.Text.Cmd(format, args...)
+       if err != nil {
+               return 0, "", err
+       }
+       c.Text.StartResponse(id)
+       defer c.Text.EndResponse(id)
+       code, msg, err := c.Text.ReadResponse(expectCode)
+       return code, msg, err
+}
+
+// helo sends the HELO greeting to the server. It should be used only when the
+// server does not support ehlo.
+func (c *Client) helo() os.Error {
+       c.ext = nil
+       _, _, err := c.cmd(250, "HELO localhost")
+       return err
+}
+
+// ehlo sends the EHLO (extended hello) greeting to the server. It
+// should be the preferred greeting for servers that support it.
+func (c *Client) ehlo() os.Error {
+       _, msg, err := c.cmd(250, "EHLO localhost")
+       if err != nil {
+               return err
+       }
+       ext := make(map[string]string)
+       extList := strings.Split(msg, "\n", -1)
+       if len(extList) > 1 {
+               extList = extList[1:]
+               for _, line := range extList {
+                       args := strings.Split(line, " ", 2)
+                       if len(args) > 1 {
+                               ext[args[0]] = args[1]
+                       } else {
+                               ext[args[0]] = ""
+                       }
+               }
+       }
+       if mechs, ok := ext["AUTH"]; ok {
+               c.auth = strings.Split(mechs, " ", -1)
+       }
+       c.ext = ext
+       return err
+}
+
+// StartTLS sends the STARTTLS command and encrypts all further communication.
+// Only servers that advertise the STARTTLS extension support this function.
+func (c *Client) StartTLS() os.Error {
+       _, _, err := c.cmd(220, "STARTTLS")
+       if err != nil {
+               return err
+       }
+       c.conn = tls.Client(c.conn, nil)
+       c.Text = textproto.NewConn(c.conn)
+       c.tls = true
+       return c.ehlo()
+}
+
+// Verify checks the validity of an email address on the server.
+// If Verify returns nil, the address is valid. A non-nil return
+// does not necessarily indicate an invalid address. Many servers
+// will not verify addresses for security reasons.
+func (c *Client) Verify(addr string) os.Error {
+       _, _, err := c.cmd(250, "VRFY %s", addr)
+       return err
+}
+
+// Auth authenticates a client using the provided authentication mechanism.
+// A failed authentication closes the connection.
+// Only servers that advertise the AUTH extension support this function.
+func (c *Client) Auth(a Auth) os.Error {
+       encoding := base64.StdEncoding
+       mech, resp, err := a.Start(&ServerInfo{c.serverName, c.tls, c.auth})
+       if err != nil {
+               c.Quit()
+               return err
+       }
+       resp64 := make([]byte, encoding.EncodedLen(len(resp)))
+       encoding.Encode(resp64, resp)
+       code, msg64, err := c.cmd(0, "AUTH %s %s", mech, resp64)
+       for err == nil {
+               var msg []byte
+               switch code {
+               case 334:
+                       msg = make([]byte, encoding.DecodedLen(len(msg64)))
+                       _, err = encoding.Decode(msg, []byte(msg64))
+               case 235:
+                       // the last message isn't base64 because it isn't a challenge
+                       msg = []byte(msg64)
+               default:
+                       err = &textproto.Error{code, msg64}
+               }
+               resp, err = a.Next(msg, code == 334)
+               if err != nil {
+                       // abort the AUTH
+                       c.cmd(501, "*")
+                       c.Quit()
+                       break
+               }
+               if resp == nil {
+                       break
+               }
+               resp64 = make([]byte, encoding.EncodedLen(len(resp)))
+               encoding.Encode(resp64, resp)
+               code, msg64, err = c.cmd(0, string(resp64))
+       }
+       return err
+}
+
+// Mail issues a MAIL command to the server using the provided email address.
+// If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME
+// parameter.
+// This initiates a mail transaction and is followed by one or more Rcpt calls.
+func (c *Client) Mail(from string) os.Error {
+       cmdStr := "MAIL FROM:<%s>"
+       if c.ext != nil {
+               if _, ok := c.ext["8BITMIME"]; ok {
+                       cmdStr += " BODY=8BITMIME"
+               }
+       }
+       _, _, err := c.cmd(250, cmdStr, from)
+       return err
+}
+
+// Rcpt issues a RCPT command to the server using the provided email address.
+// A call to Rcpt must be preceded by a call to Mail and may be followed by
+// a Data call or another Rcpt call.
+func (c *Client) Rcpt(to string) os.Error {
+       _, _, err := c.cmd(25, "RCPT TO:<%s>", to)
+       return err
+}
+
+type dataCloser struct {
+       c *Client
+       io.WriteCloser
+}
+
+func (d *dataCloser) Close() os.Error {
+       d.WriteCloser.Close()
+       _, _, err := d.c.Text.ReadResponse(250)
+       return err
+}
+
+// Data issues a DATA command to the server and returns a writer that
+// can be used to write the data. The caller should close the writer
+// before calling any more methods on c.
+// A call to Data must be preceded by one or more calls to Rcpt.
+func (c *Client) Data() (io.WriteCloser, os.Error) {
+       _, _, err := c.cmd(354, "DATA")
+       if err != nil {
+               return nil, err
+       }
+       return &dataCloser{c, c.Text.DotWriter()}, nil
+}
+
+// SendMail connects to the server at addr, switches to TLS if possible,
+// authenticates with mechanism a if possible, and then sends an email from
+// address from, to addresses to, with message msg.
+func SendMail(addr string, a Auth, from string, to []string, msg []byte) os.Error {
+       c, err := Dial(addr)
+       if err != nil {
+               return err
+       }
+       if ok, _ := c.Extension("STARTTLS"); ok {
+               if err = c.StartTLS(); err != nil {
+                       return err
+               }
+       }
+       if a != nil && c.ext != nil {
+               if _, ok := c.ext["AUTH"]; ok {
+                       if err = c.Auth(a); err != nil {
+                               return err
+                       }
+               }
+       }
+       if err = c.Mail(from); err != nil {
+               return err
+       }
+       for _, addr := range to {
+               if err = c.Rcpt(addr); err != nil {
+                       return err
+               }
+       }
+       w, err := c.Data()
+       if err != nil {
+               return err
+       }
+       _, err = w.Write(msg)
+       if err != nil {
+               return err
+       }
+       err = w.Close()
+       if err != nil {
+               return err
+       }
+       return c.Quit()
+}
+
+// Extension reports whether an extension is support by the server.
+// The extension name is case-insensitive. If the extension is supported,
+// Extension also returns a string that contains any parameters the
+// server specifies for the extension.
+func (c *Client) Extension(ext string) (bool, string) {
+       if c.ext == nil {
+               return false, ""
+       }
+       ext = strings.ToUpper(ext)
+       param, ok := c.ext[ext]
+       return ok, param
+}
+
+// Reset sends the RSET command to the server, aborting the current mail
+// transaction.
+func (c *Client) Reset() os.Error {
+       _, _, err := c.cmd(250, "RSET")
+       return err
+}
+
+// Quit sends the QUIT command and closes the connection to the server.
+func (c *Client) Quit() os.Error {
+       _, _, err := c.cmd(221, "QUIT")
+       if err != nil {
+               return err
+       }
+       return c.Text.Close()
+}
diff --git a/src/pkg/smtp/smtp_test.go b/src/pkg/smtp/smtp_test.go
new file mode 100644 (file)
index 0000000..2e96813
--- /dev/null
@@ -0,0 +1,182 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package smtp
+
+import (
+       "bufio"
+       "bytes"
+       "io"
+       "net/textproto"
+       "os"
+       "strings"
+       "testing"
+)
+
+type authTest struct {
+       auth       Auth
+       challenges []string
+       name       string
+       responses  []string
+}
+
+var authTests = []authTest{
+       authTest{PlainAuth("", "user", "pass", "testserver"), []string{}, "PLAIN", []string{"\x00user\x00pass"}},
+       authTest{PlainAuth("foo", "bar", "baz", "testserver"), []string{}, "PLAIN", []string{"foo\x00bar\x00baz"}},
+}
+
+func TestAuth(t *testing.T) {
+testLoop:
+       for i, test := range authTests {
+               name, resp, err := test.auth.Start(&ServerInfo{"testserver", true, nil})
+               if name != test.name {
+                       t.Errorf("#%d got name %s, expected %s", i, name, test.name)
+               }
+               if !bytes.Equal(resp, []byte(test.responses[0])) {
+                       t.Errorf("#%d got response %s, expected %s", i, resp, test.responses[0])
+               }
+               if err != nil {
+                       t.Errorf("#%d error: %s", i, err.String())
+               }
+               for j := range test.challenges {
+                       challenge := []byte(test.challenges[j])
+                       expected := []byte(test.responses[j+1])
+                       resp, err := test.auth.Next(challenge, true)
+                       if err != nil {
+                               t.Errorf("#%d error: %s", i, err.String())
+                               continue testLoop
+                       }
+                       if !bytes.Equal(resp, expected) {
+                               t.Errorf("#%d got %s, expected %s", i, resp, expected)
+                               continue testLoop
+                       }
+               }
+       }
+}
+
+type faker struct {
+       io.ReadWriter
+}
+
+func (f faker) Close() os.Error {
+       return nil
+}
+
+func TestBasic(t *testing.T) {
+       basicServer = strings.Join(strings.Split(basicServer, "\n", -1), "\r\n")
+       basicClient = strings.Join(strings.Split(basicClient, "\n", -1), "\r\n")
+
+       var cmdbuf bytes.Buffer
+       bcmdbuf := bufio.NewWriter(&cmdbuf)
+       var fake faker
+       fake.ReadWriter = bufio.NewReadWriter(bufio.NewReader(strings.NewReader(basicServer)), bcmdbuf)
+       c := &Client{Text: textproto.NewConn(fake)}
+
+       if err := c.helo(); err != nil {
+               t.Fatalf("HELO failed: %s", err.String())
+       }
+       if err := c.ehlo(); err == nil {
+               t.Fatalf("Expected first EHLO to fail")
+       }
+       if err := c.ehlo(); err != nil {
+               t.Fatalf("Second EHLO failed: %s", err.String())
+       }
+
+       if ok, args := c.Extension("aUtH"); !ok || args != "LOGIN PLAIN" {
+               t.Fatalf("Expected AUTH supported")
+       }
+       if ok, _ := c.Extension("DSN"); ok {
+               t.Fatalf("Shouldn't support DSN")
+       }
+
+       if err := c.Mail("user@gmail.com"); err == nil {
+               t.Fatalf("MAIL should require authentication")
+       }
+
+       if err := c.Verify("user1@gmail.com"); err == nil {
+               t.Fatalf("First VRFY: expected no verification")
+       }
+       if err := c.Verify("user2@gmail.com"); err != nil {
+               t.Fatalf("Second VRFY: expected verification, got %s", err)
+       }
+
+       // fake TLS so authentication won't complain
+       c.tls = true
+       c.serverName = "smtp.google.com"
+       if err := c.Auth(PlainAuth("", "user", "pass", "smtp.google.com")); err != nil {
+               t.Fatalf("AUTH failed: %s", err.String())
+       }
+
+       if err := c.Mail("user@gmail.com"); err != nil {
+               t.Fatalf("MAIL failed: %s", err.String())
+       }
+       if err := c.Rcpt("golang-nuts@googlegroups.com"); err != nil {
+               t.Fatalf("RCPT failed: %s", err.String())
+       }
+       msg := `From: user@gmail.com
+To: golang-nuts@googlegroups.com
+Subject: Hooray for Go
+
+Line 1
+.Leading dot line .
+Goodbye.`
+       w, err := c.Data()
+       if err != nil {
+               t.Fatalf("DATA failed: %s", err.String())
+       }
+       if _, err := w.Write([]byte(msg)); err != nil {
+               t.Fatalf("Data write failed: %s", err.String())
+       }
+       if err := w.Close(); err != nil {
+               t.Fatalf("Bad data response: %s", err.String())
+       }
+
+       if err := c.Quit(); err != nil {
+               t.Fatalf("QUIT failed: %s", err.String())
+       }
+
+       bcmdbuf.Flush()
+       actualcmds := cmdbuf.String()
+       if basicClient != actualcmds {
+               t.Fatalf("Got:\n%s\nExpected:\n%s", actualcmds, basicClient)
+       }
+}
+
+var basicServer = `250 mx.google.com at your service
+502 Unrecognized command.
+250-mx.google.com at your service
+250-SIZE 35651584
+250-AUTH LOGIN PLAIN
+250 8BITMIME
+530 Authentication required
+252 Send some mail, I'll try my best
+250 User is valid
+235 Accepted
+250 Sender OK
+250 Receiver OK
+354 Go ahead
+250 Data OK
+221 OK
+`
+
+var basicClient = `HELO localhost
+EHLO localhost
+EHLO localhost
+MAIL FROM:<user@gmail.com> BODY=8BITMIME
+VRFY user1@gmail.com
+VRFY user2@gmail.com
+AUTH PLAIN AHVzZXIAcGFzcw==
+MAIL FROM:<user@gmail.com> BODY=8BITMIME
+RCPT TO:<golang-nuts@googlegroups.com>
+DATA
+From: user@gmail.com
+To: golang-nuts@googlegroups.com
+Subject: Hooray for Go
+
+Line 1
+..Leading dot line .
+Goodbye.
+.
+QUIT
+`