diff --git a/go.mod b/go.mod index 4713e000e70dc236794b7cfef22b8ae8953eb5ea..97abf8a7e8ecfa30df469cb43f47287bf46b8f5b 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/MindscapeHQ/raygun4go v1.1.1 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/aws/aws-lambda-go v1.26.0 - github.com/aws/aws-sdk-go-v2 v1.27.1 + github.com/aws/aws-sdk-go-v2 v1.30.0 github.com/aws/aws-sdk-go-v2/config v1.27.10 github.com/aws/aws-sdk-go-v2/credentials v1.17.10 github.com/aws/aws-sdk-go-v2/service/apigatewaymanagementapi v1.19.9 @@ -44,14 +44,15 @@ require ( github.com/aws/aws-sdk-go v1.44.180 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.12 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ses v1.23.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.20.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect @@ -72,6 +73,7 @@ require ( github.com/mattn/go-runewidth v0.0.10 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pborman/uuid v1.2.1 // indirect + github.com/prozz/aws-embedded-metrics-golang v1.2.0 // indirect github.com/rivo/uniseg v0.1.0 // indirect github.com/smartystreets/goconvey v1.7.2 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect @@ -89,5 +91,7 @@ require ( golang.org/x/term v0.19.0 // indirect google.golang.org/appengine v1.6.6 // indirect google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect mellium.im/sasl v0.2.1 // indirect ) diff --git a/go.sum b/go.sum index 8bb29d35362e77708cc7f003da98953688d48343..82ebf050f317ffa1e5efe28a40a210022df883b7 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/aws/aws-sdk-go v1.44.180/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8 github.com/aws/aws-sdk-go-v2 v1.17.3/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2 v1.27.1 h1:xypCL2owhog46iFxBKKpBcw+bPTX/RJzwNj8uSilENw= github.com/aws/aws-sdk-go-v2 v1.27.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2 v1.30.0 h1:6qAwtzlfcTtcL8NHtbDQAqgM5s6NDipQTkPxyH/6kAA= +github.com/aws/aws-sdk-go-v2 v1.30.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg= github.com/aws/aws-sdk-go-v2/config v1.18.8/go.mod h1:5XCmmyutmzzgkpk/6NYTjeWb6lgo9N170m1j6pQkIBs= @@ -28,9 +30,13 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24L github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27/go.mod h1:a1/UpzeyBBerajpnP5nGZa9mGzsBn5cOKxm6NWQsvoI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8 h1:RnLB7p6aaFMRfyQkD6ckxR7myCC9SABIqSz4czYUUbU= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8/go.mod h1:XH7dQJd+56wEbP1I4e4Duo+QhSMxNArE8VP7NuUOTeM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.12 h1:SJ04WXGTwnHlWIODtC5kJzKbeuHt+OUNOgKg7nfnUGw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.12/go.mod h1:FkpvXhA92gb3GE9LD6Og0pHHycTxW7xGpnEh5E7Opwo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21/go.mod h1:+Gxn8jYn5k9ebfHEqlhrMirFjSW0v0C9fI+KN5vk2kE= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8 h1:jzApk2f58L9yW9q1GEab3BMMFWUkkiZhyrRUtbwUbKU= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8/go.mod h1:WqO+FftfO3tGePUtQxPXM6iODVfqMwsVMgTbG/ZXIdQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.12 h1:hb5KgeYfObi5MHkSSZMEudnIvX30iB+E21evI4r6BnQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.12/go.mod h1:CroKe/eWJdyfy9Vx4rljP5wTUjNJfb+fPz1uMYUhEGM= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28/go.mod h1:yRZVr/iT0AqyHeep00SZ4YfBAKojXz08w3XMBscdi0c= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= @@ -53,6 +59,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1 h1:6cnno47Me9bRykw9AEv9zkXE+5or7 github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1/go.mod h1:qmdkIIAC+GCLASF7R2whgNrJADz0QZPX+Seiw/i4S3o= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.6 h1:TIOEjw0i2yyhmhRry3Oeu9YtiiHWISZ6j/irS1W3gX4= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.6/go.mod h1:3Ba++UwWd154xtP4FRX5pUK3Gt4up5sDHCve6kVfE+g= +github.com/aws/aws-sdk-go-v2/service/ses v1.23.1 h1:XDy5gu6vWlLrR964J3yOoefbuXPEjdMglBqeANCN3Do= +github.com/aws/aws-sdk-go-v2/service/ses v1.23.1/go.mod h1:V6akueJRZRsIvbSoFZha+H2n8ZhNcjlfR1rxuU2hZug= github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4 h1:mE2ysZMEeQ3ulHWs4mmc4fZEhOfeY1o6QXAfDqjbSgw= github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4/go.mod h1:lCN2yKnj+Sp9F6UzpoPPTir+tSaC9Jwf6LcmTqnXFZw= github.com/aws/aws-sdk-go-v2/service/sso v1.12.0/go.mod h1:wo/B7uUm/7zw/dWhBJ4FXuw1sySU5lyIhVg1Bu2yL9A= @@ -163,6 +171,7 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kinbiko/jsonassert v1.0.1/go.mod h1:QRwBwiAsrcJpjw+L+Q4WS8psLxuUY+HylVZS/4j74TM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -206,6 +215,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prozz/aws-embedded-metrics-golang v1.2.0 h1:b/LFb8J9LbgANow/9nYZE3M3bkb457/dj0zAB3hPyvo= +github.com/prozz/aws-embedded-metrics-golang v1.2.0/go.mod h1:MXOqF9cJCEHjj77LWq7NWK44/AOyaFzwmcAYqR3057M= github.com/r3labs/diff/v2 v2.14.2 h1:1HVhQKwg1YnoCWzCYlOWYLG4C3yfTudZo5AcrTSgCTc= github.com/r3labs/diff/v2 v2.14.2/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc= github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= @@ -388,12 +399,16 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= diff --git a/ses/ses.go b/ses/ses.go new file mode 100644 index 0000000000000000000000000000000000000000..3b281ca83706f2669322cc33a4eb02f8ecc8e455 --- /dev/null +++ b/ses/ses.go @@ -0,0 +1,215 @@ +package ses + +import ( + "bytes" + "context" + "fmt" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ses" + sesTypes "github.com/aws/aws-sdk-go-v2/service/ses/types" + "github.com/prozz/aws-embedded-metrics-golang/emf" + "gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/errors" + "gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/logs" + "gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/s3" + "gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/utils" + "gopkg.in/gomail.v2" + "io" + "strings" +) + +const ( + InvalidParameterValueErrorString = "InvalidParameterValue" +) + +var ( + clients = map[string]*ClientWithHelpers{} + + ErrorSESClientNotEstablished = errors.Error("could not establish an SES client") +) + +type ClientWithHelpers struct { + SESClient *ses.Client +} + +type Email struct { + Recipient string `json:"recipient"` + FromName string `json:"from_name"` + FromEmail string `json:"from_email"` + Subject string `json:"subject"` + EmailCcName string `json:"email_cc_name"` + EmailCcAddress string `json:"email_cc_address"` + ReplyToEmailAddress string `json:"reply_to_email_address"` + Body EmailBody `json:"body"` + Attachments *[]s3.S3UploadResponse `json:"attachments"` +} + +type EmailBody struct { + HTMLBody string `json:"html_body"` + TextBody string `json:"text_body"` +} + +func GetClient(region ...string) *ClientWithHelpers { + sesRegion := "eu-west-1" + + // Set custom region + if region != nil && len(region) > 0 { + sesRegion = region[0] + } + + // Check if client exists for region, if it does return it + if sesClient, ok := clients[sesRegion]; ok && sesClient != nil { + return sesClient + } + + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion(sesRegion), + ) + if err != nil { + return &ClientWithHelpers{} + } + + sesClient := NewClient(cfg) + + clients[sesRegion] = sesClient + return sesClient +} + +func NewClient(cfg aws.Config) *ClientWithHelpers { + return &ClientWithHelpers{ + SESClient: ses.NewFromConfig(cfg), + } +} + +func (c *ClientWithHelpers) SendEmail(email Email, emfNamespace string, isDebug bool) (*ses.SendRawEmailOutput, error) { + if c.SESClient == nil { + return nil, ErrorSESClientNotEstablished + } + + recipients := utils.SplitAndCleanEmailAddresses(email.Recipient) + if len(recipients) == 0 { + return nil, nil + } + + // Create raw message + msg := gomail.NewMessage() + msg.SetHeader("To", recipients...) + + // Set the from header, and use gomail's internal function if we have a name and email + from := email.FromEmail + if email.FromName != "" { + from = msg.FormatAddress(email.FromEmail, email.FromName) + } + msg.SetHeader("From", from) + msg.SetAddressHeader("Cc", email.EmailCcAddress, email.EmailCcName) + msg.SetHeader("Subject", email.Subject) + msg.SetBody("text/plain", email.Body.TextBody) // See comment for `AddAlternative` + msg.AddAlternative("text/html", email.Body.HTMLBody) + + // Set the reply-to address + if email.ReplyToEmailAddress != "" { + msg.SetHeader("Reply-To", email.ReplyToEmailAddress) + } + + // Add attachments + if email.Attachments != nil && len(*email.Attachments) != 0 { + for _, attachment := range *email.Attachments { + err := addAttachmentsToEmail(msg, attachment, isDebug) + if err != nil { + return nil, err + } + } + } + + // create a new buffer to add raw data + var emailRaw bytes.Buffer + _, err := msg.WriteTo(&emailRaw) + if err != nil { + return nil, err + } + + // Append the CC address to the recipients + if email.EmailCcAddress != "" { + recipients = append(recipients, email.EmailCcAddress) + } + + // create new raw message + rawEmailInput := &ses.SendRawEmailInput{ + Source: &from, + Destinations: recipients, + RawMessage: &sesTypes.RawMessage{ + Data: emailRaw.Bytes(), + }, + } + + // Send it + result, err := c.SESClient.SendRawEmail(context.TODO(), rawEmailInput) + if err != nil { + messageRejectedErrorCode := errors.AWSErrorExceptionCode(&sesTypes.MessageRejected{}) + mailFromDomainNotVerifiedErrorCode := errors.AWSErrorExceptionCode(&sesTypes.MailFromDomainNotVerifiedException{}) + configurationSetDoesNotExistErrorCode := errors.AWSErrorExceptionCode(&sesTypes.ConfigurationSetDoesNotExistException{}) + + switch errors.AWSErrorExceptionCode(err) { + case messageRejectedErrorCode, mailFromDomainNotVerifiedErrorCode, configurationSetDoesNotExistErrorCode: + logs.ErrorWithMsg(errors.AWSErrorExceptionCode(err), err) + case InvalidParameterValueErrorString: + // Ignore errors due to invalid email addresses - we don't want to log it to raygun + logs.Warn(err.Error()) + err = nil + default: + logs.ErrorWithMsg("Failed to send email", err) + } + + if emfNamespace != "" { + emf.New(). + Namespace(emfNamespace). + MetricAs("email_failed", 1, emf.Count). + Log() + } + } else { + if emfNamespace != "" { + emf.New(). + Namespace(emfNamespace). + MetricAs("email_sent", 1, emf.Count). + Log() + } + } + + return result, err +} + +func addAttachmentsToEmail(msg *gomail.Message, attachment s3.S3UploadResponse, isDebug bool) error { + if attachment.Bucket == "" || attachment.Filename == "" { + logs.Warn("empty bucket and/or filename, cannot attach file") + return nil + } + + // Get object from S3 + object, err := s3.GetClient(isDebug).GetObject(attachment.Bucket, attachment.Filename, isDebug) + if err != nil { + logs.ErrorWithMsg(fmt.Sprintf("Failed to get S3 object from bucket %s filename %s", attachment.Bucket, attachment.Filename), err) + return err + } + defer object.Body.Close() + + attachmentBytes, err := io.ReadAll(object.Body) + if err != nil { + logs.ErrorWithMsg("Could not encode attachment for email", err) + return err + } + + // Get the filename + splitString := strings.Split(attachment.Filename, "/") + filename := splitString[len(splitString)-1] + + msg.Attach( + filename, + gomail.SetCopyFunc(func(w io.Writer) error { + _, err := w.Write(attachmentBytes) + return err + }), + gomail.SetHeader(map[string][]string{"Content-Type": {*object.ContentType}}), + ) + + return nil +}