Skip to content

Commit

Permalink
Email sender (#27)
Browse files Browse the repository at this point in the history
* add basic email client

* fix closing flow for email

* add comments to email client

* add smtp interface to allow more tests

* safer logic to set quit flag in email.Send

* real email test only if SEND_EMAIL_TEST in env

* more tests for email

* add infor about email sender

* remove exact match on error in email test

* typo
  • Loading branch information
umputun authored Jul 3, 2019
1 parent 5dfc2b4 commit e0082a6
Show file tree
Hide file tree
Showing 5 changed files with 299 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ before_install:
script:
- GO111MODULE=on go get ./...
- GO111MODULE=on go mod vendor
- GO111MODULE=on go test -v -mod=vendor -covermode=count -coverprofile=profile.cov ./... || travis_terminate 1;
- GO111MODULE=on go test -v -mod=vendor -covermode=count -coverprofile=profile.cov ./... || travis_terminate 1;
- golangci-lint run --tests=false || travis_terminate 1;
- $GOPATH/bin/goveralls -coverprofile=profile.cov -service=travis-ci
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This library provides "social login" with Github, Google, Facebook and Yandex as
- JWT stored in a secure cookie with XSRF protection. Cookies can be session-only
- Minimal scopes with user name, id and picture (avatar) only
- Direct authentication with user's provided credential checker
- Confirmed authentication with user's provided sender (email, im, etc)
- Verified authentication with user's provided sender (email, im, etc)
- Integrated avatar proxy with FS, boltdb and gridfs storage
- Support of user-defined storage for avatars
- Identicon for default avatars
Expand Down Expand Up @@ -173,7 +173,8 @@ type Sender interface {
}
```

For convenience a functional wrapper `SenderFunc` provided.
For convenience a functional wrapper `SenderFunc` provided. Email sender provided in `provider/sender` package and can be
used as `Sender`.

The API for this provider:

Expand Down Expand Up @@ -302,6 +303,7 @@ _instructions for google oauth2 setup borrowed from [oauth2_proxy](https://githu
For more details refer to [Yandex OAuth](https://tech.yandex.com/oauth/doc/dg/concepts/about-docpage/) and [Yandex.Passport](https://tech.yandex.com/passport/doc/dg/index-docpage/) API documentation.



## Status

The library extracted from [remark42](https://github.com/umputun/remark) project. The original code in production use on multiple sites and seems to work fine.
Expand Down
151 changes: 151 additions & 0 deletions provider/sender/email.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package sender

import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net"
"net/smtp"
"time"

"github.com/pkg/errors"

"github.com/go-pkgz/auth/logger"
)

// Email implements sender interface for VerifyHandler
// Uses common subject line and "from" for all messages
type Email struct {
logger.L
SMTPClient
EmailParams
}

// EmailParams with all needed to make new Email client with smtp
type EmailParams struct {
Host string // SMTP host
Port int // SMTP port
From string // From email field
Subject string // Email subject
ContentType string // Content type, optional. Will trigger MIME and Content-Type headers

TLS bool // TLS auth
SMTPUserName string // user name
SMTPPassword string // password
TimeOut time.Duration
}

// SMTPClient interface defines subset of net/smtp used by email client
type SMTPClient interface {
Mail(string) error
Auth(smtp.Auth) error
Rcpt(string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}

// NewEmailClient creates email client with prepared smtp
func NewEmailClient(p EmailParams, l logger.L) *Email {
return &Email{EmailParams: p, L: l, SMTPClient: nil}
}

// Send email with given text
// If SMTPClient defined in Email struct it will be used, if not - new smtp.Client on each send.
// Always closes client on completion or failure.
func (em *Email) Send(to string, text string) error {

client := em.SMTPClient
if client == nil { // if client not set make new net/smtp
c, err := em.client()
if err != nil {
return errors.Wrap(err, "failed to make smtp client")
}
client = c
}

var quit bool
defer func() {
if quit { // quit set if Quit() call passed because it's closing connection as well.
return
}
if err := client.Close(); err != nil {
em.Logf("[WARN] can't close smtp connection, %v", err)
}
}()

if em.SMTPUserName != "" && em.SMTPPassword != "" {
auth := smtp.PlainAuth("", em.SMTPUserName, em.SMTPPassword, em.Host)
if err := client.Auth(auth); err != nil {
return errors.Wrapf(err, "failed to auth to smtp %s:%d", em.Host, em.Port)
}
}

if err := client.Mail(em.From); err != nil {
return errors.Wrapf(err, "bad from address %q", em.From)
}
if err := client.Rcpt(to); err != nil {
return errors.Wrapf(err, "bad to address %q", to)
}

writer, err := client.Data()
if err != nil {
return errors.Wrap(err, "can't make email writer")
}

buf := bytes.NewBufferString(em.buildMessage(text, to))
if _, err = buf.WriteTo(writer); err != nil {
return errors.Wrapf(err, "failed to send email body to %q", to)
}
if err = writer.Close(); err != nil {
em.Logf("[WARN] can't close smtp body writer, %v", err)
}

if err = client.Quit(); err != nil {
em.Logf("[WARN] failed to send quit command to %s:%d, %v", em.Host, em.Port, err)
} else {
quit = true
}
return nil
}

func (em *Email) client() (c *smtp.Client, err error) {
srvAddress := fmt.Sprintf("%s:%d", em.Host, em.Port)
if em.TLS {
tlsConf := &tls.Config{
InsecureSkipVerify: false,
ServerName: em.Host,
}
conn, err := tls.Dial("tcp", srvAddress, tlsConf)
if err != nil {
return nil, errors.Wrapf(err, "failed to dial smtp tls to %s", srvAddress)
}
if c, err = smtp.NewClient(conn, em.Host); err != nil {
return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress)
}
return c, nil
}

conn, err := net.DialTimeout("tcp", srvAddress, em.TimeOut)
if err != nil {
return nil, errors.Wrapf(err, "timeout connecting to %s", srvAddress)
}

c, err = smtp.NewClient(conn, srvAddress)
if err != nil {
return nil, errors.Wrap(err, "failed to dial")
}
return c, nil
}

func (em *Email) buildMessage(msg string, to string) (message string) {
message += fmt.Sprintf("From: %s\n", em.From)
message += fmt.Sprintf("To: %s\n", to)
message += fmt.Sprintf("Subject: %s\n", em.Subject)
if em.ContentType != "" {
message += fmt.Sprintf("MIME-version: 1.0;\nContent-Type: %s; charset=\"UTF-8\";\n", em.ContentType)
}
message += "\n" + msg
return message
}
141 changes: 141 additions & 0 deletions provider/sender/email_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package sender

import (
"bytes"
"io"
"net/smtp"
"os"
"testing"
"time"

"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/go-pkgz/auth/logger"
)

func TestEmailSend(t *testing.T) {
if _, ok := os.LookupEnv("SEND_EMAIL_TEST"); !ok {
t.Skip()
}
p := EmailParams{
From: "[email protected]",
ContentType: "text/html",
Host: "192.168.1.24",
Port: 25,
Subject: "test email",
}
client := NewEmailClient(p, logger.Std)

msg := `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<body>
<h2>rest</h2>
<pre>xyz</pre>
</body>
</html>
`
err := client.Send("[email protected]", msg)
assert.NoError(t, err)
}

func TestEmail_buildMessage(t *testing.T) {
p := EmailParams{From: "[email protected]", Subject: "subj"}
e := Email{L: logger.Std, EmailParams: p}

msg := e.buildMessage("this is a test\n12345", "[email protected]")
assert.Equal(t, "From: [email protected]\nTo: [email protected]\nSubject: subj\n\nthis is a test\n12345", msg)
}

func TestEmail_buildMessageWithMIME(t *testing.T) {

p := EmailParams{From: "[email protected]", Subject: "subj", ContentType: "text/html"}
e := Email{L: logger.Std, EmailParams: p}

msg := e.buildMessage("this is a test\n12345", "[email protected]")
assert.Equal(t, "From: [email protected]\nTo: [email protected]\nSubject: subj\nMIME-version: 1."+
"0;\nContent-Type: text/html; charset=\"UTF-8\";\n\nthis is a test\n12345", msg)
}

func TestEmail_New(t *testing.T) {
p := EmailParams{Host: "127.0.0.2", From: "[email protected]", Subject: "subj", ContentType: "text/html"}
e := NewEmailClient(p, logger.Std)
assert.Equal(t, p, e.EmailParams)
}

func TestEmail_Send(t *testing.T) {
fakeSmtp := &fakeTestSmtp{}
p := EmailParams{From: "[email protected]", Subject: "subj", ContentType: "text/html",
SMTPUserName: "user", SMTPPassword: "passwd"}
e := Email{L: logger.Std, EmailParams: p, SMTPClient: fakeSmtp}
err := e.Send("[email protected]", "some text")
require.NoError(t, err)

assert.Equal(t, "[email protected]", fakeSmtp.mail)
assert.Equal(t, "[email protected]", fakeSmtp.rcpt)
assert.Equal(t, "From: [email protected]\nTo: [email protected]\nSubject: subj\n"+
"MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\nsome text", fakeSmtp.buff.String())
assert.True(t, fakeSmtp.auth)
assert.True(t, fakeSmtp.quit)
assert.False(t, fakeSmtp.close)
}

func TestEmail_SendFailed(t *testing.T) {
fakeSmtp := &fakeTestSmtp{fail: true}
p := EmailParams{From: "[email protected]", Subject: "subj", ContentType: "text/html"}
e := Email{L: logger.Std, EmailParams: p, SMTPClient: fakeSmtp}
err := e.Send("[email protected]", "some text")
require.EqualError(t, err, "can't make email writer: failed")

assert.Equal(t, "[email protected]", fakeSmtp.mail)
assert.Equal(t, "[email protected]", fakeSmtp.rcpt)
assert.Equal(t, "", fakeSmtp.buff.String())
assert.False(t, fakeSmtp.auth)
assert.False(t, fakeSmtp.quit)
assert.True(t, fakeSmtp.close)
}

func TestEmail_SendFailed2(t *testing.T) {
p := EmailParams{Host: "127.0.0.2", Port: 25, From: "[email protected]",
Subject: "subj", ContentType: "text/html", TimeOut: time.Millisecond * 200}
e := NewEmailClient(p, logger.Std)
assert.Equal(t, p, e.EmailParams)
err := e.Send("[email protected]", "some text")
require.NotNil(t, err, "failed to make smtp client")

p = EmailParams{Host: "127.0.0.1", Port: 225, From: "[email protected]", Subject: "subj", ContentType: "text/html",
TLS: true}
e = NewEmailClient(p, logger.Std)
err = e.Send("[email protected]", "some text")
require.NotNil(t, err)
}

type fakeTestSmtp struct {
fail bool

buff bytes.Buffer
mail, rcpt string
auth bool
quit, close bool
}

func (f *fakeTestSmtp) Mail(m string) error { f.mail = m; return nil }
func (f *fakeTestSmtp) Auth(smtp.Auth) error { f.auth = true; return nil }
func (f *fakeTestSmtp) Rcpt(r string) error { f.rcpt = r; return nil }
func (f *fakeTestSmtp) Quit() error { f.quit = true; return nil }
func (f *fakeTestSmtp) Close() error { f.close = true; return nil }

func (f *fakeTestSmtp) Data() (io.WriteCloser, error) {
if f.fail {
return nil, errors.New("failed")
}
return nopCloser{&f.buff}, nil
}

type nopCloser struct {
io.Writer
}

func (nopCloser) Close() error { return nil }
4 changes: 2 additions & 2 deletions provider/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func (e VerifyHandler) sendConfirmation(w http.ResponseWriter, r *http.Request)
return
}

tmpl := emailTemplate
tmpl := msgTemplate
if e.Template != "" {
tmpl = e.Template
}
Expand Down Expand Up @@ -195,7 +195,7 @@ func (e VerifyHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
e.TokenService.Reset(w)
}

var emailTemplate = `
var msgTemplate = `
Remark42 confirmation for {{.User}} {{.Address}}, site {{.Site}}
Token: {{.Token}}
Expand Down

0 comments on commit e0082a6

Please sign in to comment.