package service
import (
"context"
"errors"
"fmt"
"html"
"io"
"net/http"
"strconv"
"strings"
"time"
filemanager "wucher/internal/domain/file_manager"
mastersettings "wucher/internal/domain/master_settings"
"wucher/internal/queue"
)
const (
defaultEmailBrandingCompanyName = "Wucher"
authEmailLogoCID = "branding-email-logo"
authEmailLogoFilename = "email-logo.png"
authEmailLogoFetchTimeout = 10 * time.Second
authEmailLogoMaxBytes = 5 << 20
authEmailLogoPresignTTL = time.Minute
)
type AuthEmailBranding struct {
CompanyName string
LogoContentID string
LogoAttachment *queue.EmailAttachment
}
type BuiltAuthEmail struct {
Subject string
TextBody string
HTMLBody string
Attachments []queue.EmailAttachment
}
type AuthEmailBrandingResolver interface {
ResolveEmailBranding(ctx context.Context) AuthEmailBranding
}
type authEmailBrandingResolver struct {
masterSvc mastersettings.Service
fileSvc filemanager.Service
storage filemanager.ObjectStorage
httpClient *http.Client
}
func NewAuthEmailBrandingResolver(masterSvc mastersettings.Service, fileSvc filemanager.Service, storage filemanager.ObjectStorage) AuthEmailBrandingResolver {
return &authEmailBrandingResolver{
masterSvc: masterSvc,
fileSvc: fileSvc,
storage: storage,
httpClient: &http.Client{
Timeout: authEmailLogoFetchTimeout,
},
}
}
func (r *authEmailBrandingResolver) ResolveEmailBranding(ctx context.Context) AuthEmailBranding {
branding := AuthEmailBranding{
CompanyName: defaultEmailBrandingCompanyName,
}
if r == nil || r.masterSvc == nil {
return branding
}
rows, _, err := r.masterSvc.List(ctx, "", "-updated_at", 1, 0)
if err != nil || len(rows) == 0 {
return branding
}
row := rows[0]
if companyName := strings.TrimSpace(row.CompanyName); companyName != "" {
branding.CompanyName = companyName
}
if len(row.LogoFileID) == 0 {
return branding
}
attachment, err := r.resolveInlineLogoAttachment(ctx, row.LogoFileID)
if err != nil || attachment == nil {
return branding
}
branding.LogoContentID = attachment.ContentID
branding.LogoAttachment = attachment
return branding
}
func (r *authEmailBrandingResolver) resolveInlineLogoAttachment(ctx context.Context, fileID []byte) (*queue.EmailAttachment, error) {
if r == nil || r.fileSvc == nil {
return nil, errors.New("file manager service is not configured")
}
if r.storage == nil {
return nil, errors.New("object storage is not configured")
}
fileRow, err := r.fileSvc.GetFileByID(ctx, fileID)
if err != nil {
return nil, fmt.Errorf("get managed branding file: %w", err)
}
if fileRow == nil {
return nil, errors.New("managed branding file not found")
}
if !strings.EqualFold(strings.TrimSpace(fileRow.Status), filemanager.FileStatusReady) {
return nil, errors.New("managed branding file is not ready")
}
objectKey, declaredContentType, err := selectAuthEmailLogoVariant(fileRow)
if err != nil {
return nil, err
}
presigned, err := r.storage.PresignGetObject(ctx, objectKey, authEmailLogoPresignTTL)
if err != nil {
return nil, fmt.Errorf("presign managed branding object: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, presigned, nil)
if err != nil {
return nil, fmt.Errorf("build object request: %w", err)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download logo object: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("object storage returned status %d", resp.StatusCode)
}
limited := io.LimitReader(resp.Body, authEmailLogoMaxBytes+1)
body, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read logo object body: %w", err)
}
if len(body) == 0 {
return nil, errors.New("logo object body is empty")
}
if len(body) > authEmailLogoMaxBytes {
return nil, errors.New("logo object exceeds max allowed size")
}
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = declaredContentType
}
if !isAuthEmailSafeImageContentType(contentType) {
return nil, errors.New("managed branding asset is not email-safe")
}
return &queue.EmailAttachment{
Filename: authEmailLogoFilename,
ContentType: normalizeAuthEmailImageContentType(contentType),
ContentID: authEmailLogoCID,
Content: body,
Inline: true,
}, nil
}
func selectAuthEmailLogoVariant(fileRow *filemanager.File) (string, string, error) {
if fileRow == nil {
return "", "", errors.New("managed branding file is missing")
}
if fileRow.ThumbnailObjectKey != nil {
thumbKey := strings.TrimSpace(*fileRow.ThumbnailObjectKey)
thumbType := strings.TrimSpace(authEmailPtrStringValue(fileRow.ThumbnailMimeType))
if thumbKey != "" && isAuthEmailSafeImageContentType(thumbType) {
return thumbKey, thumbType, nil
}
}
originalKey := strings.TrimSpace(fileRow.ObjectKey)
originalType := strings.TrimSpace(fileRow.MimeType)
if originalKey != "" && isAuthEmailSafeImageContentType(originalType) {
return originalKey, originalType, nil
}
return "", "", errors.New("managed branding file has no email-safe variant")
}
func isAuthEmailSafeImageContentType(contentType string) bool {
contentType = normalizeAuthEmailImageContentType(contentType)
switch contentType {
case "image/png", "image/jpeg", "image/gif":
return true
default:
return false
}
}
func normalizeAuthEmailImageContentType(contentType string) string {
contentType = strings.ToLower(strings.TrimSpace(contentType))
if idx := strings.Index(contentType, ";"); idx >= 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
if contentType == "image/jpg" {
return "image/jpeg"
}
return contentType
}
func authEmailPtrStringValue(v *string) string {
if v == nil {
return ""
}
return *v
}
type AuthEmailTemplateContent struct {
Subject string
Greeting string
Title string
Message []string
Code string
CTALabel string
CTAURL string
HelpText string
FooterText string
}
func buildAuthEmailTemplate(ctx context.Context, resolver AuthEmailBrandingResolver, content AuthEmailTemplateContent) BuiltAuthEmail {
subject := strings.TrimSpace(content.Subject)
greeting := strings.TrimSpace(content.Greeting)
title := strings.TrimSpace(content.Title)
ctaLabel := strings.TrimSpace(content.CTALabel)
ctaURL := strings.TrimSpace(content.CTAURL)
helpText := strings.TrimSpace(content.HelpText)
footerText := strings.TrimSpace(content.FooterText)
code := strings.TrimSpace(content.Code)
messageParts := make([]string, 0, len(content.Message))
for i := range content.Message {
part := strings.TrimSpace(content.Message[i])
if part == "" {
continue
}
messageParts = append(messageParts, part)
}
textLines := make([]string, 0, len(messageParts)+10)
if greeting != "" {
textLines = append(textLines, greeting, "")
}
textLines = append(textLines, title, "")
textLines = append(textLines, messageParts...)
if code != "" {
textLines = append(textLines, code)
}
if ctaURL != "" {
textLines = append(textLines, "", ctaLabel+":", ctaURL)
}
if helpText != "" {
textLines = append(textLines, "", helpText)
}
if footerText != "" {
textLines = append(textLines, "", footerText)
}
branding := AuthEmailBranding{CompanyName: defaultEmailBrandingCompanyName}
if resolver != nil {
branding = resolver.ResolveEmailBranding(ctx)
if strings.TrimSpace(branding.CompanyName) == "" {
branding.CompanyName = defaultEmailBrandingCompanyName
}
}
var attachments []queue.EmailAttachment
if branding.LogoAttachment != nil {
attachments = append(attachments, cloneAuthEmailAttachment(*branding.LogoAttachment))
}
return BuiltAuthEmail{
Subject: subject,
TextBody: strings.Join(textLines, "\n"),
HTMLBody: renderAuthEmailHTML(branding, AuthEmailTemplateContent{Subject: subject, Greeting: greeting, Title: title, Message: messageParts, Code: code, CTALabel: ctaLabel, CTAURL: ctaURL, HelpText: helpText, FooterText: footerText}),
Attachments: attachments,
}
}
func cloneAuthEmailAttachment(attachment queue.EmailAttachment) queue.EmailAttachment {
attachment.Content = append([]byte(nil), attachment.Content...)
return attachment
}
// emailFontStack is the cross-client system font stack used in all email cells.
const emailFontStack = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif`
// emailCell writes an opening
| with the given inline style.
func emailCell(b *strings.Builder, style string) {
b.WriteString(` |
| `)
}
func emailCellClose(b *strings.Builder) {
b.WriteString(` |
`)
}
// emailSpacer writes a zero-content spacer row.
func emailSpacer(b *strings.Builder, height string) {
b.WriteString(`| |
`)
}
// emailDivider writes a 1px horizontal rule as a nested table (Outlook-safe).
func emailDivider(b *strings.Builder) {
b.WriteString(``)
b.WriteString(``)
b.WriteString(`| | `)
b.WriteString(` |
`)
}
func renderAuthEmailHTML(branding AuthEmailBranding, content AuthEmailTemplateContent) string {
subject := html.EscapeString(strings.TrimSpace(content.Subject))
greeting := html.EscapeString(strings.TrimSpace(content.Greeting))
title := html.EscapeString(strings.TrimSpace(content.Title))
ctaLabel := html.EscapeString(strings.TrimSpace(content.CTALabel))
ctaURL := html.EscapeString(strings.TrimSpace(content.CTAURL))
helpText := html.EscapeString(strings.TrimSpace(content.HelpText))
footerText := html.EscapeString(strings.TrimSpace(content.FooterText))
code := html.EscapeString(strings.TrimSpace(content.Code))
companyName := html.EscapeString(strings.TrimSpace(branding.CompanyName))
logoContentID := html.EscapeString(strings.TrimSpace(branding.LogoContentID))
var b strings.Builder
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(subject)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(subject)
b.WriteString(strings.Repeat(" ", 50))
b.WriteString(`
`)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(`| | `)
b.WriteString(` `)
b.WriteString(``)
emailSpacer(&b, "44px")
b.WriteString(``)
if logoContentID != "" {
b.WriteString(` `)
} else {
b.WriteString(``)
b.WriteString(companyName)
b.WriteString(``)
}
b.WriteString(` | `)
emailDivider(&b)
emailSpacer(&b, "40px")
if greeting != "" {
b.WriteString(`| `)
b.WriteString(greeting)
b.WriteString(` | `)
}
b.WriteString(`| `)
b.WriteString(title)
b.WriteString(` | `)
for i := range content.Message {
msg := html.EscapeString(strings.TrimSpace(content.Message[i]))
if msg == "" {
continue
}
b.WriteString(`| `)
b.WriteString(msg)
b.WriteString(` | `)
}
if code != "" {
b.WriteString(`| `)
b.WriteString(``)
b.WriteString(code)
b.WriteString(` | `)
}
if ctaURL != "" && ctaLabel != "" {
emailSpacer(&b, "20px")
b.WriteString(`| `)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(ctaLabel)
b.WriteString(``)
b.WriteString(``)
b.WriteString(` | `)
b.WriteString(`| Or copy and paste this link: | `)
b.WriteString(`| `)
b.WriteString(ctaURL)
b.WriteString(` | `)
}
emailDivider(&b)
emailSpacer(&b, "24px")
if helpText != "" {
b.WriteString(`| `)
b.WriteString(helpText)
b.WriteString(` | `)
}
b.WriteString(``)
b.WriteString(``)
b.WriteString(companyName)
b.WriteString(``)
if footerText != "" {
b.WriteString(` `)
b.WriteString(footerText)
b.WriteString(``)
}
b.WriteString(` | `)
emailSpacer(&b, "48px")
b.WriteString(` `)
b.WriteString(` |
`)
b.WriteString(`
`)
b.WriteString(``)
return b.String()
}
func formatEmailTTLMinutes(ttl time.Duration, fallback int) int {
minutes := int(ttl.Minutes())
if minutes < 1 {
return fallback
}
return minutes
}
func buildPasswordResetEmail(ctx context.Context, resolver AuthEmailBrandingResolver, userName string, link string, ttl time.Duration) BuiltAuthEmail {
minutes := formatEmailTTLMinutes(ttl, 20)
greeting := ""
if name := strings.TrimSpace(userName); name != "" {
greeting = "Hi " + name + ","
}
return buildAuthEmailTemplate(ctx, resolver, AuthEmailTemplateContent{
Subject: "Reset your password",
Greeting: greeting,
Title: "Reset your password",
Message: []string{
"We received a request to reset your password.",
"Click the button below to choose a new password. This link expires in " + strconv.Itoa(minutes) + " minutes and can only be used once.",
},
CTALabel: "Reset Password",
CTAURL: link,
HelpText: "If you did not request this, you can safely ignore this email. Your password will remain unchanged.",
FooterText: "For your security, never share this link with anyone.",
})
}
func buildSecurityPINResetEmail(ctx context.Context, resolver AuthEmailBrandingResolver, userName string, link string, ttl time.Duration) BuiltAuthEmail {
minutes := formatEmailTTLMinutes(ttl, 20)
greeting := ""
if name := strings.TrimSpace(userName); name != "" {
greeting = "Hi " + name + ","
}
return buildAuthEmailTemplate(ctx, resolver, AuthEmailTemplateContent{
Subject: "Reset your security PIN",
Greeting: greeting,
Title: "Reset your security PIN",
Message: []string{
"We received a request to reset your security PIN.",
"Click the button below to set a new 6-digit PIN. This link expires in " + strconv.Itoa(minutes) + " minutes and can only be used once.",
},
CTALabel: "Reset Security PIN",
CTAURL: link,
HelpText: "If you did not request this, you can safely ignore this email. Your PIN will remain unchanged.",
FooterText: "For your security, never share this link or your PIN with anyone.",
})
}
func buildLoginOTPEmail(ctx context.Context, resolver AuthEmailBrandingResolver, userName, otpCode string, ttl time.Duration) BuiltAuthEmail {
minutes := formatEmailTTLMinutes(ttl, 10)
greeting := ""
if name := strings.TrimSpace(userName); name != "" {
greeting = "Hi " + name + ","
}
return buildAuthEmailTemplate(ctx, resolver, AuthEmailTemplateContent{
Subject: "Your login verification code",
Greeting: greeting,
Title: "Verify your login",
Message: []string{
"Use this one-time code to complete your login:",
"This code expires in " + strconv.Itoa(minutes) + " minutes.",
},
Code: strings.TrimSpace(otpCode),
HelpText: "If this wasn't you, change your password immediately and contact support.",
FooterText: "For your security, never share this code with anyone.",
})
}