Files
honeyDueAPI/internal/services/email_service.go
Trey t 42a5533a56 Fix 113 hardening issues across entire Go backend
Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:13 -05:00

1146 lines
53 KiB
Go

package services
import (
"bytes"
"fmt"
"html/template"
"time"
mail "github.com/wneessen/go-mail"
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/config"
)
// EmailService handles sending emails
type EmailService struct {
cfg *config.EmailConfig
client *mail.Client
enabled bool
}
// NewEmailService creates a new email service
func NewEmailService(cfg *config.EmailConfig, enabled bool) *EmailService {
client, err := mail.NewClient(cfg.Host,
mail.WithPort(cfg.Port),
mail.WithSMTPAuth(mail.SMTPAuthPlain),
mail.WithUsername(cfg.User),
mail.WithPassword(cfg.Password),
mail.WithTLSPortPolicy(mail.TLSOpportunistic),
)
if err != nil {
log.Error().Err(err).Msg("Failed to create mail client - emails will not be sent")
return &EmailService{
cfg: cfg,
client: nil,
enabled: false,
}
}
return &EmailService{
cfg: cfg,
client: client,
enabled: enabled,
}
}
// SendEmail sends an email
func (s *EmailService) SendEmail(to, subject, htmlBody, textBody string) error {
if !s.enabled {
log.Debug().Msg("Email sending disabled by feature flag")
return nil
}
m := mail.NewMsg()
if err := m.FromFormat("honeyDue", s.cfg.From); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
if err := m.AddTo(to); err != nil {
return fmt.Errorf("failed to set to address: %w", err)
}
m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, textBody)
m.AddAlternativeString(mail.TypeTextHTML, htmlBody)
if err := s.client.DialAndSend(m); err != nil {
log.Error().Err(err).Str("to", to).Str("subject", subject).Msg("Failed to send email")
return fmt.Errorf("failed to send email: %w", err)
}
log.Info().Str("to", to).Str("subject", subject).Msg("Email sent successfully")
return nil
}
// EmailAttachment represents an email attachment
type EmailAttachment struct {
Filename string
ContentType string
Data []byte
}
// EmbeddedImage represents an inline image in an email
type EmbeddedImage struct {
ContentID string // Used in HTML as src="cid:ContentID"
Filename string
ContentType string
Data []byte
}
// SendEmailWithAttachment sends an email with an attachment
func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *EmailAttachment) error {
if !s.enabled {
log.Debug().Msg("Email sending disabled by feature flag")
return nil
}
m := mail.NewMsg()
if err := m.FromFormat("honeyDue", s.cfg.From); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
if err := m.AddTo(to); err != nil {
return fmt.Errorf("failed to set to address: %w", err)
}
m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, textBody)
m.AddAlternativeString(mail.TypeTextHTML, htmlBody)
if attachment != nil {
m.AttachReader(attachment.Filename,
bytes.NewReader(attachment.Data),
mail.WithFileContentType(mail.ContentType(attachment.ContentType)),
)
}
if err := s.client.DialAndSend(m); err != nil {
log.Error().Err(err).Str("to", to).Str("subject", subject).Msg("Failed to send email with attachment")
return fmt.Errorf("failed to send email: %w", err)
}
log.Info().Str("to", to).Str("subject", subject).Str("attachment", attachment.Filename).Msg("Email with attachment sent successfully")
return nil
}
// SendEmailWithEmbeddedImages sends an email with inline embedded images
func (s *EmailService) SendEmailWithEmbeddedImages(to, subject, htmlBody, textBody string, images []EmbeddedImage) error {
if !s.enabled {
log.Debug().Msg("Email sending disabled by feature flag")
return nil
}
m := mail.NewMsg()
if err := m.FromFormat("honeyDue", s.cfg.From); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
if err := m.AddTo(to); err != nil {
return fmt.Errorf("failed to set to address: %w", err)
}
m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, textBody)
m.AddAlternativeString(mail.TypeTextHTML, htmlBody)
// Embed each image with Content-ID for inline display
for _, img := range images {
img := img // capture range variable for closure
m.EmbedReader(img.Filename,
bytes.NewReader(img.Data),
mail.WithFileContentType(mail.ContentType(img.ContentType)),
mail.WithFileContentID(img.ContentID),
)
}
if err := s.client.DialAndSend(m); err != nil {
log.Error().Err(err).Str("to", to).Str("subject", subject).Int("images", len(images)).Msg("Failed to send email with embedded images")
return fmt.Errorf("failed to send email: %w", err)
}
log.Info().Str("to", to).Str("subject", subject).Int("images", len(images)).Msg("Email with embedded images sent successfully")
return nil
}
// ──────────────────────────────────────────────────────────────────────────────
// honeyDue "Warm Sage" Email Design System
// Matching the web landing page: myhoneydue.com
// ──────────────────────────────────────────────────────────────────────────────
// emailIconURL is the URL for the email icon
const emailIconURL = "https://api.myhoneydue.com/images/icon.png"
// emailFontStack — Outfit via Google Fonts with progressive fallback
const emailFontStack = "Outfit, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
// baseEmailTemplate wraps all email content in the honeyDue shell:
// cream background → white card with warm shadow → sage brand stripe at top
func baseEmailTemplate() string {
return `<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>%s</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
/* Reset */
body, table, td, p, a, li { -webkit-text-size-adjust: 100%%; -ms-text-size-adjust: 100%%; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
/* Mobile */
@media only screen and (max-width: 620px) {
.email-container { width: 100%% !important; max-width: 100%% !important; }
.email-body { padding: 32px 20px !important; }
.email-header { padding: 32px 20px !important; }
.stat-cell { display: block !important; width: 100%% !important; text-align: left !important; padding: 8px 0 !important; border-left: none !important; }
.feature-icon { width: 40px !important; height: 40px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #FAFAF7; font-family: ` + emailFontStack + `; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
<!-- Outer wrapper -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color: #FAFAF7;">
<tr>
<td align="center" style="padding: 48px 24px;">
<!--[if mso]><table role="presentation" cellspacing="0" cellpadding="0" border="0" width="560"><tr><td><![endif]-->
<table role="presentation" class="email-container" width="560" cellspacing="0" cellpadding="0" border="0" style="max-width: 560px; margin: 0 auto;">
<!-- Sage brand stripe -->
<tr>
<td style="height: 4px; background-color: #6B8F71; border-radius: 16px 16px 0 0; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Main card -->
<tr>
<td style="background: #FFFFFF; border-left: 1px solid #E8E3DC; border-right: 1px solid #E8E3DC; border-bottom: 1px solid #E8E3DC; border-radius: 0 0 16px 16px; box-shadow: 0 4px 16px rgba(45, 52, 54, 0.06), 0 1px 4px rgba(45, 52, 54, 0.03);">
%s
</td>
</tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
<!-- Sub-footer (outside card) -->
<table role="presentation" class="email-container" width="560" cellspacing="0" cellpadding="0" border="0" style="max-width: 560px; margin: 0 auto;">
<tr>
<td style="padding: 24px 0 0 0; text-align: center;">
<p style="font-family: ` + emailFontStack + `; font-size: 12px; color: #8A8F87; margin: 0;">Your home, perfectly maintained.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}
// emailHeader renders the clean white header: logo + brand name + subtitle
func emailHeader(subtitle string) string {
return fmt.Sprintf(`
<!-- Header -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-header" style="padding: 40px 48px 32px 48px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="vertical-align: middle; padding-right: 14px;">
<img src="%s" alt="honeyDue" width="44" height="44" style="display: block; border-radius: 12px; border: 0;" />
</td>
<td style="vertical-align: middle;">
<p style="font-family: %s; font-size: 22px; font-weight: 800; color: #2D3436; margin: 0; letter-spacing: -0.5px;">honeyDue</p>
</td>
</tr>
</table>
<p style="font-family: %s; font-size: 26px; font-weight: 700; color: #2D3436; margin: 28px 0 0 0; letter-spacing: -0.5px; line-height: 1.2;">%s</p>
</td>
</tr>
</table>`, emailIconURL, emailFontStack, emailFontStack, subtitle)
}
// emailDivider renders a subtle stone-colored horizontal rule
func emailDivider() string {
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding: 0 48px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="height: 1px; background-color: #E8E3DC; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>`
}
// emailFooter renders the footer with copyright
func emailFooter(year int) string {
return fmt.Sprintf(`
<!-- Divider -->
%s
<!-- Footer -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding: 24px 48px 32px 48px; text-align: center;">
<p style="font-family: %s; font-size: 12px; color: #8A8F87; margin: 0;">&copy; %d honeyDue. All rights reserved.</p>
<p style="font-family: %s; font-size: 12px; margin: 8px 0 0 0;"><a href="mailto:honeydue@treymail.com" style="color: #6B8F71; text-decoration: none;">honeydue@treymail.com</a></p>
</td>
</tr>
</table>`, emailDivider(), emailFontStack, year, emailFontStack)
}
// emailButton renders a pill-shaped CTA button (matches landing page hero)
func emailButton(text, href, bgColor string) string {
return fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin: 32px 0 8px 0;">
<tr>
<td align="center">
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="%s" style="height:52px;v-text-anchor:middle;width:260px;" arcsize="50%%" fill="t" stroke="f"><v:fill type="tile" color="%s" /><v:textbox inset="0,0,0,0"><center><![endif]-->
<a href="%s" style="display: inline-block; background-color: %s; color: #FFFFFF; font-family: %s; font-size: 15px; font-weight: 600; text-decoration: none; padding: 14px 36px; border-radius: 50px; box-shadow: 0 2px 8px rgba(196, 133, 106, 0.25); mso-hide: all;">%s</a>
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
</td>
</tr>
</table>`, href, bgColor, href, bgColor, emailFontStack, text)
}
// emailCodeBox renders a verification/reset code in a prominent box
func emailCodeBox(code, expiryText string) string {
return fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td align="center">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: #FAFAF7; border: 1px solid #E8E3DC; border-radius: 16px; min-width: 280px;">
<tr>
<td style="padding: 28px 40px; text-align: center;">
<p style="font-family: 'Courier New', Courier, monospace; font-size: 38px; font-weight: 700; letter-spacing: 10px; color: #6B8F71; margin: 0; line-height: 1;">%s</p>
<p style="font-family: %s; font-size: 13px; color: #8A8F87; margin: 14px 0 0 0;">%s</p>
</td>
</tr>
</table>
</td>
</tr>
</table>`, code, emailFontStack, expiryText)
}
// emailCalloutBox renders a linen-background callout with optional accent border
func emailCalloutBox(content string) string {
return fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td style="background-color: #F2EFE9; border-radius: 12px; padding: 24px 28px; border: 1px solid #E8E3DC;">
%s
</td>
</tr>
</table>`, content)
}
// emailAlertBox renders a warning/info box with colored left accent
func emailAlertBox(title, body, accentColor, bgColor, titleColor string) string {
return fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td style="background-color: %s; border-left: 4px solid %s; border-radius: 0 12px 12px 0; padding: 18px 24px;">
<p style="font-family: %s; font-size: 14px; font-weight: 700; color: %s; margin: 0 0 6px 0;">%s</p>
<p style="font-family: %s; font-size: 14px; line-height: 1.6; color: #4B5563; margin: 0;">%s</p>
</td>
</tr>
</table>`, bgColor, accentColor, emailFontStack, titleColor, title, emailFontStack, body)
}
// emailFeatureItem renders a single feature with an icon badge (matches landing page cards)
func emailFeatureItem(emoji, title, description, badgeBg, badgeColor string) string {
return fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin-bottom: 16px;">
<tr>
<td width="44" style="vertical-align: top; padding-right: 14px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="feature-icon" style="width: 44px; height: 44px; background-color: %s; border-radius: 10px; text-align: center; vertical-align: middle; font-size: 20px; line-height: 44px;">%s</td>
</tr>
</table>
</td>
<td style="vertical-align: top;">
<p style="font-family: %s; font-size: 14px; font-weight: 700; color: %s; margin: 0 0 4px 0;">%s</p>
<p style="font-family: %s; font-size: 13px; line-height: 1.5; color: #8A8F87; margin: 0;">%s</p>
</td>
</tr>
</table>`, badgeBg, emoji, emailFontStack, badgeColor, title, emailFontStack, description)
}
// emailTipCard renders a numbered tip card with brand-colored accent (for post-verification)
func emailTipCard(number, title, description, accentColor, bgColor, titleColor string) string {
return fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin-bottom: 12px;">
<tr>
<td style="background-color: %s; border-radius: 12px; padding: 18px 22px; border: 1px solid %s;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td width="32" style="vertical-align: top; padding-right: 14px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="width: 28px; height: 28px; background-color: %s; border-radius: 8px; text-align: center; vertical-align: middle;">
<p style="font-family: %s; font-size: 13px; font-weight: 800; color: #FFFFFF; margin: 0; line-height: 28px;">%s</p>
</td>
</tr>
</table>
</td>
<td style="vertical-align: top;">
<p style="font-family: %s; font-size: 15px; font-weight: 700; color: %s; margin: 0 0 4px 0;">%s</p>
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #4B5563; margin: 0;">%s</p>
</td>
</tr>
</table>
</td>
</tr>
</table>`, bgColor, bgColor, accentColor, emailFontStack, number, emailFontStack, titleColor, title, emailFontStack, description)
}
// ──────────────────────────────────────────────────────────────────────────────
// Email template methods
// ──────────────────────────────────────────────────────────────────────────────
// SendWelcomeEmail sends a welcome email with verification code
func (s *EmailService) SendWelcomeEmail(to, firstName, code string) error {
subject := "Welcome to honeyDue - Verify Your Email"
name := firstName
if name == "" {
name = "there"
}
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 4px 0;">Hi %s,</p>
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 0 0;">Welcome! Verify your email to get started:</p>
%s
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #8A8F87; margin: 16px 0 0 0;">If you didn't create a honeyDue account, you can safely ignore this email.</p>
</td>
</tr>
</table>
%s`,
emailHeader("Verify your email"),
emailFontStack, name,
emailFontStack,
emailCodeBox(code, "Expires in 24 hours"),
emailFontStack,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Welcome to honeyDue!
Hi %s,
Welcome! Verify your email to get started.
Your verification code: %s
This code expires in 24 hours.
If you didn't create a honeyDue account, you can safely ignore this email.
- The honeyDue Team
`, name, code)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendAppleWelcomeEmail sends a welcome email for Apple Sign In users (no verification needed)
func (s *EmailService) SendAppleWelcomeEmail(to, firstName string) error {
subject := "Welcome to honeyDue!"
name := firstName
if name == "" {
name = "there"
}
features := emailFeatureItem("&#127968;", "Manage Properties", "Track all your homes and rentals in one place", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#9989;", "Task Management", "Never miss maintenance with smart scheduling", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#128119;", "Contractor Directory", "Keep your trusted pros organized", "#FDF3EE", "#2D3436") +
emailFeatureItem("&#128196;", "Document Storage", "Store warranties, manuals, and important records", "#FEF3C7", "#2D3436")
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 24px 0;">Hi %s, your account is ready! Here's what you can do:</p>
%s
%s
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #8A8F87; margin: 24px 0 0 0;">Questions? Reach out anytime at <a href="mailto:honeydue@treymail.com" style="color: #6B8F71; text-decoration: none; font-weight: 600;">honeydue@treymail.com</a></p>
</td>
</tr>
</table>
%s`,
emailHeader("Welcome to honeyDue!"),
emailFontStack, name,
emailCalloutBox(features),
emailButton("Open honeyDue", "honeydue://home", "#C4856A"),
emailFontStack,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Welcome to honeyDue!
Hi %s,
Your account is ready! Here's what you can do:
- Manage Properties: Track all your homes and rentals in one place
- Task Management: Never miss maintenance with smart scheduling
- Contractor Directory: Keep your trusted pros organized
- Document Storage: Store warranties, manuals, and important records
Questions? Reach out anytime at honeydue@treymail.com
- The honeyDue Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendGoogleWelcomeEmail sends a welcome email for Google Sign In users (no verification needed)
func (s *EmailService) SendGoogleWelcomeEmail(to, firstName string) error {
subject := "Welcome to honeyDue!"
name := firstName
if name == "" {
name = "there"
}
features := emailFeatureItem("&#127968;", "Manage Properties", "Track all your homes and rentals in one place", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#9989;", "Task Management", "Never miss maintenance with smart scheduling", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#128119;", "Contractor Directory", "Keep your trusted pros organized", "#FDF3EE", "#2D3436") +
emailFeatureItem("&#128196;", "Document Storage", "Store warranties, manuals, and important records", "#FEF3C7", "#2D3436")
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 24px 0;">Hi %s, your account is ready! Here's what you can do:</p>
%s
%s
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #8A8F87; margin: 24px 0 0 0;">Questions? Reach out anytime at <a href="mailto:honeydue@treymail.com" style="color: #6B8F71; text-decoration: none; font-weight: 600;">honeydue@treymail.com</a></p>
</td>
</tr>
</table>
%s`,
emailHeader("Welcome to honeyDue!"),
emailFontStack, name,
emailCalloutBox(features),
emailButton("Open honeyDue", "honeydue://home", "#C4856A"),
emailFontStack,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Welcome to honeyDue!
Hi %s,
Your account is ready! Here's what you can do:
- Manage Properties: Track all your homes and rentals in one place
- Task Management: Never miss maintenance with smart scheduling
- Contractor Directory: Keep your trusted pros organized
- Document Storage: Store warranties, manuals, and important records
Questions? Reach out anytime at honeydue@treymail.com
- The honeyDue Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendPostVerificationEmail sends a welcome email after user verifies their email address
func (s *EmailService) SendPostVerificationEmail(to, firstName string) error {
subject := "You're All Set! Getting Started with honeyDue"
name := firstName
if name == "" {
name = "there"
}
tips := emailTipCard("1", "Add Your Property", "Start by adding your home. You can manage multiple properties and share access with family.", "#6B8F71", "#EDF2ED", "#2D3436") +
emailTipCard("2", "Set Up Recurring Tasks", "Create tasks for HVAC filter changes, gutter cleaning, lawn care. We'll remind you when they're due.", "#C4856A", "#FDF3EE", "#2D3436") +
emailTipCard("3", "Track Maintenance History", "Complete tasks with notes and photos. Invaluable for warranty claims or when selling your home.", "#D97706", "#FEF3C7", "#2D3436") +
emailTipCard("4", "Store Important Documents", "Upload warranties, manuals, insurance policies. Find them instantly instead of digging through drawers.", "#6B8F71", "#F2EFE9", "#2D3436") +
emailTipCard("5", "Save Your Contractors", "Keep your trusted plumber, electrician, and other pros organized and one tap away.", "#C4856A", "#FDF3EE", "#2D3436")
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 24px 0;">Hi %s, your email is verified and you're ready to go! Here's how to get the most out of honeyDue:</p>
%s
%s
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #8A8F87; margin: 24px 0 0 0;">Questions or feedback? We'd love to hear from you at <a href="mailto:honeydue@treymail.com" style="color: #6B8F71; text-decoration: none; font-weight: 600;">honeydue@treymail.com</a></p>
</td>
</tr>
</table>
%s`,
emailHeader("You're all set!"),
emailFontStack, name,
tips,
emailButton("Get Started", "honeydue://home", "#C4856A"),
emailFontStack,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`You're All Set!
Hi %s,
Your email is verified and you're ready to go! Here's how to get the most out of honeyDue:
1. ADD YOUR PROPERTY
Start by adding your home. You can manage multiple properties and share access with family.
2. SET UP RECURRING TASKS
Create tasks for HVAC filter changes, gutter cleaning, lawn care. We'll remind you when they're due.
3. TRACK MAINTENANCE HISTORY
Complete tasks with notes and photos. Invaluable for warranty claims or when selling your home.
4. STORE IMPORTANT DOCUMENTS
Upload warranties, manuals, insurance policies. Find them instantly instead of digging through drawers.
5. SAVE YOUR CONTRACTORS
Keep your trusted plumber, electrician, and other pros organized and one tap away.
Questions? honeydue@treymail.com
- The honeyDue Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendVerificationEmail sends an email verification code
func (s *EmailService) SendVerificationEmail(to, firstName, code string) error {
subject := "honeyDue - Verify Your Email"
name := firstName
if name == "" {
name = "there"
}
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 4px 0;">Hi %s,</p>
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0;">Here's your verification code:</p>
%s
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #8A8F87; margin: 16px 0 0 0;">If you didn't request this, you can safely ignore this email.</p>
</td>
</tr>
</table>
%s`,
emailHeader("Verify your email"),
emailFontStack, name,
emailFontStack,
emailCodeBox(code, "Expires in 24 hours"),
emailFontStack,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Verify Your Email
Hi %s,
Here's your verification code: %s
This code expires in 24 hours.
If you didn't request this, you can safely ignore this email.
- The honeyDue Team
`, name, code)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendPasswordResetEmail sends a password reset email
func (s *EmailService) SendPasswordResetEmail(to, firstName, code string) error {
subject := "honeyDue - Password Reset Request"
name := firstName
if name == "" {
name = "there"
}
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 4px 0;">Hi %s,</p>
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0;">Use this code to reset your password:</p>
%s
%s
</td>
</tr>
</table>
%s`,
emailHeader("Reset your password"),
emailFontStack, name,
emailFontStack,
emailCodeBox(code, "Expires in 15 minutes"),
emailAlertBox("Didn't request this?", "If you didn't request a password reset, please ignore this email. Your password will remain unchanged.", "#C4856A", "#FDF3EE", "#A06B52"),
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Password Reset Request
Hi %s,
Use this code to reset your password: %s
This code expires in 15 minutes.
If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
- The honeyDue Team
`, name, code)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendPasswordChangedEmail sends a password changed confirmation email
func (s *EmailService) SendPasswordChangedEmail(to, firstName string) error {
subject := "honeyDue - Your Password Has Been Changed"
name := firstName
if name == "" {
name = "there"
}
changeTime := time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC")
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 4px 0;">Hi %s,</p>
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 0 0;">Your password was successfully changed on <strong style="color: #2D3436;">%s</strong>.</p>
%s
</td>
</tr>
</table>
%s`,
emailHeader("Password changed"),
emailFontStack, name,
emailFontStack, changeTime,
emailAlertBox("Wasn't you?", "If you didn't make this change, contact us immediately at honeydue@treymail.com or reset your password right away.", "#C4856A", "#FDF3EE", "#A06B52"),
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Password Changed
Hi %s,
Your password was successfully changed on %s.
If you didn't make this change, contact us immediately at honeydue@treymail.com or reset your password right away.
- The honeyDue Team
`, name, changeTime)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendTaskCompletedEmail sends an email notification when a task is completed
// images parameter is optional - pass nil or empty slice if no images
func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, completedByName, residenceName string, images []EmbeddedImage) error {
subject := fmt.Sprintf("honeyDue - Task Completed: %s", taskTitle)
name := recipientName
if name == "" {
name = "there"
}
completedTime := time.Now().UTC().Format("January 2, 2006 at 3:04 PM")
// Build images HTML section if images are provided
imagesHTML := ""
imagesText := ""
if len(images) > 0 {
photoLabel := "Photo"
if len(images) > 1 {
photoLabel = "Photos"
}
imagesHTML = fmt.Sprintf(`
<p style="font-family: %s; font-size: 14px; font-weight: 600; color: #2D3436; margin: 24px 0 12px 0;">Completion %s</p>`, emailFontStack, photoLabel)
for i, img := range images {
imagesHTML += fmt.Sprintf(`
<img src="cid:%s" alt="Completion photo %d" style="max-width: 100%%; height: auto; border-radius: 12px; border: 1px solid #E8E3DC; margin-bottom: 8px;" />`, img.ContentID, i+1)
}
imagesText = fmt.Sprintf("\n\n[%d completion photo(s) attached]", len(images))
}
// Task detail card
taskCard := fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td style="background-color: #EDF2ED; border-radius: 12px; padding: 24px 28px; border: 1px solid #EDF2ED;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td width="32" style="vertical-align: top; padding-right: 14px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="width: 28px; height: 28px; background-color: #6B8F71; border-radius: 50%%; text-align: center; vertical-align: middle;">
<p style="font-family: %s; font-size: 14px; color: #FFFFFF; margin: 0; line-height: 28px;">&#10003;</p>
</td>
</tr>
</table>
</td>
<td style="vertical-align: top;">
<p style="font-family: %s; font-size: 17px; font-weight: 700; color: #2D3436; margin: 0 0 8px 0;">%s</p>
<p style="font-family: %s; font-size: 13px; color: #8A8F87; margin: 0; line-height: 1.6;">Completed by %s<br>%s</p>
</td>
</tr>
</table>
%s
</td>
</tr>
</table>`,
emailFontStack,
emailFontStack, taskTitle,
emailFontStack, completedByName, completedTime,
imagesHTML)
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 0 0;">Hi %s, a task at <strong style="color: #2D3436;">%s</strong> has been completed:</p>
%s
</td>
</tr>
</table>
%s`,
emailHeader("Task completed!"),
emailFontStack, name, residenceName,
taskCard,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Task Completed!
Hi %s,
A task at %s has been completed:
Task: %s
Completed by: %s
Completed on: %s%s
- The honeyDue Team
`, name, residenceName, taskTitle, completedByName, completedTime, imagesText)
// Use embedded images method if we have images, otherwise use simple send
if len(images) > 0 {
return s.SendEmailWithEmbeddedImages(to, subject, htmlBody, textBody, images)
}
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendTasksReportEmail sends a tasks report email with PDF attachment
func (s *EmailService) SendTasksReportEmail(to, recipientName, residenceName string, totalTasks, completed, pending, overdue int, pdfData []byte) error {
subject := fmt.Sprintf("honeyDue - Tasks Report for %s", residenceName)
name := recipientName
if name == "" {
name = "there"
}
// Build stat cells
statRow := fmt.Sprintf(`
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="stat-cell" width="25%%" style="text-align: center; padding: 16px 8px;">
<p style="font-family: %s; font-size: 32px; font-weight: 800; color: #2D3436; margin: 0; line-height: 1;">%d</p>
<p style="font-family: %s; font-size: 11px; font-weight: 600; color: #8A8F87; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 0 0;">Total</p>
</td>
<td class="stat-cell" width="25%%" style="text-align: center; padding: 16px 8px; border-left: 1px solid #E8E3DC;">
<p style="font-family: %s; font-size: 32px; font-weight: 800; color: #6B8F71; margin: 0; line-height: 1;">%d</p>
<p style="font-family: %s; font-size: 11px; font-weight: 600; color: #8A8F87; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 0 0;">Done</p>
</td>
<td class="stat-cell" width="25%%" style="text-align: center; padding: 16px 8px; border-left: 1px solid #E8E3DC;">
<p style="font-family: %s; font-size: 32px; font-weight: 800; color: #D97706; margin: 0; line-height: 1;">%d</p>
<p style="font-family: %s; font-size: 11px; font-weight: 600; color: #8A8F87; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 0 0;">Pending</p>
</td>
<td class="stat-cell" width="25%%" style="text-align: center; padding: 16px 8px; border-left: 1px solid #E8E3DC;">
<p style="font-family: %s; font-size: 32px; font-weight: 800; color: #EF4444; margin: 0; line-height: 1;">%d</p>
<p style="font-family: %s; font-size: 11px; font-weight: 600; color: #8A8F87; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 0 0;">Overdue</p>
</td>
</tr>
</table>`,
emailFontStack, totalTasks,
emailFontStack,
emailFontStack, completed,
emailFontStack,
emailFontStack, pending,
emailFontStack,
emailFontStack, overdue,
emailFontStack)
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 24px 0;">Hi %s, here's your report for <strong style="color: #2D3436;">%s</strong>:</p>
%s
%s
<p style="font-family: %s; font-size: 14px; line-height: 1.6; color: #8A8F87; margin: 24px 0 0 0;">The full report is attached as a PDF.</p>
</td>
</tr>
</table>
%s`,
emailHeader("Tasks report"),
emailFontStack, name, residenceName,
emailCalloutBox(statRow),
emailButton("Open honeyDue", "honeydue://tasks", "#6B8F71"),
emailFontStack,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Tasks Report for %s
Hi %s,
Here's your report for %s:
Total: %d | Completed: %d | Pending: %d | Overdue: %d
The full report is attached as a PDF.
- The honeyDue Team
`, residenceName, name, residenceName, totalTasks, completed, pending, overdue)
// Create filename with timestamp
filename := fmt.Sprintf("tasks_report_%s_%s.pdf",
residenceName,
time.Now().Format("2006-01-02"),
)
attachment := &EmailAttachment{
Filename: filename,
ContentType: "application/pdf",
Data: pdfData,
}
return s.SendEmailWithAttachment(to, subject, htmlBody, textBody, attachment)
}
// SendNoResidenceOnboardingEmail sends an onboarding email to users who haven't created a residence
func (s *EmailService) SendNoResidenceOnboardingEmail(to, firstName, baseURL, trackingID string) error {
subject := "Get started with honeyDue - Add your first property"
name := firstName
if name == "" {
name = "there"
}
trackingPixel := fmt.Sprintf(`<img src="%s/api/track/open/%s" width="1" height="1" style="display:none;" alt="" />`, baseURL, trackingID)
features := emailFeatureItem("&#127968;", "Track All Your Homes", "Manage single-family homes, apartments, or investment properties", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#128197;", "Never Miss Maintenance", "Set up recurring tasks with smart reminders", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#128196;", "Store Documents", "Keep warranties, manuals, and records in one place", "#FEF3C7", "#2D3436") +
emailFeatureItem("&#128119;", "Manage Contractors", "Keep your trusted pros organized and accessible", "#FDF3EE", "#2D3436")
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 24px 0;">Hi %s, you haven't added your first property yet. It only takes a minute and unlocks everything honeyDue has to offer:</p>
%s
%s
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #8A8F87; margin: 24px 0 0 0;">Open the app and tap + to get started.</p>
%s
</td>
</tr>
</table>
%s`,
emailHeader("Add your first property"),
emailFontStack, name,
emailCalloutBox(features),
emailButton("Add Your First Property", "honeydue://add-property", "#C4856A"),
emailFontStack,
trackingPixel,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Add Your First Property
Hi %s,
You haven't added your first property yet. It only takes a minute!
- Track all your homes
- Never miss maintenance
- Store important documents
- Manage contractors
Open the honeyDue app and tap + to get started.
- The honeyDue Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendNoTasksOnboardingEmail sends an onboarding email to users who have a property but no tasks
func (s *EmailService) SendNoTasksOnboardingEmail(to, firstName, baseURL, trackingID string) error {
subject := "Stay on top of home maintenance with honeyDue"
name := firstName
if name == "" {
name = "there"
}
trackingPixel := fmt.Sprintf(`<img src="%s/api/track/open/%s" width="1" height="1" style="display:none;" alt="" />`, baseURL, trackingID)
features := emailFeatureItem("&#127777;&#65039;", "HVAC Filter Replacement", "Monthly or quarterly", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#128167;", "Water Heater Flush", "Annually", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#127807;", "Lawn Care", "Weekly or bi-weekly", "#EDF2ED", "#2D3436") +
emailFeatureItem("&#128374;", "Gutter Cleaning", "Seasonal", "#FEF3C7", "#2D3436") +
emailFeatureItem("&#128293;", "Smoke Detector Test", "Monthly", "#FDF3EE", "#2D3436")
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="email-body" style="padding: 0 48px 40px 48px;">
<p style="font-family: %s; font-size: 16px; line-height: 1.7; color: #4B5563; margin: 0 0 24px 0;">Hi %s, great job adding your property! Now set up your first task. Here are some ideas:</p>
%s
%s
<p style="font-family: %s; font-size: 13px; line-height: 1.6; color: #8A8F87; margin: 24px 0 0 0;">Set up recurring tasks and we'll remind you when they're due.</p>
%s
</td>
</tr>
</table>
%s`,
emailHeader("Create your first task"),
emailFontStack, name,
emailCalloutBox(features),
emailButton("Create Your First Task", "honeydue://add-task", "#C4856A"),
emailFontStack,
trackingPixel,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`Create Your First Task
Hi %s,
Great job adding your property! Now set up your first task. Here are some ideas:
- HVAC Filter Replacement: Monthly or quarterly
- Water Heater Flush: Annually
- Lawn Care: Weekly or bi-weekly
- Gutter Cleaning: Seasonal
- Smoke Detector Test: Monthly
Set up recurring tasks and we'll remind you when they're due.
- The honeyDue Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// EmailTemplate represents an email template
type EmailTemplate struct {
name string
template *template.Template
}
// ParseTemplate parses an email template from a string
func ParseTemplate(name, tmpl string) (*EmailTemplate, error) {
t, err := template.New(name).Parse(tmpl)
if err != nil {
return nil, err
}
return &EmailTemplate{name: name, template: t}, nil
}
// Execute executes the template with the given data
func (t *EmailTemplate) Execute(data interface{}) (string, error) {
var buf bytes.Buffer
if err := t.template.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}