package email import ( "context" "fmt" emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email" "git.threesix.ai/jordan/persona-community-2/pkg/logging" "git.threesix.ai/jordan/persona-community-2/pkg/notify" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port" ) // Compile-time interface check. var _ port.EmailSender = (*NotifySender)(nil) // NotifySender sends transactional emails via the orchard9 notify service. // It renders HTML using the email.Renderer before sending so every email // has a styled layout with inline CSS. type NotifySender struct { client *notify.Client renderer *emailpkg.Renderer host string from string logger *logging.Logger } // NewNotifySender creates a notify-backed email sender with HTML rendering. func NewNotifySender(client *notify.Client, renderer *emailpkg.Renderer, host, from string, logger *logging.Logger) *NotifySender { return &NotifySender{ client: client, renderer: renderer, host: host, from: from, logger: logger.WithComponent("EmailSender"), } } func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error { // Map (purpose, code) to the correct template context. emailCtx := purposeToContext(purpose, code) rendered, err := s.renderer.Render(purpose, emailCtx) if err != nil { s.logger.Error("failed to render email template", "purpose", purpose, "error", err) return fmt.Errorf("render email %s: %w", purpose, err) } if rendered.CSSInlineErr != nil { s.logger.Warn("CSS inlining failed for email, styles may be degraded in some clients", "purpose", purpose, "error", rendered.CSSInlineErr) } resp, err := s.client.SendEmail(ctx, ¬ify.SendRequest{ To: toEmail, From: s.from, Content: notify.Content{ Subject: rendered.Subject, HTML: rendered.HTML, Text: rendered.PlainText, }, Meta: notify.Meta{ Host: s.host, Category: "critical", Tags: []string{"auth", purpose}, }, Options: notify.Options{ // Stable idempotency key: same user + same code = same key, safe to retry. IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code), }, }) if err != nil { s.logger.Error("failed to send email via notify", "to", toEmail, "purpose", purpose, "error", err) return fmt.Errorf("send email: %w", err) } s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID) return nil } // purposeToContext maps (purpose, code) to an EmailContext for template rendering. // code may be an OTP digit string or a URL depending on the purpose. func purposeToContext(purpose, code string) emailpkg.EmailContext { switch purpose { case "login_otp": return emailpkg.EmailContext{ Code: code, ExpiresIn: 10, Purpose: "sign in", } case "magic_link": return emailpkg.EmailContext{ ActionURL: emailpkg.SafeURL(code), ButtonText: "Sign In \u2192", ExpiresIn: 15, } case "password_reset": return emailpkg.EmailContext{ ActionURL: emailpkg.SafeURL(code), ButtonText: "Reset Password \u2192", ExpiresIn: 60, } case "email_verify": return emailpkg.EmailContext{ Code: code, ExpiresIn: 30, Purpose: "verify your email", } default: return emailpkg.EmailContext{ Code: code, } } }