diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39936ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.idea +vendor +.golangci*.yml +linter.mk +bin +c.out +tests.mk \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9dbad96 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +# ---- +## LINTER stuff start + +linter_include_check: + @[ -f linter.mk ] && echo "linter.mk include exists" || (echo "getting linter.mk from github.com" && curl -sO https://raw.githubusercontent.com/spacetab-io/makefiles/master/golang/linter.mk) + +.PHONY: lint +lint: linter_include_check + @make -f linter.mk go_lint + +## LINTER stuff end +# ---- + +# ---- +## TESTS stuff start + +tests_include_check: + @[ -f tests.mk ] && echo "tests.mk include exists" || (echo "getting tests.mk from github.com" && curl -sO https://raw.githubusercontent.com/spacetab-io/makefiles/master/golang/tests.mk) + +tests: tests_include_check + @make -f tests.mk go_tests +.PHONY: tests + +tests_html: tests_include_check + @make -f tests.mk go_tests_html +.PHONY: tests_html + +## TESTS stuff end +# ---- \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..58a20af --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# mails-go + +Golang library for email sending. + +## Providers + +List of available providers: + +* [Sendgrid](github.com/sendgrid/sendgrid-go) +* [Mandrill](github.com/mattbaird/gochimp) +* [Mailgun](github.com/mailgun/mailgun-go/v4) +* [SMTP](github.com/xhit/go-simple-mail/v2) +* log +* file + +## Usage + +```go +package main + +import ( + "context" + "time" + + "github.com/spacetab-io/configuration-structs-go/v2/configuration/mimetype" + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/mails-go/contracts" + "github.com/spacetab-io/mails-go/providers" +) + +func main() { + // 1. Get Provider config (with should implement mailing.MailProviderConfigInterface + // For Example, Sendgrid Config + sendgridCfg := mailing.SendgridConfig{ + Enabled: true, + Key: "APIKey", + SendTimeout: 5 * time.Second, + } + + // 2. Initiate provider + // Sendgrid provider + sendgrid, err := providers.NewSendgrid(sendgridCfg) + if err != nil { + panic(err) + } + + // 3. Prepare Message with should implement contracts.MessageInterface + msg := contracts.Message{ + To: mailing.MailAddressList{ + {Email: "toOne@spacetab.io", Name: "To One"}, + {Email: "totwo@spacetab.io", Name: "To Two"}, + }, + MimeType: mime.TextPlain, + Subject: "Test email", + Content: []byte("test email content"), + } + + // 4. Send message + if err := sendgrid.Send(context.Background(), msg); err != nil { + panic(err) + } +} +``` \ No newline at end of file diff --git a/contracts/attachment.go b/contracts/attachment.go new file mode 100644 index 0000000..50d3d09 --- /dev/null +++ b/contracts/attachment.go @@ -0,0 +1,77 @@ +package contracts + +import ( + "os" + "path/filepath" + "strings" + + mime "github.com/gabriel-vasile/mimetype" + "github.com/spacetab-io/mails-go/errors" +) + +type AttachMethod string + +const ( + AttachMethodInline AttachMethod = "inline" + AttachMethodFile AttachMethod = "file" +) + +type Attachment struct { + MimeType string + AttachMethod AttachMethod + Filename string + Name string + Extension string + Content []byte +} + +func NewAttachmentFromFile(filePath string) (a Attachment, err error) { + fi, err := os.Stat(filePath) + if err != nil { + return + } + + if fi.IsDir() { + return a, errors.ErrAttachmentIsNotAFile + } + + a.Content, err = os.ReadFile(filePath) + if err != nil { + return + } + + fileName := fi.Name() + m, _ := mime.DetectFile(filePath) + + a.MimeType = m.String() + a.Extension = strings.Trim(filepath.Ext(filePath), ".") + a.Name = fileName[:len(fileName)-len(filepath.Ext(fileName))] + a.Filename = fi.Name() + a.AttachMethod = AttachMethodFile + + return a, nil +} + +func (a Attachment) IsEmpty() bool { + return len(a.Content) == 0 +} + +func (a Attachment) GetFileName() string { + return a.Filename +} + +func (a Attachment) GetMimeType() string { + return a.MimeType +} + +func (a Attachment) GetContent() []byte { + return a.Content +} + +func (a Attachment) GetName() string { + return a.Name +} + +func (a Attachment) GetAttachMethod() AttachMethod { + return a.AttachMethod +} diff --git a/contracts/attachmentInterface.go b/contracts/attachmentInterface.go new file mode 100644 index 0000000..df4d031 --- /dev/null +++ b/contracts/attachmentInterface.go @@ -0,0 +1,16 @@ +package contracts + +type MessageAttachmentListInterface interface { + GetList() []MessageAttachmentInterface + IsEmpty() bool + GetFileNames() []string +} + +type MessageAttachmentInterface interface { + IsEmpty() bool + GetFileName() string + GetName() string + GetMimeType() string + GetContent() []byte + GetAttachMethod() AttachMethod +} diff --git a/contracts/attachmentList.go b/contracts/attachmentList.go new file mode 100644 index 0000000..1af61d4 --- /dev/null +++ b/contracts/attachmentList.go @@ -0,0 +1,31 @@ +package contracts + +type MessageAttachmentList []Attachment + +func (mal MessageAttachmentList) GetFileNames() []string { + if mal.IsEmpty() { + return nil + } + + fileNames := make([]string, 0, len(mal.GetList())) + + for _, file := range mal.GetList() { + fileNames = append(fileNames, file.GetFileName()) + } + + return fileNames +} + +func (mal MessageAttachmentList) GetList() []MessageAttachmentInterface { + mm := make([]MessageAttachmentInterface, 0, len(mal)) + + for _, ma := range mal { + mm = append(mm, ma) + } + + return mm +} + +func (mal MessageAttachmentList) IsEmpty() bool { + return len(mal) == 0 +} diff --git a/contracts/attachment_test.go b/contracts/attachment_test.go new file mode 100644 index 0000000..1c4ff1e --- /dev/null +++ b/contracts/attachment_test.go @@ -0,0 +1,146 @@ +package contracts_test + +import ( + "os" + "testing" + + "github.com/spacetab-io/mails-go/contracts" + "github.com/spacetab-io/mails-go/errors" + "github.com/stretchr/testify/assert" +) + +var testAtt = contracts.Attachment{ + MimeType: "text/plain; charset=utf-8", + AttachMethod: contracts.AttachMethodFile, + Filename: "test.file", + Name: "test", + Extension: "file", + Content: []byte("some content"), +} + +func TestNewAttachmentFromFile(t *testing.T) { + type testCase struct { + name string + in string + exp contracts.Attachment + err error + } + + tcs := []testCase{ + { + name: "normal attachment", + in: "./test.file", + exp: contracts.Attachment{ + MimeType: "text/plain; charset=utf-8", + AttachMethod: contracts.AttachMethodFile, + Name: "test", + Filename: "test.file", + Extension: "file", + Content: []byte("some content"), + }, + err: nil, + }, + { + name: "not existing file attachment", + in: "./not_existing_test.file", + exp: contracts.Attachment{}, + err: os.ErrNotExist, + }, + { + name: "attachment is a dir", + in: "../contracts", + exp: contracts.Attachment{}, + err: errors.ErrAttachmentIsNotAFile, + }, + //{ + // name: "not available to read file", + // in: "./test_no_access.file", + // exp: contracts.Attachment{}, + // err: os.ErrPermission, + // }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + att, err := contracts.NewAttachmentFromFile(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, err, tc.err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, att) + }) + } +} + +func TestAttachment_IsEmpty(t *testing.T) { + type testCase struct { + name string + in contracts.Attachment + exp bool + } + + tcs := []testCase{ + { + name: "empty attachment", + in: contracts.Attachment{}, + exp: true, + }, + { + name: "filled attachment", + in: testAtt, + exp: false, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.exp, tc.in.IsEmpty()) + }) + } +} + +func TestAttachment_GetFileName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "test.file", testAtt.GetFileName()) +} + +func TestAttachment_GetMimeType(t *testing.T) { + t.Parallel() + + assert.Equal(t, "text/plain; charset=utf-8", testAtt.GetMimeType()) +} + +func TestAttachment_GetName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "test", testAtt.GetName()) +} + +func TestAttachment_GetAttachMethod(t *testing.T) { + t.Parallel() + + assert.Equal(t, contracts.AttachMethodFile, testAtt.GetAttachMethod()) +} + +func TestAttachment_GetContent(t *testing.T) { + t.Parallel() + + assert.Equal(t, []byte("some content"), testAtt.GetContent()) +} diff --git a/contracts/loggerInterface.go b/contracts/loggerInterface.go new file mode 100644 index 0000000..d7d5bd7 --- /dev/null +++ b/contracts/loggerInterface.go @@ -0,0 +1,6 @@ +package contracts + +type LoggerInterface interface { + Print(log string) + Printf(format string, args ...interface{}) +} diff --git a/contracts/message.go b/contracts/message.go new file mode 100644 index 0000000..a303937 --- /dev/null +++ b/contracts/message.go @@ -0,0 +1,220 @@ +package contracts + +import ( + "fmt" + "strings" + + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/configuration-structs-go/v2/mime" + "github.com/spacetab-io/mails-go/errors" +) + +type Message struct { + From mailing.MailAddress + ReplyTo mailing.MailAddress + To mailing.MailAddressList + Cc mailing.MailAddressList + Bcc mailing.MailAddressList + + MimeType mime.Type + Subject string + Content []byte + + Attachments MessageAttachmentList +} + +func (mm *Message) SetFrom(addr mailing.MailAddressInterface) error { + if addr.IsEmpty() { + return errors.ErrEmptyAddress + } + + mm.From = mailing.NewMailAddressFromInterface(addr) + + return nil +} + +func (mm *Message) SetTo(addr mailing.MailAddressInterface) error { + if addr.IsEmpty() { + return errors.ErrEmptyAddress + } + + mm.To = append(mm.To, mailing.NewMailAddressFromInterface(addr)) + + return nil +} + +func (mm *Message) SetTos(addrs mailing.MailAddressListInterface) { + for _, addr := range addrs.GetList() { + mm.To = append(mm.To, mailing.NewMailAddressFromInterface(addr)) + } +} + +func (mm *Message) SetCc(addr mailing.MailAddressInterface) error { + if addr.IsEmpty() { + return errors.ErrEmptyAddress + } + + mm.Cc = append(mm.Cc, mailing.NewMailAddressFromInterface(addr)) + + return nil +} + +func (mm *Message) SetCcs(addrs mailing.MailAddressListInterface) { + for _, addr := range addrs.GetList() { + mm.Cc = append(mm.Cc, mailing.NewMailAddressFromInterface(addr)) + } +} + +func (mm *Message) SetBcc(addr mailing.MailAddressInterface) error { + if addr.IsEmpty() { + return errors.ErrEmptyAddress + } + + mm.Bcc = append(mm.Bcc, mailing.NewMailAddressFromInterface(addr)) + + return nil +} + +func (mm *Message) SetBccs(addrs mailing.MailAddressListInterface) { + for _, addr := range addrs.GetList() { + mm.Bcc = append(mm.Bcc, mailing.NewMailAddressFromInterface(addr)) + } +} + +func (mm *Message) SetReplyTo(addr mailing.MailAddressInterface) error { + if addr.IsEmpty() { + return errors.ErrEmptyAddress + } + + mm.ReplyTo = mailing.NewMailAddressFromInterface(addr) + + return nil +} + +func (mm *Message) SetSubject(sbj string) error { + if sbj == "" { + return fmt.Errorf("%w: %s", errors.ErrEmptyData, "subject") + } + + mm.Subject = sbj + + return nil +} + +func (mm *Message) SetHTML(msg []byte) error { + if msg == nil { + mm.emptyContent() + + return fmt.Errorf("%w: %s", errors.ErrEmptyData, "content") + } + + mm.MimeType = mime.TextHTML + mm.Content = msg + + return nil +} + +func (mm *Message) SetPlainText(msg []byte) error { + if msg == nil { + mm.emptyContent() + + return fmt.Errorf("%w: %s", errors.ErrEmptyData, "content") + } + + mm.MimeType = mime.TextPlain + mm.Content = msg + + return nil +} + +func (mm *Message) AddAttachment(file MessageAttachmentInterface) error { + if file.IsEmpty() { + return fmt.Errorf("%w: %s", errors.ErrEmptyData, "attachment") + } + + mm.Attachments = append(mm.Attachments, Attachment{ + MimeType: file.GetMimeType(), + AttachMethod: file.GetAttachMethod(), + Name: file.GetName(), + Filename: file.GetFileName(), + Content: file.GetContent(), + }) + + return nil +} + +func (mm *Message) AddAttachments(files ...MessageAttachmentInterface) error { + for _, file := range files { + if err := mm.AddAttachment(file); err != nil { + return err + } + } + + return nil +} + +func (mm Message) GetAttachments() MessageAttachmentListInterface { + return mm.Attachments +} + +func (mm Message) GetFrom() mailing.MailAddressInterface { + return mm.From +} + +func (mm Message) GetTo() mailing.MailAddressListInterface { + return mm.To +} + +func (mm Message) GetCc() mailing.MailAddressListInterface { + return mm.Cc +} + +func (mm Message) GetBcc() mailing.MailAddressListInterface { + return mm.Bcc +} + +func (mm Message) GetReplyTo() mailing.MailAddressInterface { + return mm.ReplyTo +} + +func (mm Message) GetMimeType() mime.Type { + return mm.MimeType +} + +func (mm Message) GetBody() []byte { + return mm.Content +} + +func (mm Message) GetSubject() string { + return mm.Subject +} + +func (mm Message) String() string { + msgFormat := `======================= +from: %s +to: %s +cc: %s +bc: %s +replyTo: %s +subject: %s + +body: +%s +======================= +` + + return fmt.Sprintf(msgFormat, + mm.GetFrom().String(), + strings.Join(mm.GetTo().GetStringList(), ", "), + strings.Join(mm.GetCc().GetStringList(), ", "), + strings.Join(mm.GetBcc().GetStringList(), ", "), + mm.GetReplyTo().String(), + mm.GetSubject(), + string(mm.GetBody()), + ) +} + +func (mm *Message) emptyContent() { + mm.MimeType = "" + mm.Content = nil +} diff --git a/contracts/messageInterface.go b/contracts/messageInterface.go new file mode 100644 index 0000000..7ed9099 --- /dev/null +++ b/contracts/messageInterface.go @@ -0,0 +1,34 @@ +package contracts + +import ( + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/configuration-structs-go/v2/mime" +) + +type MessageInterface interface { + SetFrom(addr mailing.MailAddressInterface) error + SetTo(addr mailing.MailAddressInterface) error + SetTos(addrs mailing.MailAddressListInterface) + SetCc(addr mailing.MailAddressInterface) error + SetCcs(addrs mailing.MailAddressListInterface) + SetBcc(addr mailing.MailAddressInterface) error + SetBccs(addrs mailing.MailAddressListInterface) + SetReplyTo(addr mailing.MailAddressInterface) error + SetSubject(sbj string) error + SetHTML(msg []byte) error + SetPlainText(msg []byte) error + AddAttachment(file MessageAttachmentInterface) error + AddAttachments(files ...MessageAttachmentInterface) error + + GetFrom() mailing.MailAddressInterface + GetTo() mailing.MailAddressListInterface + GetCc() mailing.MailAddressListInterface + GetBcc() mailing.MailAddressListInterface + GetReplyTo() mailing.MailAddressInterface + GetSubject() string + GetMimeType() mime.Type + GetBody() []byte + GetAttachments() MessageAttachmentListInterface + + String() string +} diff --git a/contracts/message_test.go b/contracts/message_test.go new file mode 100644 index 0000000..0f1a5fa --- /dev/null +++ b/contracts/message_test.go @@ -0,0 +1,612 @@ +package contracts_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/configuration-structs-go/v2/mime" + "github.com/spacetab-io/mails-go/contracts" + "github.com/spacetab-io/mails-go/errors" + "github.com/stretchr/testify/assert" +) + +func TestMessage_SetFrom(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddress + exp string + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddress{Name: "from", Email: "from@email.com"}, + exp: "\"from\" ", + }, + { + name: "empty address", + in: mailing.MailAddress{}, + exp: "", + err: errors.ErrEmptyAddress, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetFrom(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, tc.err, err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, msg.GetFrom().String()) + }) + } +} + +func TestMessage_SetReplyTo(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddress + exp string + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddress{Name: "replyTo", Email: "replyTo@email.com"}, + exp: "\"replyTo\" ", + }, + { + name: "empty address", + in: mailing.MailAddress{}, + exp: "", + err: errors.ErrEmptyAddress, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetReplyTo(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, tc.err, err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, msg.GetReplyTo().String()) + }) + } +} + +func TestMessage_SetTo(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddress + exp []string + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddress{Name: "to", Email: "to@email.com"}, + exp: []string{"\"to\" "}, + }, + { + name: "empty address", + in: mailing.MailAddress{}, + exp: nil, + err: errors.ErrEmptyAddress, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetTo(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, tc.err, err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, msg.GetTo().GetStringList()) + }) + } +} + +func TestMessage_SetTos(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddressList + exp []string + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddressList{mailing.MailAddress{Name: "toOne", Email: "toOne@email.com"}, mailing.MailAddress{Name: "toTwo", Email: "toTwo@email.com"}}, + exp: []string{"\"toOne\" ", "\"toTwo\" "}, + }, + { + name: "empty address", + in: mailing.MailAddressList{}, + exp: nil, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + msg.SetTos(tc.in) + + assert.Equal(t, tc.exp, msg.GetTo().GetStringList()) + }) + } +} + +func TestMessage_SetCc(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddress + exp []string + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddress{Name: "cc", Email: "cc@email.com"}, + exp: []string{"\"cc\" "}, + }, + { + name: "empty address", + in: mailing.MailAddress{}, + exp: nil, + err: errors.ErrEmptyAddress, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetCc(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, tc.err, err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, msg.GetCc().GetStringList()) + }) + } +} + +func TestMessage_SetCcs(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddressList + exp []string + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddressList{mailing.MailAddress{Name: "ccOne", Email: "ccOne@email.com"}, mailing.MailAddress{Name: "ccTwo", Email: "ccTwo@email.com"}}, + exp: []string{"\"ccOne\" ", "\"ccTwo\" "}, + }, + { + name: "empty address", + in: mailing.MailAddressList{}, + exp: nil, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + msg.SetCcs(tc.in) + + assert.Equal(t, tc.exp, msg.GetCc().GetStringList()) + }) + } +} + +func TestMessage_SetBcc(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddress + exp []string + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddress{Name: "bcc", Email: "bcc@email.com"}, + exp: []string{"\"bcc\" "}, + }, + { + name: "empty address", + in: mailing.MailAddress{}, + exp: nil, + err: errors.ErrEmptyAddress, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetBcc(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, tc.err, err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, msg.GetBcc().GetStringList()) + }) + } +} + +func TestMessage_SetBccs(t *testing.T) { + type testCase struct { + name string + in mailing.MailAddressList + exp []string + } + + tcs := []testCase{ + { + name: "correct setting", + in: mailing.MailAddressList{mailing.MailAddress{Name: "bccOne", Email: "bccOne@email.com"}, mailing.MailAddress{Name: "bccTwo", Email: "bccTwo@email.com"}}, + exp: []string{"\"bccOne\" ", "\"bccTwo\" "}, + }, + { + name: "empty address", + in: mailing.MailAddressList{}, + exp: nil, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + msg.SetBccs(tc.in) + + assert.Equal(t, tc.exp, msg.GetBcc().GetStringList()) + }) + } +} + +func TestMessage_SetSubject(t *testing.T) { + type testCase struct { + name string + in string + exp string + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: "subject", + exp: "subject", + }, + { + name: "empty data", + in: "", + exp: "", + err: errors.ErrEmptyData, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetSubject(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, err, tc.err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, msg.GetSubject()) + }) + } +} + +func TestMessage_SetHTML(t *testing.T) { + type expStruct struct { + mime mime.Type + content []byte + } + type testCase struct { + name string + in []byte + exp expStruct + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: []byte("text"), + exp: expStruct{ + mime: mime.TextHTML, + content: []byte("text"), + }, + }, + { + name: "empty data", + in: nil, + exp: expStruct{}, + err: errors.ErrEmptyData, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetHTML(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, err, tc.err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp.content, msg.GetBody()) + assert.Equal(t, tc.exp.mime, msg.GetMimeType()) + }) + } +} + +func TestMessage_SetPlainText(t *testing.T) { + type expStruct struct { + mime mime.Type + content []byte + } + type testCase struct { + name string + in []byte + exp expStruct + err error + } + + tcs := []testCase{ + { + name: "correct setting", + in: []byte("plain text"), + exp: expStruct{ + mime: mime.TextPlain, + content: []byte("plain text"), + }, + }, + { + name: "empty data", + in: nil, + exp: expStruct{}, + err: errors.ErrEmptyData, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := contracts.Message{} + + err := msg.SetPlainText(tc.in) + if tc.err != nil { + if !assert.ErrorIs(t, err, tc.err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp.content, msg.GetBody()) + assert.Equal(t, tc.exp.mime, msg.GetMimeType()) + }) + } +} + +func TestMessage_AddAttachment(t *testing.T) { + type inStruct struct { + filePath string + } + type testCase struct { + name string + in inStruct + exp []string + err error + } + + tcs := []testCase{ + { + name: "filled attachment", + in: inStruct{filePath: "test.file"}, + exp: []string{"test.file"}, + err: nil, + }, + { + name: "empty attachment", + in: inStruct{}, + exp: nil, + err: errors.ErrEmptyData, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + msg contracts.Message + att contracts.Attachment + err error + ) + + if tc.in.filePath != "" { + att, err = contracts.NewAttachmentFromFile(tc.in.filePath) + if !assert.NoError(t, err) { + t.FailNow() + } + } + + err = msg.AddAttachment(att) + if tc.err != nil { + if !assert.ErrorIs(t, err, tc.err) { + t.FailNow() + } + } else { + if !assert.NoError(t, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp, msg.GetAttachments().GetFileNames()) + }) + } +} + +func TestMessage_String(t *testing.T) { + t.Parallel() + + format := `======================= +from: %s +to: %s +cc: %s +bc: %s +replyTo: %s +subject: %s + +body: +%s +======================= +` + + msg := contracts.Message{ + To: mailing.MailAddressList{mailing.MailAddress{Email: "toOne@spacetab.io", Name: "To One"}, mailing.MailAddress{Email: "totwo@spacetab.io", Name: "To Two"}}, + MimeType: mime.TextPlain, + Subject: "Test email", + Content: []byte("test email content"), + Attachments: nil, + } + + expString := fmt.Sprintf(format, + msg.GetFrom().String(), + strings.Join(msg.GetTo().GetStringList(), ", "), + strings.Join(msg.GetCc().GetStringList(), ", "), + strings.Join(msg.GetBcc().GetStringList(), ", "), + msg.GetReplyTo().String(), + msg.GetSubject(), + string(msg.GetBody()), + ) + + assert.Equal(t, expString, msg.String()) +} diff --git a/contracts/providerInterface.go b/contracts/providerInterface.go new file mode 100644 index 0000000..15e980b --- /dev/null +++ b/contracts/providerInterface.go @@ -0,0 +1,12 @@ +package contracts + +import ( + "context" + + "github.com/spacetab-io/configuration-structs-go/v2/mailing" +) + +type ProviderInterface interface { + Name() mailing.MailProviderName + Send(ctx context.Context, msg MessageInterface) error +} diff --git a/contracts/test.file b/contracts/test.file new file mode 100644 index 0000000..f0eec86 --- /dev/null +++ b/contracts/test.file @@ -0,0 +1 @@ +some content \ No newline at end of file diff --git a/contracts/test_file b/contracts/test_file new file mode 100644 index 0000000..f0eec86 --- /dev/null +++ b/contracts/test_file @@ -0,0 +1 @@ +some content \ No newline at end of file diff --git a/contracts/test_na_access.file b/contracts/test_na_access.file new file mode 100644 index 0000000..e69de29 diff --git a/errors/attachment.go b/errors/attachment.go new file mode 100644 index 0000000..4442de9 --- /dev/null +++ b/errors/attachment.go @@ -0,0 +1,7 @@ +package errors + +import ( + "errors" +) + +var ErrAttachmentIsNotAFile = errors.New("file is a dir") diff --git a/errors/mail.go b/errors/mail.go new file mode 100644 index 0000000..708bd65 --- /dev/null +++ b/errors/mail.go @@ -0,0 +1,11 @@ +package errors + +import ( + "errors" +) + +var ( + ErrEmailProviderIsDisabled = errors.New("email provider is disabled") + ErrEmptyAddress = errors.New("mail address is empty") + ErrEmptyData = errors.New("empty data") +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e9cf9d9 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/spacetab-io/mails-go + +go 1.17 + +require ( + github.com/gabriel-vasile/mimetype v1.4.0 + github.com/mailgun/mailgun-go/v4 v4.6.2 + github.com/mattbaird/gochimp v0.0.0-20200820164431-f1082bcdf63f + github.com/sendgrid/sendgrid-go v3.11.1+incompatible + github.com/spacetab-io/configuration-structs-go/v2 v2.0.0-alpha2 + github.com/stretchr/testify v1.7.1 + github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 + github.com/xhit/go-simple-mail/v2 v2.11.0 +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-test/deep v1.0.8 // indirect + github.com/gorilla/mux v1.8.0 // indirect + github.com/json-iterator/go v1.1.10 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect + github.com/pkg/errors v0.8.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect + golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5f15632 --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= +github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro= +github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/mailgun/mailgun-go/v4 v4.6.2 h1:w65d1Zz/9Ty42itBNvtL5DWyhdFxFlqnaTd7ROhp2jI= +github.com/mailgun/mailgun-go/v4 v4.6.2/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40= +github.com/mattbaird/gochimp v0.0.0-20200820164431-f1082bcdf63f h1:Sbn1gG/7kAsH27zPoR+VzwC8pag/rfhbptoZAgCZKeE= +github.com/mattbaird/gochimp v0.0.0-20200820164431-f1082bcdf63f/go.mod h1:UaYd2gciRA1AoYEN6S+EiSNFK/0XHj9e1Wgloicgh6s= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.11.1+incompatible h1:ai0+woZ3r/+tKLQExznak5XerOFoD6S7ePO0lMV8WXo= +github.com/sendgrid/sendgrid-go v3.11.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/spacetab-io/configuration-structs-go/v2 v2.0.0-alpha2 h1:WT5CvXlGEJ69lIPsBm/anuwoCnEajRwL0ha2aoLCaQM= +github.com/spacetab-io/configuration-structs-go/v2 v2.0.0-alpha2/go.mod h1:/qyni0G7nIAu2Hdp7VW3p+RwjhwvIKJtKdbbN2osywE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= +github.com/xhit/go-simple-mail/v2 v2.11.0 h1:o/056V50zfkO3Mm5tVdo9rG3ryg4ZmJ2XW5GMinHfVs= +github.com/xhit/go-simple-mail/v2 v2.11.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 h1:Ugb8sMTWuWRC3+sz5WeN/4kejDx9BvIwnPUiJBjJE+8= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..e5a4ed9 --- /dev/null +++ b/logger.go @@ -0,0 +1,22 @@ +package mails + +import ( + "fmt" + "io" +) + +type MockLogger struct { + io io.Writer +} + +func (m MockLogger) Print(log string) { + _, _ = m.io.Write([]byte(log)) +} + +func (m MockLogger) Printf(format string, args ...interface{}) { + _, _ = m.io.Write([]byte(fmt.Sprintf(format, args...))) +} + +func NewLogger(writer io.Writer) MockLogger { + return MockLogger{io: writer} +} diff --git a/mailing.go b/mailing.go new file mode 100644 index 0000000..58cdb51 --- /dev/null +++ b/mailing.go @@ -0,0 +1,88 @@ +package mails + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/mails-go/contracts" + "github.com/spacetab-io/mails-go/providers" +) + +type Mailing struct { + provider contracts.ProviderInterface + msgCfg mailing.MessagingConfigInterface +} + +func NewMailing(providerCfg mailing.MailProviderConfigInterface, msgCfg mailing.MessagingConfigInterface) (Mailing, error) { + var ( + provider contracts.ProviderInterface + err error + ) + + switch providerCfg.Name() { + case mailing.MailProviderLogs: + var w io.Writer + + switch providerCfg.GetHostPort().GetHost() { + case "stdout": + w = os.Stdout + case "stderr": + w = os.Stderr + default: + w = os.Stdout + } + + logger := NewLogger(w) + provider, err = providers.NewLogProvider(mailing.LogsConfig{}, logger) + case mailing.MailProviderFile: + provider, err = providers.NewFileProvider(providerCfg) + case mailing.MailProviderMailgun: + provider, err = providers.NewMailgun(providerCfg) + case mailing.MailProviderMandrill: + provider, err = providers.NewMandrill(providerCfg) + case mailing.MailProviderSendgrid: + provider, err = providers.NewSendgrid(providerCfg) + case mailing.MailProviderSMTP: + provider, err = providers.NewSMTP(providerCfg) + default: + return Mailing{}, mailing.ErrUnknownProvider + } + + if err != nil { + return Mailing{}, fmt.Errorf("provider init error: %w", err) + } + + return Mailing{provider: provider, msgCfg: msgCfg}, nil +} + +func NewMailingForProvider(provider contracts.ProviderInterface, msgCfg mailing.MessagingConfigInterface) Mailing { + return Mailing{provider: provider, msgCfg: msgCfg} +} + +func (m Mailing) Send(ctx context.Context, msg contracts.MessageInterface) error { + if msg.GetFrom().IsEmpty() && !m.msgCfg.GetFrom().IsEmpty() { + _ = msg.SetFrom(m.msgCfg.GetFrom()) + } + + if !m.msgCfg.GetReplyTo().IsEmpty() && msg.GetReplyTo().IsEmpty() { + _ = msg.SetReplyTo(m.msgCfg.GetReplyTo()) + } + + if m.msgCfg.GetSubjectPrefix() != "" { + _ = msg.SetSubject(fmt.Sprintf( + "%s %s", + strings.TrimSpace(m.msgCfg.GetSubjectPrefix()), + strings.TrimSpace(msg.GetSubject()), + )) + } + + if err := m.provider.Send(ctx, msg); err != nil { + return fmt.Errorf("mailing send error: %w", err) + } + + return nil +} diff --git a/mailing_test.go b/mailing_test.go new file mode 100644 index 0000000..1cf8ecf --- /dev/null +++ b/mailing_test.go @@ -0,0 +1,112 @@ +package mails_test + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/configuration-structs-go/v2/mime" + "github.com/spacetab-io/mails-go" + "github.com/spacetab-io/mails-go/contracts" + "github.com/spacetab-io/mails-go/providers" + "github.com/stretchr/testify/assert" +) + +func TestMailing_Send(t *testing.T) { + type inStruct struct { + mailing func(writer io.Writer) mails.Mailing + msg contracts.Message + } + type testCase struct { + name string + in inStruct + exp func() string + err error + } + basicMsg := contracts.Message{ + To: mailing.MailAddressList{mailing.MailAddress{Email: "toOne@spacetab.io", Name: "To One"}, mailing.MailAddress{Email: "totwo@spacetab.io", Name: "To Two"}}, + MimeType: mime.TextPlain, + Subject: "Test email", + Content: []byte("test email content"), + Attachments: nil, + } + + fullMsg := basicMsg + fullMsg.From = mailing.MailAddress{Email: "from@spacetab.io", Name: "FromName"} + fullMsg.ReplyTo = mailing.MailAddress{Email: "replyTo@spacetab.io", Name: "replyToName"} + fullMsg.Cc = mailing.MailAddressList{mailing.MailAddress{Email: "cc@spacetab.io", Name: "Carbon Copy"}} + fullMsg.Bcc = mailing.MailAddressList{mailing.MailAddress{Email: "bcc@spacetab.io", Name: "Blind Carbon Copy"}} + + basicMailCfg := mailing.MessagingConfig{ + From: mailing.MailAddress{Email: "robot@spacetab.io", Name: "Spacetab Robot"}, + ReplyTo: mailing.MailAddress{Email: "feedback@spacetab.io", Name: "Spacetab Feedback"}, + } + + fullMailCfg := basicMailCfg + fullMailCfg.SubjectPrefix = "[test]" + + prefix := "email by [logs]:\n" + + tcs := []testCase{ + { + name: "basic email config and full message", + in: inStruct{ + msg: fullMsg, + mailing: func(writer io.Writer) mails.Mailing { + mockLogger := mails.NewLogger(writer) + mockProvider, _ := providers.NewLogProvider(mailing.LogsConfig{}, mockLogger) + + return mails.NewMailingForProvider(mockProvider, basicMailCfg) + }, + }, + exp: func() string { + return prefix + fullMsg.String() + }, + }, + { + name: "full email config and basic message", + in: inStruct{msg: basicMsg, mailing: func(writer io.Writer) mails.Mailing { + mockLogger := mails.NewLogger(writer) + mockProvider, _ := providers.NewLogProvider(mailing.LogsConfig{}, mockLogger) + + return mails.NewMailingForProvider(mockProvider, fullMailCfg) + }}, + exp: func() string { + msg := basicMsg + msg.From = fullMailCfg.From + msg.Subject = fullMailCfg.SubjectPrefix + " " + msg.Subject + msg.ReplyTo = fullMailCfg.ReplyTo + + return prefix + msg.String() + }, + }, + } + + t.Parallel() + + for _, tc := range tcs { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + bb := &bytes.Buffer{} + m := tc.in.mailing(bb) + ctx := context.Background() + err := m.Send(ctx, &tc.in.msg) + if tc.err != nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else { + if !assert.ErrorIs(t, tc.err, err) { + t.FailNow() + } + } + + assert.Equal(t, tc.exp(), bb.String()) + }) + } +} diff --git a/providers/file.go b/providers/file.go new file mode 100644 index 0000000..2e13f97 --- /dev/null +++ b/providers/file.go @@ -0,0 +1,78 @@ +package providers + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/mails-go/contracts" +) + +type File struct { + providerCfg mailing.MailProviderConfigInterface +} + +func NewFileProvider(providerCfg mailing.MailProviderConfigInterface) (File, error) { + if _, err := providerCfg.Validate(); err != nil { + return File{}, fmt.Errorf("file provider config validation error: %w", err) + } + + if !fileExists(providerCfg.GetHostPort().GetHost()) { + if err := createFile(providerCfg.GetHostPort().GetHost()); err != nil { + return File{}, fmt.Errorf("email file create error: %w", err) + } + } else { + if err := os.Chtimes(providerCfg.GetHostPort().GetHost(), time.Now().Local(), time.Now().Local()); err != nil { + return File{}, fmt.Errorf("email file touch error: %w", err) + } + } + + return File{providerCfg: providerCfg}, nil +} + +func (f File) Name() mailing.MailProviderName { + return mailing.MailProviderFile +} + +func (f File) Send(_ context.Context, msg contracts.MessageInterface) error { + file, err := os.OpenFile( + f.providerCfg.GetHostPort().GetHost(), + os.O_APPEND|os.O_WRONLY|os.O_CREATE, + 0o600, // nolint: gomnd + ) + if err != nil { + return fmt.Errorf("file open on email send error: %w", err) + } + + defer file.Close() + + if _, err = file.WriteString(msg.String() + "\n"); err != nil { + return fmt.Errorf("file write on email send error: %w", err) + } + + return nil +} + +func createFile(filePath string) error { + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("mailing file %s create error: %w", filePath, err) + } + + defer file.Close() + + return nil +} + +// fileExists checks if a file exists and is not a directory before we +// try using it to prevent further errors. +func fileExists(filePath string) bool { + info, err := os.Stat(filePath) + if os.IsNotExist(err) { + return false + } + + return !info.IsDir() +} diff --git a/providers/log.go b/providers/log.go new file mode 100644 index 0000000..3b81da4 --- /dev/null +++ b/providers/log.go @@ -0,0 +1,32 @@ +package providers + +import ( + "context" + "fmt" + + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/mails-go/contracts" +) + +type LogProvider struct { + providerCfg mailing.MailProviderConfigInterface + logger contracts.LoggerInterface +} + +func NewLogProvider(providerCfg mailing.MailProviderConfigInterface, logger contracts.LoggerInterface) (LogProvider, error) { + if _, err := providerCfg.Validate(); err != nil { + return LogProvider{}, fmt.Errorf("log provider config validation error: %w", err) + } + + return LogProvider{providerCfg: providerCfg, logger: logger}, nil +} + +func (o LogProvider) Name() mailing.MailProviderName { + return mailing.MailProviderLogs +} + +func (o LogProvider) Send(_ context.Context, msg contracts.MessageInterface) error { + o.logger.Printf("email by [%s]:\n%s", o.Name(), msg.String()) + + return nil +} diff --git a/providers/mailgun.go b/providers/mailgun.go new file mode 100644 index 0000000..a526630 --- /dev/null +++ b/providers/mailgun.go @@ -0,0 +1,74 @@ +package providers + +import ( + "context" + "fmt" + + "github.com/mailgun/mailgun-go/v4" + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/configuration-structs-go/v2/mime" + "github.com/spacetab-io/mails-go/contracts" +) + +type Mailgun struct { + client *mailgun.MailgunImpl + providerCfg mailing.MailProviderConfigInterface +} + +func NewMailgun(providerCfg mailing.MailProviderConfigInterface) (Mailgun, error) { + if _, err := providerCfg.Validate(); err != nil { + return Mailgun{}, fmt.Errorf("mailgun provider config validation error: %w", err) + } + + mg := mailgun.NewMailgun(providerCfg.GetUsername(), providerCfg.GetPassword()) + mg.SetAPIBase(providerCfg.GetHostPort().String()) + + return Mailgun{client: mg, providerCfg: providerCfg}, nil +} + +func (o Mailgun) Name() mailing.MailProviderName { + return "mailgunAPI" +} + +func (o Mailgun) Send(ctx context.Context, msg contracts.MessageInterface) error { + tos := make([]string, 0) + for _, to := range msg.GetTo().GetList() { + tos = append(tos, to.String()) + } + + message := o.client.NewMessage(msg.GetFrom().String(), msg.GetSubject(), string(msg.GetBody()), tos...) + + if !msg.GetCc().IsEmpty() { + for _, cc := range msg.GetCc().GetList() { + message.AddCC(cc.String()) + } + } + + if !msg.GetBcc().IsEmpty() { + for _, bcc := range msg.GetBcc().GetList() { + message.AddBCC(bcc.String()) + } + } + + if !msg.GetReplyTo().IsEmpty() { + message.SetReplyTo(msg.GetReplyTo().String()) + } + + if msg.GetMimeType() == mime.TextHTML { + message.SetHtml(string(msg.GetBody())) + } + + if o.providerCfg.GetDKIMPrivateKey() != nil { + message.SetDKIM(true) + } + + ctx, cancel := context.WithTimeout(ctx, o.providerCfg.GetSendTimeout()) + + defer cancel() + + if _, _, err := o.client.Send(ctx, message); err != nil { + return fmt.Errorf("%s send message error: %w", o.Name(), err) + } + + return nil +} diff --git a/providers/mandrill.go b/providers/mandrill.go new file mode 100644 index 0000000..b102310 --- /dev/null +++ b/providers/mandrill.go @@ -0,0 +1,96 @@ +package providers + +import ( + "context" + "fmt" + + "github.com/mattbaird/gochimp" + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/configuration-structs-go/v2/mime" + "github.com/spacetab-io/mails-go/contracts" +) + +type Mandrill struct { + mandrillAPI *gochimp.MandrillAPI + providerCfg mailing.MailProviderConfigInterface +} + +func NewMandrill(providerCfg mailing.MailProviderConfigInterface) (Mandrill, error) { + if _, err := providerCfg.Validate(); err != nil { + return Mandrill{}, fmt.Errorf("mandrill provider config validation error: %w", err) + } + + api, err := gochimp.NewMandrill(providerCfg.GetPassword()) + if err != nil { + return Mandrill{}, fmt.Errorf("mandrill client init error: %w", err) + } + + if providerCfg.GetSendTimeout() != 0 { + api.Timeout = providerCfg.GetSendTimeout() + } + + return Mandrill{mandrillAPI: api, providerCfg: providerCfg}, nil +} + +func (o Mandrill) Name() mailing.MailProviderName { + return "mandrillAPI" +} + +func (o Mandrill) Send(_ context.Context, msg contracts.MessageInterface) error { + tos := make([]gochimp.Recipient, 0) + + for _, to := range msg.GetTo().GetList() { + tos = append(tos, gochimp.Recipient{ + Name: to.GetName(), + Email: to.GetEmail(), + }) + } + + if !msg.GetCc().IsEmpty() { + for _, cc := range msg.GetCc().GetList() { + tos = append(tos, gochimp.Recipient{ + Name: cc.GetName(), + Email: cc.GetEmail(), + Type: "cc", + }) + } + } + + if !msg.GetBcc().IsEmpty() { + for _, bcc := range msg.GetBcc().GetList() { + tos = append(tos, gochimp.Recipient{ + Name: bcc.GetName(), + Email: bcc.GetEmail(), + Type: "bcc", + }) + } + } + + message := gochimp.Message{ + Subject: msg.GetSubject(), + FromName: msg.GetFrom().GetName(), + FromEmail: msg.GetFrom().GetEmail(), + To: tos, + } + + switch msg.GetMimeType() { + case mime.TextHTML: + message.Html = string(msg.GetBody()) + case mime.TextPlain: + message.Text = string(msg.GetBody()) + default: + message.Text = string(msg.GetBody()) + } + + if !msg.GetReplyTo().IsEmpty() { + message.Headers = map[string]string{ + "Reply-To": msg.GetReplyTo().String(), + } + } + + if _, err := o.mandrillAPI.MessageSend(message, o.providerCfg.IsAsync()); err != nil { + return fmt.Errorf("mandrill email send error: %w", err) + } + + return nil +} diff --git a/providers/sendgrid.go b/providers/sendgrid.go new file mode 100644 index 0000000..28e840f --- /dev/null +++ b/providers/sendgrid.go @@ -0,0 +1,106 @@ +package providers + +import ( + "context" + "fmt" + "net/http" + + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + "github.com/spacetab-io/configuration-structs-go/v2/mime" + "github.com/spacetab-io/mails-go/contracts" +) + +type Sendgrid struct { + client *sendgrid.Client + providerCfg mailing.MailProviderConfigInterface +} + +func NewSendgrid(providerCfg mailing.MailProviderConfigInterface) (Sendgrid, error) { + if _, err := providerCfg.Validate(); err != nil { + return Sendgrid{}, fmt.Errorf("sendgrid provider config validation error: %w", err) + } + + return Sendgrid{client: sendgrid.NewSendClient(providerCfg.GetPassword()), providerCfg: providerCfg}, nil +} + +func (o Sendgrid) Name() mailing.MailProviderName { + return "sendgridAPI" +} + +func (o Sendgrid) Send(ctx context.Context, msg contracts.MessageInterface) error { + var content *mail.Content + + switch msg.GetMimeType() { + case mime.TextHTML, mime.TextPlain: + content = mail.NewContent(msg.GetMimeType().String(), string(msg.GetBody())) + default: + content = mail.NewContent(mime.TextPlain.String(), string(msg.GetBody())) + } + + message := mail.NewV3Mail() + + if !msg.GetFrom().IsEmpty() { + message.SetFrom(mail.NewEmail(msg.GetFrom().GetName(), msg.GetFrom().GetEmail())) + } + + message.AddPersonalizations(o.getPersonalization(msg)) + message.AddContent(content) + + if !msg.GetReplyTo().IsEmpty() { + message.ReplyTo = mail.NewEmail(msg.GetReplyTo().GetName(), msg.GetReplyTo().GetEmail()) + } + + message.Subject = msg.GetSubject() + + ctx, cancel := context.WithTimeout(ctx, o.providerCfg.GetSendTimeout()) + + defer cancel() + + response, err := o.client.SendWithContext(ctx, message) + if err == nil && response.StatusCode != http.StatusOK && response.StatusCode != http.StatusAccepted { + return fmt.Errorf("sendgrid send message error: %d %s", response.StatusCode, response.Body) //nolint: goerr113 + } else if err != nil { + return fmt.Errorf("sendgrid email send error: %w", err) + } + + return nil +} + +func (o Sendgrid) getPersonalization(msg contracts.MessageInterface) *mail.Personalization { + p := mail.NewPersonalization() + + if !msg.GetFrom().IsEmpty() { + p.AddFrom(mail.NewEmail(msg.GetFrom().GetName(), msg.GetFrom().GetEmail())) + } + + tos := make([]*mail.Email, 0) + for _, to := range msg.GetTo().GetList() { + tos = append(tos, mail.NewEmail(to.GetName(), to.GetEmail())) + } + + p.AddTos(tos...) + + if !msg.GetCc().IsEmpty() { + ccs := make([]*mail.Email, 0, len(msg.GetCc().GetList())) + for _, cc := range msg.GetCc().GetList() { + ccs = append(ccs, mail.NewEmail(cc.GetName(), cc.GetEmail())) + } + + p.AddCCs(ccs...) + } + + if !msg.GetBcc().IsEmpty() { + bccs := make([]*mail.Email, 0, len(msg.GetBcc().GetList())) + for _, bcc := range msg.GetBcc().GetList() { + bccs = append(bccs, mail.NewEmail(bcc.GetName(), bcc.GetEmail())) + } + + p.AddBCCs(bccs...) + } + + p.Subject = msg.GetSubject() + + return p +} diff --git a/providers/smtp.go b/providers/smtp.go new file mode 100644 index 0000000..86ef827 --- /dev/null +++ b/providers/smtp.go @@ -0,0 +1,180 @@ +package providers + +import ( + "context" + "crypto/tls" + "fmt" + + cfgstructs "github.com/spacetab-io/configuration-structs-go/v2/contracts" + "github.com/spacetab-io/configuration-structs-go/v2/mailing" + customMime "github.com/spacetab-io/configuration-structs-go/v2/mime" + "github.com/spacetab-io/mails-go/contracts" + "github.com/toorop/go-dkim" + mail "github.com/xhit/go-simple-mail/v2" +) + +type SMTP struct { + client *mail.SMTPClient + providerCfg mailing.MailProviderConfigInterface +} + +func NewSMTP(providerCfg mailing.MailProviderConfigInterface) (SMTP, error) { + if _, err := providerCfg.Validate(); err != nil { + return SMTP{}, fmt.Errorf("smtp provider config validation error: %w", err) + } + + server := mail.NewSMTPClient() + + // SMTP Server + server.Host = providerCfg.GetHostPort().GetHost() + server.Port = int(providerCfg.GetHostPort().GetPort()) + server.Username = providerCfg.GetUsername() + server.Password = providerCfg.GetPassword() + server.Encryption = toProviderEncryption(providerCfg.GetEncryption()) + + // Since v2.3.0 you can specified authentication type: + // - PLAIN (default) + // - LOGIN + // - CRAM-MD5 + // - None + server.Authentication = toProviderAuthType(providerCfg.GetAuthType()) + + // Variable to keep alive connection + server.KeepAlive = false + + // Timeout for connect to SMTP Server + server.ConnectTimeout = providerCfg.GetConnectionTimeout() + + // Timeout for send the data and wait respond + server.SendTimeout = providerCfg.GetSendTimeout() + + if providerCfg.GetEncryption() == mailing.MailProviderEncryptionNone { + // Set TLSConfig to provide custom TLS configuration. For example, + // to skip TLS verification (useful for testing): + server.TLSConfig = &tls.Config{InsecureSkipVerify: true} //nolint: gosec + } + + // SMTP client + smtpClient, err := server.Connect() + if err != nil { + return SMTP{}, fmt.Errorf("smtp server connection error: %w", err) + } + + return SMTP{client: smtpClient, providerCfg: providerCfg}, nil +} + +func (o SMTP) Name() mailing.MailProviderName { + return "smtp" +} + +func (o SMTP) Send(_ context.Context, msg contracts.MessageInterface) error { + // New email simple html with inline and CC + email := mail.NewMSG().SetSubject(msg.GetSubject()).SetFrom(msg.GetFrom().String()) + + tos := make([]string, 0, len(msg.GetTo().GetList())) + for _, to := range msg.GetTo().GetList() { + tos = append(tos, to.String()) + } + + email.AddTo(tos...) + + if !msg.GetCc().IsEmpty() { + ccs := make([]string, 0, len(msg.GetCc().GetList())) + for _, cc := range msg.GetCc().GetList() { + ccs = append(ccs, cc.String()) + } + + email.AddCc(ccs...) + } + + if !msg.GetBcc().IsEmpty() { + bccs := make([]string, 0, len(msg.GetBcc().GetList())) + for _, bcc := range msg.GetBcc().GetList() { + bccs = append(bccs, bcc.String()) + } + + email.AddBcc(bccs...) + } + + mime := mail.TextPlain + + if msg.GetMimeType() == customMime.TextHTML { + mime = mail.TextHTML + } + + email.SetBody(mime, string(msg.GetBody())) + + // also you can add body from []byte with SetBodyData, example: + // email.SetBodyData(mail.TextHTML, []byte(htmlBody)) + // or alternative part + // email.AddAlternativeData(mail.TextHTML, []byte(htmlBody)) + + // add inline + email.Attach(&mail.File{FilePath: "/path/to/image.png", Name: "Gopher.png", Inline: true}) + + // you can add dkim signature to the email. + // to add dkim, you need a private key already created one. + if o.providerCfg.GetDKIMPrivateKey() != nil { + options := dkim.NewSigOptions() + k := o.providerCfg.GetDKIMPrivateKey() + options.PrivateKey = []byte(*k) + options.Domain = msg.GetFrom().GetDomain() + options.Selector = "default" + options.SignatureExpireIn = 3600 + options.Headers = []string{"from", "date", "mime-version", "received", "received"} + options.AddSignatureTimestamp = true + options.Canonicalization = "relaxed/relaxed" + + email.SetDkim(options) + } + + // Call Send and pass the client + if err := email.Send(o.client); err != nil { + return fmt.Errorf("smtp email send error: %w", err) + } + + // always check error after send + if email.Error != nil { + return email.Error + } + + return nil +} + +func toProviderAuthType(pat cfgstructs.AuthType) (at mail.AuthType) { + switch pat { + case cfgstructs.AuthTypePlain: + at = mail.AuthPlain + case cfgstructs.AuthTypeLogin: + at = mail.AuthLogin + case cfgstructs.AuthTypeCRAMMD5: + at = mail.AuthCRAMMD5 + case cfgstructs.AuthTypeNone: + at = mail.AuthNone + case cfgstructs.AuthTypeBasic, cfgstructs.AuthTypeJWT: + at = mail.AuthPlain + default: + at = mail.AuthPlain + } + + return at +} + +func toProviderEncryption(enc mailing.MailProviderEncryption) (me mail.Encryption) { + switch enc { + case mailing.MailProviderEncryptionNone: + me = mail.EncryptionNone + case mailing.MailProviderEncryptionSSL: + me = mail.EncryptionSSLTLS + case mailing.MailProviderEncryptionTLS: + me = mail.EncryptionTLS + case mailing.MailProviderEncryptionSSLTLS: + me = mail.EncryptionSSLTLS + case mailing.MailProviderEncryptionSTARTTLS: + me = mail.EncryptionSTARTTLS + default: + me = mail.EncryptionNone + } + + return +}