package notification import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sesv2" "github.com/aws/aws-sdk-go-v2/service/sesv2/types" ) // SESConfig is the surface area for picking an SES sender. ConfigurationSet // is optional but recommended in production — it's where bounce + complaint // SNS topics get wired so the webhook handler has something to consume. type SESConfig struct { Region string FromEmail string FromName string ConfigurationSet string PublicBaseURL string // for unsubscribe links in templates } // SESEmailSender sends transactional emails (verification + reset for the // auth flows, plus invitation/confirmation/reminder for guests) via Amazon // SESv2. The same client serves both audiences so callers don't end up // with two SES configurations to maintain. type SESEmailSender struct { client *sesv2.Client tpls *Templates from string configSet *string baseURL string } // NewSESEmailSender returns a configured SES sender, or an error if the // AWS SDK can't bootstrap. The caller typically constructs this once at // startup and reuses it for the lifetime of the process. func NewSESEmailSender(ctx context.Context, cfg SESConfig, tpls *Templates) (*SESEmailSender, error) { if cfg.FromEmail == "" { return nil, fmt.Errorf("ses: FromEmail required") } if cfg.Region == "" { cfg.Region = "us-east-1" } awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region)) if err != nil { return nil, fmt.Errorf("ses: load aws config: %w", err) } client := sesv2.NewFromConfig(awsCfg) from := cfg.FromEmail if cfg.FromName != "" { from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromEmail) } var cs *string if cfg.ConfigurationSet != "" { cs = aws.String(cfg.ConfigurationSet) } return &SESEmailSender{ client: client, tpls: tpls, from: from, configSet: cs, baseURL: strings.TrimRight(cfg.PublicBaseURL, "/"), }, nil } // --- auth.EmailSender implementation --- // SendVerification renders the verification template and posts it to SES. func (s *SESEmailSender) SendVerification(ctx context.Context, to, name, link string) error { return s.sendTemplated(ctx, to, "Verify your GuestGuard email", TmplVerification, map[string]any{ "Name": name, "Link": link, }) } // SendPasswordReset renders the reset template and posts it to SES. func (s *SESEmailSender) SendPasswordReset(ctx context.Context, to, name, link string) error { return s.sendTemplated(ctx, to, "Reset your GuestGuard password", TmplPasswordReset, map[string]any{ "Name": name, "Link": link, "ExpiryHumane": "1 hour", }) } // SendCollaboratorInvite renders the team-invite template and posts it to SES. func (s *SESEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error { return s.sendTemplated(ctx, to, inviterName+" invited you to "+eventName, TmplCollaboratorInvite, map[string]any{ "InviterName": inviterName, "EventName": eventName, "Role": role, "Link": link, }) } // SendGuest is used by the notifier worker for invitation / confirmation / // reminder emails — anything addressed at a guest. func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) { return s.sendTemplatedReturnID(ctx, to, subject, name, data) } // --- internals --- func (s *SESEmailSender) sendTemplated(ctx context.Context, to, subject string, name TemplateName, data map[string]any) error { _, err := s.sendTemplatedReturnID(ctx, to, subject, name, data) return err } func (s *SESEmailSender) sendTemplatedReturnID(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { if data == nil { data = map[string]any{} } data["Subject"] = subject html, text, err := s.tpls.Render(name, data) if err != nil { return "", err } out, err := s.client.SendEmail(ctx, &sesv2.SendEmailInput{ FromEmailAddress: aws.String(s.from), Destination: &types.Destination{ToAddresses: []string{to}}, ConfigurationSetName: s.configSet, Content: &types.EmailContent{ Simple: &types.Message{ Subject: &types.Content{Data: aws.String(subject), Charset: aws.String("UTF-8")}, Body: &types.Body{ Html: &types.Content{Data: aws.String(html), Charset: aws.String("UTF-8")}, Text: &types.Content{Data: aws.String(text), Charset: aws.String("UTF-8")}, }, }, }, }) if err != nil { return "", fmt.Errorf("ses: send: %w", err) } if out.MessageId == nil { return "", nil } return *out.MessageId, nil }