From 42644cc0c89dd35c7b3fb75da416fbc76c572edb Mon Sep 17 00:00:00 2001 From: "Urko." Date: Sat, 21 Oct 2023 18:45:33 +0200 Subject: [PATCH] feat: email service --- email/email.go | 220 +++++++++++++++++++++++++++++++++ email/email_test.go | 171 +++++++++++++++++++++++++ email/testdata/attachment1.txt | 1 + email/testdata/attachment2.txt | 1 + email/testdata/attachment3.txt | 1 + go.mod | 12 ++ go.sum | 14 +++ 7 files changed, 420 insertions(+) create mode 100755 email/email.go create mode 100755 email/email_test.go create mode 100755 email/testdata/attachment1.txt create mode 100755 email/testdata/attachment2.txt create mode 100755 email/testdata/attachment3.txt create mode 100644 go.sum diff --git a/email/email.go b/email/email.go new file mode 100755 index 0000000..b7752a6 --- /dev/null +++ b/email/email.go @@ -0,0 +1,220 @@ +package email + +import ( + "bytes" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "net/smtp" +) + +const ( + mime = "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" + delimeter = "**=myohmy689407924327" +) + +type smtpClient interface { + StartTLS(*tls.Config) error + Auth(a smtp.Auth) error + Close() error + Data() (io.WriteCloser, error) + Mail(from string) error + Quit() error + Rcpt(to string) error +} + +type SmtpDialFn func(hostPort string) (smtpClient, error) + +type EmailService struct { + auth smtp.Auth + host string + port string + from string + tlsconfig *tls.Config + dial SmtpDialFn +} + +type SMTPClient struct { + client smtp.Client +} + +func (c *SMTPClient) Auth(a smtp.Auth) error { + return c.client.Auth(a) +} +func (c *SMTPClient) Close() error { + return c.client.Close() +} +func (c *SMTPClient) Data() (io.WriteCloser, error) { + return c.client.Data() +} +func (c *SMTPClient) Mail(from string) error { + return c.client.Mail(from) +} + +func (c *SMTPClient) Quit() error { + return c.client.Quit() +} +func (c *SMTPClient) Rcpt(to string) error { + return c.client.Rcpt(to) +} + +func (c *SMTPClient) StartTLS(config *tls.Config) error { + return c.client.StartTLS(config) +} + +type EmailMessage struct { + To string + Subject string + Body string + Attachments []EmailAttachment +} + +type EmailAttachment struct { + File io.Reader + Title string +} + +func (e EmailAttachment) ReadContent() ([]byte, error) { + bts, err := io.ReadAll(e.File) + if err != nil { + return nil, fmt.Errorf("error loading attachment: %s", err) + } + return bts, nil +} + +type MailServiceConfig struct { + Auth smtp.Auth + Host string + Port string + From string // Sender email address +} + +func dial(hostPort string) (smtpClient, error) { + client, err := smtp.Dial(hostPort) + if err != nil { + return nil, err + } + return client, nil +} + +func NewMailService(config MailServiceConfig) *EmailService { + return &EmailService{ + auth: config.Auth, + host: config.Host, + port: config.Port, + from: config.From, + tlsconfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: config.Host, + }, + dial: dial, + } +} + +func (e *EmailService) SendEmail(emailData EmailMessage) error { + msg, err := newMessage(e.from, emailData.To, emailData.Subject). + withAttachments(emailData.Body, emailData.Attachments) + + if err != nil { + return fmt.Errorf("error while preparing email: %w", err) + } + + return e.send(emailData.To, msg) +} + +func (e *EmailService) send(to string, msg []byte) error { + c, err := e.dial(e.host + ":" + e.port) + if err != nil { + return fmt.Errorf("DIAL: %s", err) + } + + if err = c.StartTLS(e.tlsconfig); err != nil { + return fmt.Errorf("c.StartTLS: %s", err) + } + + // Auth + if err = c.Auth(e.auth); err != nil { + return fmt.Errorf("c.Auth: %s", err) + } + + // To && From + if err = c.Mail(e.from); err != nil { + return fmt.Errorf("c.Mail: %s", err) + } + + if err = c.Rcpt(to); err != nil { + return fmt.Errorf("c.Rcpt: %s", err) + } + + // Data + w, err := c.Data() + if err != nil { + return fmt.Errorf("c.Data: %s", err) + } + + written, err := w.Write(msg) + if err != nil { + return fmt.Errorf("w.Write: %s", err) + } + + if written <= 0 { + return fmt.Errorf("%d bytes written", written) + } + + if err = w.Close(); err != nil { + return fmt.Errorf("w.Close: %s", err) + } + + if err = c.Quit(); err != nil { + return fmt.Errorf("w.Quit: %s", err) + } + return nil +} + +type message struct { + from string + to string + subject string +} + +func newMessage(from, to, subject string) message { + return message{from: from, to: to, subject: subject} +} + +func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) { + headers := make(map[string]string) + headers["From"] = m.from + headers["To"] = m.to + headers["Subject"] = m.subject + headers["MIME-Version"] = "1.0" + + var message bytes.Buffer + + for k, v := range headers { + message.WriteString(k) + message.WriteString(": ") + message.WriteString(v) + message.WriteString("\r\n") + } + + message.WriteString("Content-Type: " + fmt.Sprintf("multipart/mixed; boundary=\"%s\"\r\n", delimeter)) + message.WriteString("--" + delimeter + "\r\n") + message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n") + message.WriteString(body + "\r\n\r\n") + + for _, attachment := range attachments { + attachmentRawFile, err := attachment.ReadContent() + if err != nil { + return nil, err + } + message.WriteString("--" + delimeter + "\r\n") + message.WriteString("Content-Disposition: attachment; filename=\"" + attachment.Title + "\"\r\n") + message.WriteString("Content-Type: application/octet-stream\r\n") + message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") + message.WriteString(base64.StdEncoding.EncodeToString(attachmentRawFile) + "\r\n") + } + + message.WriteString("--" + delimeter + "--") // End the message + return message.Bytes(), nil +} diff --git a/email/email_test.go b/email/email_test.go new file mode 100755 index 0000000..86e6cf2 --- /dev/null +++ b/email/email_test.go @@ -0,0 +1,171 @@ +package email + +import ( + "crypto/tls" + "fmt" + "io" + "net/smtp" + "os" + "testing" + + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type config struct { + MailUser string `required:"false" split_words:"true"` + MailPassword string `required:"false" split_words:"true"` + MailHost string `required:"false" split_words:"true"` + MailPort string `required:"false" split_words:"true"` + MailFrom string `required:"false" split_words:"true"` + MailTo string `required:"false" split_words:"true"` +} + +func newConfig(envFile string) *config { + if envFile != "" { + err := godotenv.Load(envFile) + if err != nil { + panic(fmt.Errorf("godotenv.Load: %w", err)) + } + } + + cfg := &config{} + err := envconfig.Process("", cfg) + if err != nil { + panic(fmt.Errorf("envconfig.Process: %w", err)) + } + + return cfg +} + +type mockWriter struct{} + +func (w *mockWriter) Close() error { + return nil +} +func (w *mockWriter) Write(p []byte) (n int, err error) { + return 10, nil +} + +// Mock SMTP Client +type mockSMTP struct{} + +func (m *mockSMTP) StartTLS(*tls.Config) error { + return nil +} +func (m *mockSMTP) Auth(a smtp.Auth) error { + return nil +} +func (m *mockSMTP) Close() error { + return nil +} +func (m *mockSMTP) Data() (io.WriteCloser, error) { + return &mockWriter{}, nil +} +func (m *mockSMTP) Mail(from string) error { + return nil +} +func (m *mockSMTP) Quit() error { + return nil +} +func (m *mockSMTP) Rcpt(to string) error { + return nil +} + +func TestMockSendEmail(t *testing.T) { + service := &EmailService{ + auth: smtp.PlainAuth("", "", "", ""), + host: "", + port: "", + from: "", + tlsconfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: "", + }, + dial: func(hostPort string) (smtpClient, error) { + return &mockSMTP{}, nil + }, + } + + emailData := EmailMessage{ + To: "test@example.com", + Subject: "Test Email", + Body: "This is a test email.", + } + + err := service.SendEmail(emailData) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestSendEmail(t *testing.T) { + cfg := newConfig(".env.test") + + mailSrv := NewMailService(MailServiceConfig{ + Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost), + Host: cfg.MailHost, + Port: cfg.MailPort, + From: cfg.MailFrom, + }) + + data := EmailMessage{ + To: cfg.MailTo, + Subject: "Mail Sender", + Body: "Hello this is a test email", + } + require.NoError(t, mailSrv.SendEmail(data)) +} + +func TestSendEmailWithAttachments(t *testing.T) { + cfg := newConfig(".env.test") + + mailSrv := NewMailService(MailServiceConfig{ + Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost), + Host: cfg.MailHost, + Port: cfg.MailPort, + From: cfg.MailFrom, + }) + reader, err := os.Open("testdata/attachment1.txt") + require.NoError(t, err) + defer reader.Close() + + reader2, err := os.Open("testdata/attachment2.txt") + require.NoError(t, err) + defer reader2.Close() + + reader3, err := os.Open("testdata/attachment3.txt") + require.NoError(t, err) + defer reader3.Close() + + data := EmailMessage{ + To: cfg.MailTo, + Subject: "Mail Sender", + Body: "Hello this is a test email", + Attachments: []EmailAttachment{ + { + Title: "attachment1.txt", + File: reader, + }, + { + Title: "attachment2.txt", + File: reader2, + }, + { + Title: "attachment3.txt", + File: reader3, + }, + }, + } + err = mailSrv.SendEmail(data) + require.NoError(t, err) +} + +func TestWithAttachments(t *testing.T) { + msg := newMessage("from", "to", "subject") + content, err := msg.withAttachments("body", nil) + require.NoError(t, err) + assert.Greater(t, len(content), 0) +} diff --git a/email/testdata/attachment1.txt b/email/testdata/attachment1.txt new file mode 100755 index 0000000..d2991c3 --- /dev/null +++ b/email/testdata/attachment1.txt @@ -0,0 +1 @@ +this is txt 1 attachment \ No newline at end of file diff --git a/email/testdata/attachment2.txt b/email/testdata/attachment2.txt new file mode 100755 index 0000000..543a4b4 --- /dev/null +++ b/email/testdata/attachment2.txt @@ -0,0 +1 @@ +this is txt 2 attachment \ No newline at end of file diff --git a/email/testdata/attachment3.txt b/email/testdata/attachment3.txt new file mode 100755 index 0000000..9c91f63 --- /dev/null +++ b/email/testdata/attachment3.txt @@ -0,0 +1 @@ +this is txt 3 attachment \ No newline at end of file diff --git a/go.mod b/go.mod index 7224df4..a304705 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module gitea.urkob.com/urko/mail-sender go 1.21.1 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..09dbd53 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=