diff --git a/.travis.yml b/.travis.yml index 9424da4..ffd8ca5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,3 +4,11 @@ go: - "1.8.x" - "1.9.x" - "1.10.x" + +before_install: + - go get -u gopkg.in/alecthomas/gometalinter.v2 + - gometalinter.v2 --install + +script: + - gometalinter.v2 --disable=gas --disable=gocyclo ./... + - go test -v ./... diff --git a/README.md b/README.md index 58a7353..267f09a 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,34 @@ -Go sendmail [![Build Status](https://travis-ci.org/meehow/sendmail.svg?branch=master)](https://travis-ci.org/meehow/sendmail) -=========== +# Go sendmail -This package implements classic, well known from PHP, method of sending emails. -It's stupid simple and it works not only with Sendmail, -but also with other MTAs, like [Postfix](http://www.postfix.org/sendmail.1.html) -or [sSMTP](https://wiki.debian.org/sSMTP), which provide compatibility interface. +[![GoDoc](https://godoc.org/github.com/meehow/sendmail?status.svg)](https://godoc.org/github.com/meehow/sendmail) +[![Build Status](https://travis-ci.org/meehow/sendmail.svg?branch=master)](https://travis-ci.org/meehow/sendmail) + + +This package implements the classic method of sending emails, well known +from PHP. It's stupid simple and it works not only with Sendmail, but also +with other MTAs, like [Postfix][], [sSMTP][], or [mhsendmail][], which +provide a compatible interface. + +[Postfix]: http://www.postfix.org/sendmail.1.html +[sSMTP]: https://wiki.debian.org/sSMTP +[mhsendmail]: https://github.com/mailhog/mhsendmail * it separates email headers from email body, * encodes UTF-8 headers like `Subject`, `From`, `To` * makes it easy to use [text/template](https://golang.org/pkg/text/template) * doesn't require any SMTP configuration, -* just uses `/usr/sbin/sendmail` command which is present on most of the systems, - * if not, just update `sendmail.Binary` -* outputs emails to _stdout_ when environment variable `DEBUG` is set. +* can write email body to a custom `io.Writer` to simplify testing +* by default, it just uses `/usr/sbin/sendmail` (but can be changed if need be) + + +## Installation -Installation ------------- ``` go get -u github.com/meehow/sendmail ``` -Usage ------ +## Usage + ```go package main @@ -59,7 +66,9 @@ tpl.ExecuteTemplate(&sm.Text, "email", &struct{ Name string }{"Dominik"}) ``` -ToDo ----- +## ToDo -* HTML emails +- [x] HTML emails +- [ ] multipart emails (HTML + Text) +- [ ] attachments +- [ ] inline attachments diff --git a/options.go b/options.go new file mode 100644 index 0000000..6bc30a1 --- /dev/null +++ b/options.go @@ -0,0 +1,104 @@ +package sendmail + +import ( + "io" + "net/mail" + "os" +) + +// SetSendmail modifies the path to the sendmail binary. You can pass +// additional arguments, if you need to. +func (m *Mail) SetSendmail(path string, args ...string) *Mail { + m.sendmailPath = path + m.sendmailArgs = args + return m +} + +// SetDebug sets the debug output to stderr if active is true, else it +// removes the debug output. Use SetDebugOutput to set it to something else. +func (m *Mail) SetDebug(active bool) *Mail { + var out io.Writer + if active { + out = os.Stderr + } + m.debugOut = out + return m +} + +// SetDebugOutput sets the debug output to the given writer. If w is +// nil, this is equivalent to SetDebug(false). +func (m *Mail) SetDebugOutput(w io.Writer) *Mail { + m.debugOut = w + return m +} + +// AppendTo adds a recipient to the Mail. +func (m *Mail) AppendTo(toAddress ...*mail.Address) *Mail { + m.To = append(m.To, toAddress...) + return m +} + +// AppendCC adds a carbon-copy recipient to the Mail. +func (m *Mail) AppendCC(ccAddress ...*mail.Address) *Mail { + m.CC = append(m.CC, ccAddress...) + return m +} + +// AppendBCC adds a blind carbon-copy recipient to the Mail. +func (m *Mail) AppendBCC(bccAddress ...*mail.Address) *Mail { + m.BCC = append(m.BCC, bccAddress...) + return m +} + +// SetFrom updates (replaces) the sender's address. +func (m *Mail) SetFrom(fromAddress *mail.Address) *Mail { + m.From = fromAddress + return m +} + +// SetSubject sets the mail subject. +func (m *Mail) SetSubject(subject string) *Mail { + m.Subject = subject + return m +} + +// Option is used in the Mail constructor. +type Option interface { + execute(*Mail) +} + +type optionFunc func(*Mail) + +func (o optionFunc) execute(m *Mail) { o(m) } + +// Sendmail modifies the path to the sendmail binary. +func Sendmail(path string, args ...string) Option { + return optionFunc(func(m *Mail) { m.SetSendmail(path, args...) }) +} + +// Debug sets the debug output to stderr if active is true, else it +// removes the debug output. Use SetDebugOutput to set it to something else. +func Debug(active bool) Option { + return optionFunc(func(m *Mail) { m.SetDebug(active) }) +} + +// DebugOutput sets the debug output to the given writer. If w is nil, +// this is equivalent to SetDebug(false). +func DebugOutput(w io.Writer) Option { + return optionFunc(func(m *Mail) { m.SetDebugOutput(w) }) +} + +// To adds a recipient to the Mail. +func To(address *mail.Address) Option { + return optionFunc(func(m *Mail) { m.AppendTo(address) }) +} + +// From sets the sender's address. +func From(fromAddress *mail.Address) Option { + return optionFunc(func(m *Mail) { m.SetFrom(fromAddress) }) +} + +// Subject sets the mail subject. +func Subject(subject string) Option { + return optionFunc(func(m *Mail) { m.SetSubject(subject) }) +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..1e4fbe0 --- /dev/null +++ b/options_test.go @@ -0,0 +1,120 @@ +package sendmail + +import ( + "bytes" + "net/mail" + "os" + "testing" +) + +func TestChaningOptions(t *testing.T) { + var buf bytes.Buffer + m := &Mail{ + To: []*mail.Address{ + &mail.Address{Name: "Michał", Address: "me@example.com"}, + }, + } + if m.Subject != "" { + t.Errorf("Expected subject to be empty, got %q", m.Subject) + } + if len(m.To) != 1 { + t.Errorf("Expected len(To) to be 1, got %d: %+v", len(m.To), m.To) + } + if m.From != nil { + t.Errorf("Expected From address to be nil, got %s", m.From) + } + if m.sendmailPath != "" { + t.Errorf("Expected initial sendmail to be empty, got %q", m.sendmailPath) + } + if m.debugOut != nil { + t.Errorf("Expected initial debugOut to be nil, got %T", m.debugOut) + } + + m.SetSubject("Test subject"). + SetFrom(&mail.Address{Name: "Dominik", Address: "dominik@example.org"}). + AppendTo(&mail.Address{Name: "Dominik2", Address: "dominik2@example.org"}). + SetDebugOutput(&buf). + SetSendmail("/bin/true") + + if m.Subject != "Test subject" { + t.Errorf("Expected subject to be %q, got %q", "Test subject", m.Subject) + } + if len(m.To) != 2 { + t.Errorf("Expected len(To) to be 2, got %d: %+v", len(m.To), m.To) + } + if m.From == nil || m.From.Address != "dominik@example.org" { + expected := mail.Address{Name: "Dominik", Address: "dominik@example.org"} + t.Errorf("Expected From address to be %s, got %s", expected, m.From) + } + if m.sendmailPath != "/bin/true" { + t.Errorf("Expected sendmail to be %q, got %q", "/bin/true", m.sendmailPath) + } + if m.debugOut != &buf { + t.Errorf("Expected debugOut to be %T (buf), got %T", &buf, m.debugOut) + } +} + +func TestOptions(t *testing.T) { + m := &Mail{} + + o := Sendmail("/foo/bar", "--verbose") + if o.execute(m); m.sendmailPath != "/foo/bar" { + t.Errorf("Expected sendmail to be %q, got %q", "/foo/bar", m.sendmailPath) + } + if len(m.sendmailArgs) != 1 || m.sendmailArgs[0] != "--verbose" { + t.Errorf("Expected sendmail args to be %q, got %v", "--verbose", m.sendmailArgs) + } + + o = Debug(true) + if o.execute(m); m.debugOut != os.Stderr { + t.Errorf("Expected debugOut to be %T (stderr), got %T", os.Stderr, m.debugOut) + } + + o = Debug(false) + if o.execute(m); m.debugOut != nil { + t.Errorf("Expected debugOut to be nil, got %T", m.debugOut) + } + + var buf bytes.Buffer + o = DebugOutput(&buf) + if o.execute(m); m.debugOut != &buf { + t.Errorf("Expected debugOut to be %T (buf), got %T", &buf, m.debugOut) + } + + o = DebugOutput(nil) + if o.execute(m); m.debugOut != nil { + t.Errorf("Expected debugOut to be nil, got %T", m.debugOut) + } + + // To() appends list + o = To(&mail.Address{Name: "Ktoś", Address: "info@example.com"}) + if o.execute(m); len(m.To) != 1 { + t.Errorf("Expected len(To) to be 1, got %d: %+v", len(m.To), m.To) + } + o = To(&mail.Address{Name: "Ktoś2", Address: "info2@example.com"}) + if o.execute(m); len(m.To) != 2 { + t.Errorf("Expected len(To) to be 2, got %d: %+v", len(m.To), m.To) + } + + // From() updates current sender + o = From(&mail.Address{Name: "Michał", Address: "me@example.com"}) + if o.execute(m); m.From == nil || m.From.Address != "me@example.com" { + expected := mail.Address{Name: "Michał", Address: "me@example.com"} + t.Errorf("Expected From address to be %s, got %s", expected, m.From) + } + o = From(&mail.Address{Name: "Michał", Address: "me@example.com"}) + if o.execute(m); m.From == nil || m.From.Address != "me@example.com" { + expected := mail.Address{Name: "Michał", Address: "me@example.com"} + t.Errorf("Expected From address to be %s, got %s", expected, m.From) + } + + // Subject() updates current subject + o = Subject("Cześć") + if o.execute(m); m.Subject != "Cześć" { + t.Errorf("Expected Subject to be %q, got %q", "Cześć", m.Subject) + } + o = Subject("Test") + if o.execute(m); m.Subject != "Test" { + t.Errorf("Expected Subject to be %q, got %q", "Test", m.Subject) + } +} diff --git a/sendmail.go b/sendmail.go index 5f814ea..aab19fc 100644 --- a/sendmail.go +++ b/sendmail.go @@ -11,26 +11,39 @@ import ( "mime" "net/http" "net/mail" - "os" "os/exec" "strings" ) -var ( - _, debug = os.LookupEnv("DEBUG") - - // Binary points to the sendmail binary. - Binary = "/usr/sbin/sendmail" -) +// SendmailDefault points to the default sendmail binary location. +const SendmailDefault = "/usr/sbin/sendmail" // Mail defines basic mail structure and headers type Mail struct { Subject string From *mail.Address To []*mail.Address + CC []*mail.Address + BCC []*mail.Address Header http.Header Text bytes.Buffer HTML bytes.Buffer + + sendmailPath string + sendmailArgs []string + debugOut io.Writer +} + +// New creates a new Mail instance with the given options. +func New(options ...Option) (m *Mail) { + m = &Mail{ + Header: make(http.Header), + sendmailPath: SendmailDefault, + } + for _, option := range options { + option.execute(m) + } + return } // Send sends an email, or prints it on stderr, @@ -45,9 +58,9 @@ func (m *Mail) Send() error { if m.Header == nil { m.Header = make(http.Header) } - m.Header.Set("Content-Type", "text/plain; charset=UTF-8") m.Header.Set("Subject", mime.QEncoding.Encode("utf-8", m.Subject)) m.Header.Set("From", m.From.String()) + to := make([]string, len(m.To)) arg := make([]string, len(m.To)) for i, t := range m.To { @@ -55,26 +68,51 @@ func (m *Mail) Send() error { arg[i] = t.Address } m.Header.Set("To", strings.Join(to, ", ")) - if debug { - delimiter := "\n" + strings.Repeat("-", 70) - fmt.Println(delimiter) - m.WriteTo(os.Stdout) - fmt.Println(delimiter) - return nil - } - sendmail := exec.Command(Binary, arg...) - stdin, err := sendmail.StdinPipe() + + if cc := concatAddresses(m.CC); cc != "" { + m.Header.Set("CC", cc) + } + if bcc := concatAddresses(m.BCC); bcc != "" { + m.Header.Set("BCC", bcc) + } + + if m.debugOut != nil { + _, err := m.WriteTo(m.debugOut) + return err + } + + return m.exec(arg...) +} + +func concatAddresses(list []*mail.Address) string { + buf := make([]string, 0, len(list)) + for _, addr := range list { + buf = append(buf, addr.String()) + } + return strings.Join(buf, ", ") +} + +// exec handles sendmail command invokation. +func (m *Mail) exec(arg ...string) error { + bin := SendmailDefault + if m.sendmailPath != "" { + bin = m.sendmailPath + } + args := append(append([]string{}, m.sendmailArgs...), arg...) + cmd := exec.Command(bin, args...) + + stdin, err := cmd.StdinPipe() if err != nil { return err } - stderr, err := sendmail.StderrPipe() + stderr, err := cmd.StderrPipe() if err != nil { return err } - if err = sendmail.Start(); err != nil { + if err = cmd.Start(); err != nil { return err } - if err = m.WriteTo(stdin); err != nil { + if _, err = m.WriteTo(stdin); err != nil { return err } if err = stdin.Close(); err != nil { @@ -87,19 +125,62 @@ func (m *Mail) Send() error { if len(out) != 0 { return errors.New(string(out)) } - return sendmail.Wait() + return cmd.Wait() } // WriteTo writes headers and content of the email to io.Writer -func (m *Mail) WriteTo(wr io.Writer) error { - if err := m.Header.Write(wr); err != nil { - return err +func (m *Mail) WriteTo(wr io.Writer) (n int64, err error) { + isText := m.Text.Len() > 0 + isHTML := m.HTML.Len() > 0 + + if isText && isHTML { + err = fmt.Errorf("Multipart mails are not supported yet") + return + } else if isHTML { + m.Header.Set("Content-Type", "text/html; charset=UTF-8") + } else { + // also for mails without body + m.Header.Set("Content-Type", "text/plain; charset=UTF-8") } - if _, err := wr.Write([]byte("\r\n")); err != nil { - return err + + w := &writeCounter{w: wr} + + // write header + if err = m.Header.Write(w); err != nil { + return } - if _, err := m.Text.WriteTo(wr); err != nil { - return err + if _, err = w.Write([]byte("\r\n")); err != nil { + return + } + + if isText && isHTML { + // TODO + } else if isHTML { + if _, err = m.HTML.WriteTo(w); err != nil { + return + } + } else { + if _, err = m.Text.WriteTo(w); err != nil { + return + } + } + return w.n, nil +} + +// writeCounter is an internal type wrapping an io.Writer to work around +// the issue of net.http.Header.Write() not returning the number of bytes +// written. +type writeCounter struct { + n int64 + w io.Writer +} + +// Write satisfies the io.Writer interface. It updates an internal cache +// for the number of bytes written. +func (wc *writeCounter) Write(p []byte) (n int, err error) { + n, err = wc.w.Write(p) + if err == nil { + wc.n += int64(n) } - return nil + return } diff --git a/sendmail_test.go b/sendmail_test.go index f4efa54..1ebf6c2 100644 --- a/sendmail_test.go +++ b/sendmail_test.go @@ -1,9 +1,12 @@ package sendmail import ( + "bytes" "fmt" "io" "net/mail" + "os" + "strings" "testing" ) @@ -13,24 +16,18 @@ func maddr(name, address string) *mail.Address { return &mail.Address{Name: name, Address: address + domain} } -func init() { - Binary = "/bin/true" -} - func TestSend(tc *testing.T) { tc.Run("debug:true", func(t *testing.T) { + t.Parallel() testSend(t, true) }) tc.Run("debug:false", func(t *testing.T) { + t.Parallel() testSend(t, false) }) } func testSend(t *testing.T, withDebug bool) { - oldDebug := debug - debug = withDebug - defer func() { debug = oldDebug }() - sm := Mail{ Subject: "Cześć", From: maddr("Michał", "me@"), @@ -39,6 +36,8 @@ func testSend(t *testing.T, withDebug bool) { maddr("Ktoś2", "info2@"), }, } + sm.SetSendmail("/bin/true").SetDebug(withDebug) + io.WriteString(&sm.Text, ":)\r\n") if err := sm.Send(); err != nil { t.Errorf("(debug=%v) %v", withDebug, err) @@ -53,6 +52,85 @@ func testSend(t *testing.T, withDebug bool) { } } +func TestTextMail(t *testing.T) { + var buf bytes.Buffer + sm := New( + Subject("Cześć"), + From(maddr("Michał", "me@")), + To(maddr("Ktoś", "info@")), + To(maddr("Ktoś2", "info2@")), + DebugOutput(&buf), + ) + io.WriteString(&sm.Text, ":)\r\n") + + expected := strings.Join([]string{ + "Content-Type: text/plain; charset=UTF-8", + "From: =?utf-8?q?Micha=C5=82?= ", + "Subject: =?utf-8?q?Cze=C5=9B=C4=87?=", + "To: =?utf-8?q?Kto=C5=9B?= , =?utf-8?q?Kto=C5=9B2?= ", + "", + ":)", + "", + }, "\r\n") + + if err := sm.Send(); err != nil { + t.Errorf("Error writing to buffer: %v", err) + } + if actual := buf.String(); actual != expected { + t.Error("Unexpected mail content", actual) + } +} + +func TestHTMLMail(t *testing.T) { + var buf bytes.Buffer + sm := New( + Subject("Cześć"), + From(maddr("Michał", "me@")), + To(maddr("Ktoś", "info@")), + To(maddr("Ktoś2", "info2@")), + DebugOutput(&buf), + ) + io.WriteString(&sm.HTML, "

:)

\r\n") + + expected := strings.Join([]string{ + "Content-Type: text/html; charset=UTF-8", + "From: =?utf-8?q?Micha=C5=82?= ", + "Subject: =?utf-8?q?Cze=C5=9B=C4=87?=", + "To: =?utf-8?q?Kto=C5=9B?= , =?utf-8?q?Kto=C5=9B2?= ", + "", + "

:)

