feat: email service
This commit is contained in:
parent
9bc1fc0c66
commit
42644cc0c8
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
this is txt 1 attachment
|
|
@ -0,0 +1 @@
|
||||||
|
this is txt 2 attachment
|
|
@ -0,0 +1 @@
|
||||||
|
this is txt 3 attachment
|
12
go.mod
12
go.mod
|
@ -1,3 +1,15 @@
|
||||||
module gitea.urkob.com/urko/mail-sender
|
module gitea.urkob.com/urko/mail-sender
|
||||||
|
|
||||||
go 1.21.1
|
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
|
||||||
|
)
|
||||||
|
|
|
@ -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=
|
Loading…
Reference in New Issue