From 06e37755f2ef3c3dbd8b0ce2b086a65ae0de55bc Mon Sep 17 00:00:00 2001 From: Winni Neessen Date: Sat, 12 Mar 2022 15:10:01 +0100 Subject: [PATCH] Some progress was made: - Implemented proper AUTH LOGIN mechanism - Implemented msgWriter for handling io - Implemented proper mail header formatting/output - Minor refactoring --- auth.go | 6 --- auth/login.go | 69 +++++++++++++++++++++++++++++++ client.go | 63 ++++++++++++++++++++++++----- cmd/main.go | 35 +++++++--------- mailmsg.go => msg.go | 54 ++++++++++++++++++++----- msgwriter.go | 96 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 48 deletions(-) create mode 100644 auth/login.go rename mailmsg.go => msg.go (82%) create mode 100644 msgwriter.go diff --git a/auth.go b/auth.go index fb63b29..33f4ed4 100644 --- a/auth.go +++ b/auth.go @@ -15,9 +15,6 @@ const ( // SMTPAuthCramMD5 is the "CRAM-MD5" SASL authentication mechanism as described in RFC 4954 SMTPAuthCramMD5 SMTPAuthType = "CRAM-MD5" - - // SMTPAuthDigestMD5 is the "DIGEST-MD5" SASL authentication mechanism as described in RFC 4954 - SMTPAuthDigestMD5 SMTPAuthType = "DIGEST-MD5" ) // SMTP Auth related static errors @@ -30,7 +27,4 @@ var ( // ErrCramMD5AuthNotSupported should be used if the target server does not support the "CRAM-MD5" schema ErrCramMD5AuthNotSupported = errors.New("server does not support SMTP AUTH type: CRAM-MD5") - - // ErrDigestMD5AuthNotSupported should be used if the target server does not support the "DIGEST-MD5" schema - ErrDigestMD5AuthNotSupported = errors.New("server does not support SMTP AUTH type: DIGEST-MD5") ) diff --git a/auth/login.go b/auth/login.go new file mode 100644 index 0000000..8c483e5 --- /dev/null +++ b/auth/login.go @@ -0,0 +1,69 @@ +// Package auth implements the LOGIN and MD5-DIGEST smtp authentication mechansims +package auth + +import ( + "errors" + "fmt" + "net/smtp" +) + +type loginAuth struct { + username, password string + host string +} + +const ( + // ServerRespUsername represents the "Username:" response by the SMTP server + ServerRespUsername = "Username:" + + // ServerRespPassword represents the "Password:" response by the SMTP server + ServerRespPassword = "Password:" +) + +// LoginAuth returns an Auth that implements the LOGIN authentication +// mechanism as it is used by MS Outlook. The Auth works similar to PLAIN +// but instead of sending all in one response, the login is handled within +// 3 steps: +// - Sending AUTH LOGIN (server responds with "Username:") +// - Sending the username (server responds with "Password:") +// - Sending the password (server authenticates) +// +// LoginAuth will only send the credentials if the connection is using TLS +// or is connected to localhost. Otherwise authentication will fail with an +// error, without sending the credentials. +func LoginAuth(username, password, host string) smtp.Auth { + return &loginAuth{username, password, host} +} + +func isLocalhost(name string) bool { + return name == "localhost" || name == "127.0.0.1" || name == "::1" +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + // Must have TLS, or else localhost server. + // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. + // In particular, it doesn't matter if the server advertises LOGIN auth. + // That might just be the attacker saying + // "it's ok, you can trust me with your password." + if !server.TLS && !isLocalhost(server.Name) { + return "", nil, errors.New("unencrypted connection") + } + if server.Name != a.host { + return "", nil, errors.New("wrong host name") + } + return "LOGIN", nil, nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case ServerRespUsername: + return []byte(a.username), nil + case ServerRespPassword: + return []byte(a.password), nil + default: + return nil, fmt.Errorf("unexpected server response: %s", string(fromServer)) + } + } + return nil, nil +} diff --git a/client.go b/client.go index 249f74f..20e34fd 100644 --- a/client.go +++ b/client.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "errors" "fmt" + "github.com/wneessen/go-mail/auth" "net" "net/smtp" "os" @@ -275,10 +276,54 @@ func (c *Client) DialWithContext(pc context.Context) error { } // Send sends out the mail message -func (c *Client) Send() error { +func (c *Client) Send(ml ...*Msg) error { if err := c.checkConn(); err != nil { return fmt.Errorf("failed to send mail: %w", err) } + for _, m := range ml { + f, err := m.GetSender(false) + if err != nil { + return err + } + rl, err := m.GetRecipients() + if err != nil { + return err + } + + if err := c.sc.Mail(f); err != nil { + return fmt.Errorf("sending MAIL FROM command failed: %w", err) + } + for _, r := range rl { + if err := c.sc.Rcpt(r); err != nil { + return fmt.Errorf("sending RCPT TO command failed: %w", err) + } + } + w := os.Stderr + + /* + w, err := c.sc.Data() + if err != nil { + return fmt.Errorf("sending DATA command failed: %w", err) + } + + */ + + _, err = m.Write(w) + if err != nil { + return fmt.Errorf("sending mail content failed: %w", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("failed to close DATA writer: %w", err) + } + + if err := c.Reset(); err != nil { + return fmt.Errorf("sending RSET command failed: %s", err) + } + if err := c.checkConn(); err != nil { + return fmt.Errorf("failed to check server connection: %w", err) + } + } return nil } @@ -288,7 +333,7 @@ func (c *Client) Close() error { if err := c.checkConn(); err != nil { return err } - if err := c.sc.Close(); err != nil { + if err := c.sc.Quit(); err != nil { return fmt.Errorf("failed to close SMTP client: %w", err) } @@ -309,14 +354,17 @@ func (c *Client) Reset() error { // DialAndSend establishes a connection to the SMTP server with a // default context.Background and sends the mail -func (c *Client) DialAndSend() error { +func (c *Client) DialAndSend(ml ...*Msg) error { ctx := context.Background() if err := c.DialWithContext(ctx); err != nil { return fmt.Errorf("dial failed: %w", err) } - if err := c.Send(); err != nil { + if err := c.Send(ml...); err != nil { return fmt.Errorf("send failed: %w", err) } + if err := c.Close(); err != nil { + return fmt.Errorf("failed to close connction: %s", err) + } return nil } @@ -383,17 +431,12 @@ func (c *Client) auth() error { if !strings.Contains(sat, string(SMTPAuthLogin)) { return ErrLoginAuthNotSupported } - c.sa = smtp.PlainAuth("", c.user, c.pass, c.host) + c.sa = auth.LoginAuth(c.user, c.pass, c.host) case SMTPAuthCramMD5: if !strings.Contains(sat, string(SMTPAuthCramMD5)) { return ErrCramMD5AuthNotSupported } c.sa = smtp.CRAMMD5Auth(c.user, c.pass) - case SMTPAuthDigestMD5: - if !strings.Contains(sat, string(SMTPAuthDigestMD5)) { - return ErrDigestMD5AuthNotSupported - } - c.sa = smtp.CRAMMD5Auth(c.user, c.pass) default: return fmt.Errorf("unsupported SMTP AUTH type %q", c.satype) } diff --git a/cmd/main.go b/cmd/main.go index 8a0b2c5..1b934c6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/wneessen/go-mail" "os" - "time" ) func main() { @@ -16,42 +15,36 @@ func main() { tu := os.Getenv("TEST_USER") tp := os.Getenv("TEST_PASS") + fa := "Winni Neessen " + toa := "Winfried Neessen " + //toa = "Winfried Neessen " + m := mail.NewMsg() - if err := m.From(`Winni Neessen `); err != nil { + if err := m.From(fa); err != nil { fmt.Printf("failed to set FROM addres: %s", err) os.Exit(1) } - if err := m.To("t1+2941@test.de", "foo@bar.de", "blubb@blah.com"); err != nil { + if err := m.To(toa); err != nil { fmt.Printf("failed to set TO address: %s", err) os.Exit(1) } - m.CcIgnoreInvalid("cc@test.de", "bar.de", "cc@blah.com") - m.BccIgnoreInvalid("bcc@test.de", "bcc@blah.com") m.Subject("This is the Subject with Umlauts: üöäß") - m.SetHeader(mail.HeaderContentLang, "en") + m.SetHeader(mail.HeaderContentLang, "de", "en", "fr", "sp", "de", "en", "fr", "sp", "de", "en", "fr", + "sp", "de", "en", "fr", "sp") + m.SetHeader(mail.HeaderListUnsubscribePost, "üüüüüüüü", "aaaaääää", "ßßßßßßßßß", "XXXXXX", "ZZZZZ", "XXXXXXXX", + "äää äää", "YYYYYY", "XXXXXX", "ZZZZZ", "üäö´") m.SetMessageID() m.SetDate() m.SetBulk() - m.Header() - c, err := mail.NewClient(th, mail.WithTimeout(time.Millisecond*500), mail.WithTLSPolicy(mail.TLSMandatory), - mail.WithSMTPAuth(mail.SMTPAuthDigestMD5), mail.WithUsername(tu), mail.WithPassword(tp)) + c, err := mail.NewClient(th, mail.WithTLSPolicy(mail.TLSMandatory), + mail.WithSMTPAuth(mail.SMTPAuthLogin), mail.WithUsername(tu), + mail.WithPassword(tp)) if err != nil { fmt.Printf("failed to create new client: %s\n", err) os.Exit(1) } - defer func() { - if err := c.Reset(); err != nil { - fmt.Printf("failed to reset: %s\n", err) - os.Exit(1) - } - if err := c.Close(); err != nil { - fmt.Printf("failed to close: %s\n", err) - os.Exit(1) - } - }() - - if err := c.DialAndSend(); err != nil { + if err := c.DialAndSend(m); err != nil { fmt.Printf("failed to dial: %s\n", err) os.Exit(1) } diff --git a/mailmsg.go b/msg.go similarity index 82% rename from mailmsg.go rename to msg.go index 6bc94ed..961b61b 100644 --- a/mailmsg.go +++ b/msg.go @@ -1,7 +1,9 @@ package mail import ( + "errors" "fmt" + "io" "math/rand" "mime" "net/mail" @@ -9,6 +11,14 @@ import ( "time" ) +var ( + // ErrNoFromAddress should be used when a FROM address is requrested but not set + ErrNoFromAddress = errors.New("no FROM address set") + + // ErrNoRcptAddresses should be used when the list of RCPTs is empty + ErrNoRcptAddresses = errors.New("no recipient addresses set") +) + // Msg is the mail message struct type Msg struct { // charset represents the charset of the mail (defaults to UTF-8) @@ -167,7 +177,7 @@ func (m *Msg) SetMessageID() { if err != nil { hn = "localhost.localdomain" } - ct := time.Now().UnixMicro() + ct := time.Now().Unix() r := rand.New(rand.NewSource(ct)) rn := r.Int() pid := os.Getpid() @@ -194,18 +204,40 @@ func (m *Msg) SetDate() { m.SetHeader(HeaderDate, ts) } -// Header does something -// FIXME: This is only here to quickly show the set headers for debugging purpose. Remove me later -func (m *Msg) Header() { - fmt.Println("Address header:") - for k, v := range m.addrHeader { - fmt.Printf(" - %s: %s\n", k, v) +// GetSender returns the currently set FROM address. If f is true, it will return the full +// address string including the address name, if set +func (m *Msg) GetSender(ff bool) (string, error) { + f, ok := m.addrHeader[HeaderFrom] + if !ok || len(f) == 0 { + return "", ErrNoFromAddress } - fmt.Println("\nGeneric header:") - for k, v := range m.genHeader { - fmt.Printf(" - %s: %s\n", k, v) + if ff { + return f[0].String(), nil } - fmt.Println() + return f[0].Address, nil +} + +// GetRecipients returns a list of the currently set TO/CC/BCC addresses. +func (m *Msg) GetRecipients() ([]string, error) { + var rl []string + for _, t := range []AddrHeader{HeaderTo, HeaderCc, HeaderBcc} { + al, ok := m.addrHeader[t] + if !ok || len(al) == 0 { + continue + } + for _, r := range al { + rl = append(rl, r.Address) + } + } + if len(rl) <= 0 { + return rl, ErrNoRcptAddresses + } + return rl, nil +} +func (m *Msg) Write(w io.Writer) (int64, error) { + mw := &msgWriter{w: w} + mw.writeMsg(m) + return mw.n, mw.err } // setEncoder creates a new mime.WordEncoder based on the encoding setting of the message diff --git a/msgwriter.go b/msgwriter.go new file mode 100644 index 0000000..458b0ba --- /dev/null +++ b/msgwriter.go @@ -0,0 +1,96 @@ +package mail + +import ( + "io" + "strings" +) + +// MaxHeaderLength defines the maximum line length for a mail header +// RFC 2047 suggests 76 characters +const MaxHeaderLength = 76 + +// msgWriter handles the I/O to the io.WriteCloser of the SMTP client +type msgWriter struct { + w io.Writer + n int64 + //writers [3]*multipart.Writer + //partWriter io.Writer + //depth uint8 + err error +} + +// writeMsg formats the message and sends it to its io.Writer +func (mw *msgWriter) writeMsg(m *Msg) { + if _, ok := m.genHeader["Date"]; !ok { + m.SetDate() + } + for k, v := range m.genHeader { + mw.writeHeader(k, v...) + } + for _, t := range []AddrHeader{HeaderFrom, HeaderTo, HeaderCc} { + if al, ok := m.addrHeader[t]; ok { + var v []string + for _, a := range al { + v = append(v, a.String()) + } + mw.writeHeader(Header(t), v...) + } + } + mw.writeString("\r\n") + mw.writeString("This is a test mail") +} + +// writeHeader writes a header into the msgWriter's io.Writer +func (mw *msgWriter) writeHeader(k Header, v ...string) { + mw.writeString(string(k)) + if len(v) == 0 { + mw.writeString(":\r\n") + return + } + mw.writeString(": ") + + // Chars left: MaxHeaderLength - ": " - "CRLF" + cl := MaxHeaderLength - len(k) - 4 + for i, s := range v { + nfl := 0 + if i < len(v) { + nfl = len(v[i]) + } + if cl-len(s) < 1 { + if p := strings.IndexByte(s, ' '); p != -1 { + mw.writeString(s[:p]) + mw.writeString("\r\n ") + mw.writeString(s[p:]) + cl -= len(s[p:]) + continue + } + } + if cl < 1 || cl-nfl < 1 { + mw.writeString("\r\n") + cl = MaxHeaderLength - 4 + if i != len(v) { + mw.writeString(" ") + cl -= 1 + } + } + mw.writeString(s) + cl -= len(s) + + if i != len(v)-1 { + mw.writeString(", ") + cl -= 2 + } + + } + mw.writeString("\r\n") +} + +// writeString writes a string into the msgWriter's io.Writer interface +func (mw *msgWriter) writeString(s string) { + if mw.err != nil { + return + } + var n int + n, mw.err = io.WriteString(mw.w, s) + mw.n += int64(n) +}