From d61841bc589ec4633522a9561aef7eacb22d7054 Mon Sep 17 00:00:00 2001 From: Ashutosh Gangwar Date: Thu, 3 Aug 2023 21:05:35 +0530 Subject: [PATCH] add 'message.replyToAddresses' config option --- internal/cmd/init.go | 1 + internal/cmd/send.go | 12 +++-- internal/config/config.go | 11 +++-- internal/email/service.go | 53 ++++++++++++++------- internal/email/service_test.go | 85 +++++++++++++++++++++------------- 5 files changed, 101 insertions(+), 61 deletions(-) diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 733ffcc..81b58d3 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -21,6 +21,7 @@ var defaultConfig = &config.Config{ }, Message: config.MessageConfig{ Sender: "Iris CLI ", + ReplyToAddresses: []string{"inbox@example.test", "another@example.test"}, DefaultDataCsvFile: "default.csv", RecipientDataCsvFile: "recipients.csv", RecipientEmailColumnName: "Email", diff --git a/internal/cmd/send.go b/internal/cmd/send.go index 9e96a2a..558dab6 100644 --- a/internal/cmd/send.go +++ b/internal/cmd/send.go @@ -69,15 +69,17 @@ func SendCommand(v *viper.Viper) *cobra.Command { return err } - e, err := t.Render(recipientData) + msg, err := t.Render(recipientData) if err != nil { return err } - sender := cfg.Message.Sender - recipient := recipientData[cfg.Message.RecipientEmailColumnName] - cmd.Println("dispatching to", recipient) - if err := svc.Send(sender, recipient, e); err != nil { + if err := svc.Send(&email.SendOptions{ + From: cfg.Message.Sender, + To: recipientData[cfg.Message.RecipientEmailColumnName], + ReplyTo: cfg.Message.ReplyToAddresses, + Message: msg, + }); err != nil { return err } } diff --git a/internal/config/config.go b/internal/config/config.go index ad792b0..f093efc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,11 +26,12 @@ type AwsSesServiceConfig struct { } type MessageConfig struct { - Sender string `yaml:"sender,omitempty"` - DefaultDataCsvFile string `yaml:"defaultDataCsvFile,omitempty"` - RecipientDataCsvFile string `yaml:"recipientDataCsvFile,omitempty"` - RecipientEmailColumnName string `yaml:"recipientEmailColumnName,omitempty"` - MinifyHtml bool `yaml:"minifyHtml,omitempty"` + Sender string `yaml:"sender,omitempty"` + ReplyToAddresses []string `yaml:"replyToAddresses,omitempty"` + DefaultDataCsvFile string `yaml:"defaultDataCsvFile,omitempty"` + RecipientDataCsvFile string `yaml:"recipientDataCsvFile,omitempty"` + RecipientEmailColumnName string `yaml:"recipientEmailColumnName,omitempty"` + MinifyHtml bool `yaml:"minifyHtml,omitempty"` } // Read attempts to read the config file in the current working directory. It diff --git a/internal/email/service.go b/internal/email/service.go index 197ecb3..87b2933 100644 --- a/internal/email/service.go +++ b/internal/email/service.go @@ -16,7 +16,14 @@ import ( ) type Service interface { - Send(from string, to string, m *Message) error + Send(opts *SendOptions) error +} + +type SendOptions struct { + From string + To string + ReplyTo []string + Message *Message } type ServiceOption func(upstream Service) Service @@ -58,32 +65,36 @@ type awsSesService struct { client AwsSesClient } -func (s *awsSesService) Send(from string, to string, m *Message) error { - if m == nil { +func (s *awsSesService) Send(opts *SendOptions) error { + if opts == nil { + return fmt.Errorf("send options must not be nil") + } + + if opts.Message == nil { return fmt.Errorf("message must not be nil") } if _, err := s.client.SendEmail(&ses.SendEmailInput{ - Source: aws.String(from), + Source: aws.String(opts.From), Destination: &ses.Destination{ - CcAddresses: []*string{}, ToAddresses: []*string{ - aws.String(to), + aws.String(opts.To), }, }, + ReplyToAddresses: aws.StringSlice(opts.ReplyTo), Message: &ses.Message{ Subject: &ses.Content{ Charset: aws.String("utf-8"), - Data: aws.String(m.Subject), + Data: aws.String(opts.Message.Subject), }, Body: &ses.Body{ Text: &ses.Content{ Charset: aws.String("utf-8"), - Data: aws.String(m.TextBody), + Data: aws.String(opts.Message.TextBody), }, Html: &ses.Content{ Charset: aws.String("utf-8"), - Data: aws.String(m.HtmlBody), + Data: aws.String(opts.Message.HtmlBody), }, }, }, @@ -102,8 +113,12 @@ type printService struct { w io.Writer } -func (s *printService) Send(from string, to string, m *Message) error { - if m == nil { +func (s *printService) Send(opts *SendOptions) error { + if opts == nil { + return fmt.Errorf("send options must not be nil") + } + + if opts.Message == nil { return fmt.Errorf("message must not be nil") } @@ -121,9 +136,9 @@ func (s *printService) Send(from string, to string, m *Message) error { tw.SetAutoWrapText(false) tw.SetRowLine(true) tw.AppendBulk([][]string{ - {"Subject", wordwrap.WrapString(m.Subject, uint(pw))}, - {"Text Body", wordwrap.WrapString(m.TextBody, uint(pw))}, - {"HTML Body", wordwrap.WrapString(m.HtmlBody, uint(pw))}, + {"Subject", wordwrap.WrapString(opts.Message.Subject, uint(pw))}, + {"Text Body", wordwrap.WrapString(opts.Message.TextBody, uint(pw))}, + {"HTML Body", wordwrap.WrapString(opts.Message.HtmlBody, uint(pw))}, }) tw.Render() return nil @@ -151,9 +166,9 @@ type rateLimitedService struct { limiter ratelimit.Limiter } -func (s *rateLimitedService) Send(from string, to string, m *Message) error { +func (s *rateLimitedService) Send(opts *SendOptions) error { s.limiter.Take() - return s.upstream.Send(from, to, m) + return s.upstream.Send(opts) } func WithRetries(retryCount int) ServiceOption { @@ -170,10 +185,10 @@ type retryService struct { retryCount int } -func (s *retryService) Send(from string, to string, m *Message) error { +func (s *retryService) Send(opts *SendOptions) error { var err error for i := 0; i <= s.retryCount; i++ { - err = s.upstream.Send(from, to, m) + err = s.upstream.Send(opts) if err == nil { break } @@ -182,6 +197,8 @@ func (s *retryService) Send(from string, to string, m *Message) error { return err } +// ApplyOptions wraps the given `upstream` service in the given service options +// (decorators). func ApplyOptions(upstream Service, opts ...ServiceOption) Service { s := upstream for _, option := range opts { diff --git a/internal/email/service_test.go b/internal/email/service_test.go index 5cfa76e..79afa22 100644 --- a/internal/email/service_test.go +++ b/internal/email/service_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ses" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,39 +17,48 @@ func TestAwsSesService(t *testing.T) { t.Run("WithNilMessage", func(t *testing.T) { c := &FakeAwsSesClient{RespondWithOutput: &ses.SendEmailOutput{}} s := email.NewAwsSesServiceWithClient(c) - err := s.Send("test-from", "test-to", nil) + err := s.Send(&email.SendOptions{ + From: "test-from", + To: "test-to", + }) assert.Error(t, err) }) t.Run("WithUpstreamError", func(t *testing.T) { c := &FakeAwsSesClient{RespondWithError: fmt.Errorf("test-error")} s := email.NewAwsSesServiceWithClient(c) - err := s.Send("test-from", "test-to", &email.Message{}) + err := s.Send(&email.SendOptions{ + From: "test-from", + To: "test-to", + Message: &email.Message{}, + }) assert.Error(t, err) }) t.Run("WithNoError", func(t *testing.T) { - const from = "test-from" - const to = "test-to" - const subject = "test-subject" - const textBody = "test-text-body" - const htmlBody = "test-html-body" + sendOpts := &email.SendOptions{ + From: "test-from", + To: "test-to", + ReplyTo: []string{"test-reply-to"}, + Message: &email.Message{ + Subject: "test-subject", + TextBody: "test-text-body", + HtmlBody: "test-html-body", + }, + } + c := &FakeAwsSesClient{RespondWithOutput: &ses.SendEmailOutput{}} s := email.NewAwsSesServiceWithClient(c) - err := s.Send(from, to, &email.Message{ - Subject: subject, - TextBody: textBody, - HtmlBody: htmlBody, - }) - + err := s.Send(sendOpts) assert.NoError(t, err) i := c.LastSendEmailInput - assert.Equal(t, from, *i.Source) - assert.Equal(t, to, *i.Destination.ToAddresses[0]) - assert.Equal(t, subject, *i.Message.Subject.Data) - assert.Equal(t, textBody, *i.Message.Body.Text.Data) - assert.Equal(t, htmlBody, *i.Message.Body.Html.Data) + assert.Equal(t, sendOpts.From, *i.Source) + assert.Equal(t, sendOpts.To, *i.Destination.ToAddresses[0]) + assert.Equal(t, sendOpts.ReplyTo, aws.StringValueSlice(i.ReplyToAddresses)) + assert.Equal(t, sendOpts.Message.Subject, *i.Message.Subject.Data) + assert.Equal(t, sendOpts.Message.TextBody, *i.Message.Body.Text.Data) + assert.Equal(t, sendOpts.Message.HtmlBody, *i.Message.Body.Html.Data) }) } @@ -65,24 +75,25 @@ func (c *FakeAwsSesClient) SendEmail(input *ses.SendEmailInput) (*ses.SendEmailO } func TestPrintService(t *testing.T) { - const subject = "test-subject" - const textBody = "test-text-body" - const htmlBody = "test-html-body" + sendOpts := &email.SendOptions{ + From: "test-from", + To: "test-to", + Message: &email.Message{ + Subject: "test-subject", + TextBody: "test-text-body", + HtmlBody: "test-html-body", + }, + } b := &bytes.Buffer{} s := email.NewPrintService(b) - err := s.Send("test-from", "test-to", &email.Message{ - Subject: subject, - TextBody: textBody, - HtmlBody: htmlBody, - }) - + err := s.Send(sendOpts) assert.NoError(t, err) out := b.String() - assert.Contains(t, out, subject) - assert.Contains(t, out, textBody) - assert.Contains(t, out, htmlBody) + assert.Contains(t, out, sendOpts.Message.Subject) + assert.Contains(t, out, sendOpts.Message.TextBody) + assert.Contains(t, out, sendOpts.Message.HtmlBody) } func TestRateLimitedService(t *testing.T) { @@ -90,7 +101,11 @@ func TestRateLimitedService(t *testing.T) { s = email.ApplyOptions(s, email.WithRateLimit(1)) then := time.Now() for i := 0; i < 5; i++ { - err := s.Send("test", "test", &email.Message{}) + err := s.Send(&email.SendOptions{ + From: "test-from", + To: "test-to", + Message: &email.Message{}, + }) require.NoError(t, err) } @@ -135,7 +150,11 @@ func TestRetryService(t *testing.T) { t.Run(test.name, func(t *testing.T) { var s email.Service = &unreliableService{errorsBeforeSucceeding: test.errorCount} s = email.ApplyOptions(s, email.WithRetries(test.retryCount)) - err := s.Send("test", "test", &email.Message{}) + err := s.Send(&email.SendOptions{ + From: "test-from", + To: "test-to", + Message: &email.Message{}, + }) if test.wantErr { assert.Error(t, err) } else { @@ -149,7 +168,7 @@ type unreliableService struct { errorsBeforeSucceeding int } -func (s *unreliableService) Send(from string, to string, m *email.Message) error { +func (s *unreliableService) Send(opts *email.SendOptions) error { s.errorsBeforeSucceeding-- if s.errorsBeforeSucceeding > -1 { return fmt.Errorf("test-error")