", + "", + }, "\r\n") + + if err := sm.Send(); err != nil { + t.Errorf("Error writing to buffer: %v", err) + } + if actual := buf.String(); actual != expected { + fmt.Fprintln(os.Stderr, actual) + t.Errorf("Unexpected mail content") + } +} + +func TestWriteTo(t *testing.T) { + var buf bytes.Buffer + sm := New( + Subject("Cześć"), + From(maddr("Michał", "me@")), + To(maddr("Ktoś", "info@")), + To(maddr("Ktoś2", "info2@")), + DebugOutput(&buf), + ) + io.WriteString(&sm.Text, ":)\r\n") + + actual, err := sm.WriteTo(&buf) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if expected := int64(buf.Len()); actual != expected { + t.Errorf("expeted to have written %d bytes, got %d", expected, actual) + } +} + func TestFromError(t *testing.T) { sm := Mail{ To: []*mail.Address{maddr("Ktoś", "info@")}, @@ -70,3 +148,31 @@ func TestToError(t *testing.T) { t.Errorf("Expected an error because of missing `To` addresses") } } + +func TestNew(t *testing.T) { + var buf bytes.Buffer + m := New( + Subject("Test subject"), + From(maddr("Dominik", "dominik@")), + To(maddr("Dominik2", "dominik2@")), + DebugOutput(&buf), + Sendmail("/bin/true"), + ) + + if m.Subject != "Test subject" { + t.Errorf("Expected subject to be %q, got %q", "Test subject", m.Subject) + } + if len(m.To) != 1 { + t.Errorf("Expected len(To) to be 1, got %d: %+v", len(m.To), m.To) + } + if m.From == nil || m.From.Address != "dominik@example.com" { + expected := mail.Address{Name: "Dominik", Address: "dominik@example.com"} + t.Errorf("Expected From address to be %s, got %s", expected, m.From) + } + if m.sendmailPath != "/bin/true" { + t.Errorf("Expected sendmail to be %q, got %q", "/bin/true", m.sendmailPath) + } + if m.debugOut != &buf { + t.Errorf("Expected debugOut to be %T (buf), got %T", &buf, m.debugOut) + } +} diff --git a/validate_test.go b/validate_test.go index 11ab083..b663edf 100644 --- a/validate_test.go +++ b/validate_test.go @@ -22,9 +22,9 @@ func TestValidate(tc *testing.T) { e := email t.Parallel() err := Validate(e.Address) - if err == nil && e.IsValid == false { + if err == nil && !e.IsValid { t.Errorf("Email `%s` is valid, but should be invalid", e.Address) - } else if err != nil && e.IsValid == true { + } else if err != nil && e.IsValid { t.Errorf("Email `%s` is invalid, but should be valid", e.Address) } })