Files
fm_be/internal/service/auth_email_template.go
2026-07-16 22:16:45 +07:00

560 lines
19 KiB
Go

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 <tr><td> with the given inline style.
func emailCell(b *strings.Builder, style string) {
b.WriteString(`<tr><td style="`)
b.WriteString(style)
b.WriteString(`">`)
}
func emailCellClose(b *strings.Builder) {
b.WriteString(`</td></tr>`)
}
// emailSpacer writes a zero-content spacer row.
func emailSpacer(b *strings.Builder, height string) {
b.WriteString(`<tr><td style="height:`)
b.WriteString(height)
b.WriteString(`;font-size:0;line-height:0;">&nbsp;</td></tr>`)
}
// emailDivider writes a 1px horizontal rule as a nested table (Outlook-safe).
func emailDivider(b *strings.Builder) {
b.WriteString(`<tr><td style="padding:0 32px;">`)
b.WriteString(`<table role="presentation" width="100%" cellspacing="0" cellpadding="0">`)
b.WriteString(`<tr><td style="height:1px;background-color:#e4e4e7;font-size:0;line-height:0;">&nbsp;</td></tr>`)
b.WriteString(`</table></td></tr>`)
}
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(`<!DOCTYPE html><html lang="en">`)
b.WriteString(`<head>`)
b.WriteString(`<meta charset="UTF-8">`)
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1.0">`)
b.WriteString(`<meta http-equiv="X-UA-Compatible" content="IE=edge">`)
b.WriteString(`<title>`)
b.WriteString(subject)
b.WriteString(`</title>`)
b.WriteString(`<style>`)
b.WriteString(`@media only screen and (max-width:620px){`)
b.WriteString(`.em-cell{padding-left:20px!important;padding-right:20px!important;}`)
b.WriteString(`.em-cta a{display:block!important;text-align:center!important;}`)
b.WriteString(`}`)
b.WriteString(`</style>`)
b.WriteString(`</head>`)
b.WriteString(`<body style="margin:0;padding:0;background-color:#ffffff;`)
b.WriteString(`-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;">`)
b.WriteString(`<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;color:#ffffff;">`)
b.WriteString(subject)
b.WriteString(strings.Repeat(" &zwnj;&nbsp;", 50))
b.WriteString(`</div>`)
b.WriteString(`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" bgcolor="#ffffff">`)
b.WriteString(`<tr><td align="center" style="padding:0;">`)
b.WriteString(`<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;">`)
b.WriteString(`<tr><td style="height:4px;background-color:#18181b;font-size:0;line-height:0;">&nbsp;</td></tr>`)
b.WriteString(`</table>`)
b.WriteString(`<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;">`)
emailSpacer(&b, "44px")
b.WriteString(`<tr><td class="em-cell" align="left" style="padding:0 32px 36px;">`)
if logoContentID != "" {
b.WriteString(`<img src="cid:`)
b.WriteString(logoContentID)
b.WriteString(`" alt="`)
b.WriteString(companyName)
b.WriteString(`" height="36" style="display:block;height:36px;width:auto;max-width:200px;border:0;outline:none;text-decoration:none;">`)
} else {
b.WriteString(`<span style="font-size:16px;font-weight:700;color:#18181b;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;letter-spacing:-0.3px;">`)
b.WriteString(companyName)
b.WriteString(`</span>`)
}
b.WriteString(`</td></tr>`)
emailDivider(&b)
emailSpacer(&b, "40px")
if greeting != "" {
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 6px;font-size:14px;line-height:1.6;color:#71717a;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(greeting)
b.WriteString(`</td></tr>`)
}
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 20px;font-size:26px;line-height:1.25;font-weight:700;`)
b.WriteString(`letter-spacing:-0.5px;color:#18181b;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(title)
b.WriteString(`</td></tr>`)
for i := range content.Message {
msg := html.EscapeString(strings.TrimSpace(content.Message[i]))
if msg == "" {
continue
}
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 12px;font-size:15px;line-height:1.75;color:#3f3f46;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(msg)
b.WriteString(`</td></tr>`)
}
if code != "" {
b.WriteString(`<tr><td class="em-cell" style="padding:4px 32px 16px;">`)
b.WriteString(`<span style="display:inline-block;background-color:#f4f4f5;border:1px solid #e4e4e7;`)
b.WriteString(`border-radius:10px;padding:12px 16px;font-size:28px;line-height:1;letter-spacing:6px;`)
b.WriteString(`color:#111827;font-weight:700;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(code)
b.WriteString(`</span></td></tr>`)
}
if ctaURL != "" && ctaLabel != "" {
emailSpacer(&b, "20px")
b.WriteString(`<tr><td class="em-cell em-cta" align="left" style="padding:0 32px 16px;">`)
b.WriteString(`<!--[if mso]>`)
b.WriteString(`<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"`)
b.WriteString(` href="`)
b.WriteString(ctaURL)
b.WriteString(`" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="18%" stroke="f" fillcolor="#18181b">`)
b.WriteString(`<w:anchorlock/>`)
b.WriteString(`<center style="color:#ffffff;font-family:sans-serif;font-size:14px;font-weight:700;">`)
b.WriteString(ctaLabel)
b.WriteString(`</center></v:roundrect><![endif]-->`)
b.WriteString(`<!--[if !mso]><!-->`)
b.WriteString(`<a href="`)
b.WriteString(ctaURL)
b.WriteString(`" target="_blank" style="display:inline-block;background-color:#18181b;color:#ffffff;`)
b.WriteString(`font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;font-size:14px;font-weight:600;letter-spacing:0.1px;line-height:1;`)
b.WriteString(`text-decoration:none;padding:13px 26px;border-radius:8px;`)
b.WriteString(`-webkit-text-size-adjust:none;">`)
b.WriteString(ctaLabel)
b.WriteString(`</a>`)
b.WriteString(`<!--<![endif]-->`)
b.WriteString(`</td></tr>`)
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 4px;font-size:12px;line-height:1.6;color:#a1a1aa;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">Or copy and paste this link:</td></tr>`)
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 36px;font-size:12px;line-height:1.6;color:#71717a;`)
b.WriteString(`word-break:break-all;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(ctaURL)
b.WriteString(`</td></tr>`)
}
emailDivider(&b)
emailSpacer(&b, "24px")
if helpText != "" {
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 32px;font-size:13px;line-height:1.7;color:#71717a;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(helpText)
b.WriteString(`</td></tr>`)
}
b.WriteString(`<tr><td class="em-cell" style="padding:20px 32px 0;border-top:1px solid #e4e4e7;">`)
b.WriteString(`<span style="font-size:12px;line-height:1.7;color:#a1a1aa;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(companyName)
b.WriteString(`</span>`)
if footerText != "" {
b.WriteString(`<br><span style="font-size:12px;line-height:1.7;color:#a1a1aa;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(footerText)
b.WriteString(`</span>`)
}
b.WriteString(`</td></tr>`)
emailSpacer(&b, "48px")
b.WriteString(`</table>`)
b.WriteString(`</td></tr>`)
b.WriteString(`</table>`)
b.WriteString(`</body></html>`)
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.",
})
}