github.com/mongodb/grip@v0.0.0-20240213223901-f906268d82b9/send/ses.go (about) 1 package send 2 3 import ( 4 "context" 5 "encoding/base64" 6 "fmt" 7 "net/mail" 8 "strings" 9 10 "github.com/aws/aws-sdk-go-v2/aws" 11 "github.com/aws/aws-sdk-go-v2/service/ses" 12 "github.com/aws/aws-sdk-go-v2/service/ses/types" 13 "github.com/mongodb/grip/message" 14 "github.com/pkg/errors" 15 ) 16 17 const maxRecipients = 50 18 19 // SESOptions configures the SES Logger. 20 type SESOptions struct { 21 // Name is the name of the logger. It's also used as the friendly name in emails' From field. 22 Name string 23 // SenderAddress is the default address to send emails from. Individual emails can override this field. 24 // This email or its domain must be verified in SES. 25 SenderAddress string 26 // AWSConfig configures the SES client. The config must include authorization to send raw emails over SES. 27 AWSConfig aws.Config 28 } 29 30 func (o *SESOptions) validate() error { 31 if o.Name == "" { 32 return errors.New("logger name must be provided") 33 } 34 if o.SenderAddress == "" { 35 return errors.New("sender address must be provided") 36 } 37 38 return nil 39 } 40 41 type sesSender struct { 42 options SESOptions 43 ctx context.Context 44 *Base 45 } 46 47 // NewSESLogger returns a Sender implementation backed by SES. 48 func NewSESLogger(ctx context.Context, options SESOptions, l LevelInfo) (Sender, error) { 49 if err := options.validate(); err != nil { 50 return nil, errors.Wrap(err, "invalid options") 51 } 52 sender := &sesSender{ 53 Base: NewBase(options.Name), 54 ctx: ctx, 55 options: options, 56 } 57 sender.SetLevel(l) 58 59 return sender, nil 60 } 61 62 // Flush is a noop for the sesSender. 63 func (s *sesSender) Flush(_ context.Context) error { return nil } 64 65 // Send sends the email over SES. 66 func (s *sesSender) Send(m message.Composer) { 67 ctx, cancel := context.WithCancel(s.ctx) 68 defer cancel() 69 70 if !s.Level().ShouldLog(m) { 71 return 72 } 73 74 emailMsg, ok := m.Raw().(*message.Email) 75 if !ok { 76 s.ErrorHandler()(errors.Errorf("expecting *message.Email, got %T", m), m) 77 return 78 } 79 80 s.ErrorHandler()(errors.Wrap(s.sendRawEmail(ctx, emailMsg), "sending email"), m) 81 } 82 83 func (s *sesSender) sendRawEmail(ctx context.Context, emailMsg *message.Email) error { 84 from := s.options.SenderAddress 85 if emailMsg.From != "" { 86 from = emailMsg.From 87 } 88 fromAddr, err := mail.ParseAddress(from) 89 if err != nil { 90 return errors.Wrap(err, "parsing From address") 91 } 92 fromAddr.Name = s.Name() 93 94 if len(emailMsg.Recipients) == 0 { 95 return errors.New("no recipients specified") 96 } 97 if len(emailMsg.Recipients) > maxRecipients { 98 return errors.Errorf("cannot send to more than %d recipients", maxRecipients) 99 } 100 101 var toAddresses []string 102 for _, address := range emailMsg.Recipients { 103 toAddr, err := mail.ParseAddress(address) 104 if err != nil { 105 return errors.Wrapf(err, "parsing To address '%s'", address) 106 } 107 toAddresses = append(toAddresses, toAddr.Address) 108 } 109 110 contents := []string{ 111 fmt.Sprintf("From: %s", fromAddr.String()), 112 fmt.Sprintf("To: %s", strings.Join(toAddresses, ", ")), 113 fmt.Sprintf("Subject: %s", emailMsg.Subject), 114 "MIME-Version: 1.0", 115 } 116 117 hasContentTypeSet := false 118 for k, v := range emailMsg.Headers { 119 if k == "To" || k == "From" || k == "Subject" || k == "Content-Transfer-Encoding" { 120 continue 121 } 122 if k == "Content-Type" { 123 hasContentTypeSet = true 124 } 125 for i := range v { 126 contents = append(contents, fmt.Sprintf("%s: %s", k, v[i])) 127 } 128 } 129 130 if !hasContentTypeSet { 131 if emailMsg.PlainTextContents { 132 contents = append(contents, "Content-Type: text/plain; charset=\"utf-8\"") 133 } else { 134 contents = append(contents, "Content-Type: text/html; charset=\"utf-8\"") 135 } 136 } 137 138 contents = append(contents, 139 "Content-Transfer-Encoding: base64", 140 base64.StdEncoding.EncodeToString([]byte(emailMsg.Body))) 141 142 _, err = ses.NewFromConfig(s.options.AWSConfig).SendRawEmail(ctx, &ses.SendRawEmailInput{ 143 Source: aws.String(fromAddr.Address), 144 Destinations: toAddresses, 145 RawMessage: &types.RawMessage{Data: []byte(strings.Join(contents, "\r\n"))}, 146 }) 147 148 return errors.Wrap(err, "calling SES SendRawEmail") 149 }