persona-community-1/services/persona-api/internal/adapter/email/notify.go
2026-02-23 10:21:29 +00:00

113 lines
3.3 KiB
Go

package email
import (
"context"
"fmt"
emailpkg "git.threesix.ai/jordan/persona-community-1/pkg/email"
"git.threesix.ai/jordan/persona-community-1/pkg/logging"
"git.threesix.ai/jordan/persona-community-1/pkg/notify"
"git.threesix.ai/jordan/persona-community-1/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, &notify.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,
}
}
}