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  }