-
-
Notifications
You must be signed in to change notification settings - Fork 84
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
5 changed files
with
299 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters