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(`
 
`) b.WriteString(``) emailSpacer(&b, "44px") b.WriteString(``) emailDivider(&b) emailSpacer(&b, "40px") if greeting != "" { b.WriteString(``) } b.WriteString(``) for i := range content.Message { msg := html.EscapeString(strings.TrimSpace(content.Message[i])) if msg == "" { continue } b.WriteString(``) } if code != "" { b.WriteString(``) } if ctaURL != "" && ctaLabel != "" { emailSpacer(&b, "20px") b.WriteString(``) b.WriteString(``) b.WriteString(``) } emailDivider(&b) emailSpacer(&b, "24px") if helpText != "" { b.WriteString(``) } b.WriteString(``) emailSpacer(&b, "48px") b.WriteString(`
`) if logoContentID != "" { b.WriteString(``)
		b.WriteString(companyName)
		b.WriteString(``) } else { b.WriteString(``) b.WriteString(companyName) b.WriteString(``) } b.WriteString(`
`) b.WriteString(greeting) b.WriteString(`
`) b.WriteString(title) b.WriteString(`
`) b.WriteString(msg) b.WriteString(`
`) b.WriteString(``) b.WriteString(code) b.WriteString(`
`) b.WriteString(``) b.WriteString(``) b.WriteString(``) b.WriteString(ctaLabel) b.WriteString(``) b.WriteString(``) b.WriteString(`
Or copy and paste this link:
`) b.WriteString(ctaURL) b.WriteString(`
`) b.WriteString(helpText) b.WriteString(`
`) b.WriteString(``) b.WriteString(companyName) b.WriteString(``) if footerText != "" { b.WriteString(`
`) b.WriteString(footerText) 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.", }) }