113 lines
3.3 KiB
Go
113 lines
3.3 KiB
Go
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,
|
|
}
|
|
}
|
|
}
|