package notification import ( "context" "errors" "fmt" "github.com/google/uuid" ) // GuestEmailDispatcher is the abstraction the notifier uses to send a // templated email to a single guest. Both SES (production) and Log (dev) // satisfy it. type GuestEmailDispatcher interface { SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) } // EmailSender is the notification.Sender for ChannelEmail. It maps the // generic OutboundMessage shape used by the notifier worker onto our // templated SES / Log path, and honours the suppression list (a guest who // unsubscribed must never receive another email). type EmailSender struct { dispatcher GuestEmailDispatcher suppression *SuppressionRepo } func NewEmailSender(d GuestEmailDispatcher, s *SuppressionRepo) *EmailSender { return &EmailSender{dispatcher: d, suppression: s} } func (e *EmailSender) Send(ctx context.Context, msg OutboundMessage) (string, error) { if msg.GuestID == uuid.Nil { return "", errors.New("missing guest id") } if msg.Channel != ChannelEmail { return "", fmt.Errorf("EmailSender does not handle channel %q", msg.Channel) } to, _ := msg.Metadata["to"].(string) if to == "" { return "", errors.New("email recipient missing from metadata.to") } if e.suppression != nil { yep, err := e.suppression.IsSuppressed(ctx, to) if err != nil { // On lookup error, fail safe by NOT sending — better than // emailing an unsubscribed address. return "", fmt.Errorf("suppression lookup: %w", err) } if yep { return "suppressed:" + to, nil } } tmpl := templateForType(msg.Type) if tmpl == "" { return "", fmt.Errorf("no template for type %q", msg.Type) } subject := msg.Subject if subject == "" { subject = defaultSubject(msg.Type, msg.Metadata) } data := map[string]any{} for k, v := range msg.Metadata { data[k] = v } return e.dispatcher.SendGuest(ctx, to, subject, tmpl, data) } func templateForType(t Type) TemplateName { switch t { case TypeInvitation: return TmplInvitation case TypeConfirmation: return TmplConfirmation case TypeReminder: return TmplReminder case TypeVerification: return TmplVerification } return "" } func defaultSubject(t Type, meta map[string]any) string { event, _ := meta["EventName"].(string) switch t { case TypeInvitation: if event != "" { return "You're invited — " + event } return "You're invited" case TypeConfirmation: if event != "" { return "RSVP confirmed — " + event } return "RSVP confirmed" case TypeReminder: if event != "" { return "Reminder: " + event } return "Reminder" } return "GuestGuard" }