560 lines
19 KiB
Go
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;"> </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;"> </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(" ‌ ", 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;"> </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.",
|
|
})
|
|
}
|