package services import ( "bytes" "fmt" "html/template" "io" "time" "github.com/rs/zerolog/log" "gopkg.in/gomail.v2" "github.com/treytartt/casera-api/internal/config" ) // EmailService handles sending emails type EmailService struct { cfg *config.EmailConfig dialer *gomail.Dialer enabled bool } // NewEmailService creates a new email service func NewEmailService(cfg *config.EmailConfig, enabled bool) *EmailService { dialer := gomail.NewDialer(cfg.Host, cfg.Port, cfg.User, cfg.Password) return &EmailService{ cfg: cfg, dialer: dialer, 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 := gomail.NewMessage() m.SetHeader("From", s.cfg.From) m.SetHeader("To", to) m.SetHeader("Subject", subject) m.SetBody("text/plain", textBody) m.AddAlternative("text/html", htmlBody) if err := s.dialer.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 := gomail.NewMessage() m.SetHeader("From", s.cfg.From) m.SetHeader("To", to) m.SetHeader("Subject", subject) m.SetBody("text/plain", textBody) m.AddAlternative("text/html", htmlBody) if attachment != nil { m.Attach(attachment.Filename, gomail.SetCopyFunc(func(w io.Writer) error { _, err := w.Write(attachment.Data) return err }), gomail.SetHeader(map[string][]string{ "Content-Type": {attachment.ContentType}, }), ) } if err := s.dialer.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 := gomail.NewMessage() m.SetHeader("From", s.cfg.From) m.SetHeader("To", to) m.SetHeader("Subject", subject) m.SetBody("text/plain", textBody) m.AddAlternative("text/html", htmlBody) // Embed each image with Content-ID for inline display for _, img := range images { m.Embed(img.Filename, gomail.SetCopyFunc(func(w io.Writer) error { _, err := w.Write(img.Data) return err }), gomail.SetHeader(map[string][]string{ "Content-Type": {img.ContentType}, "Content-ID": {"<" + img.ContentID + ">"}, "Content-Disposition": {"inline; filename=\"" + img.Filename + "\""}, }), ) } if err := s.dialer.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 } // ────────────────────────────────────────────────────────────────────────────── // Casera "Warm Sage" Email Design System // Matching the web landing page: casera.app // ────────────────────────────────────────────────────────────────────────────── // emailIconURL is the URL for the email icon const emailIconURL = "https://casera.app/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 Casera shell: // cream background → white card with warm shadow → sage brand stripe at top func baseEmailTemplate() string { return ` %s
` } // emailHeader renders the clean white header: logo + brand name + subtitle func emailHeader(subtitle string) string { return fmt.Sprintf(`
`, emailIconURL, emailFontStack, emailFontStack, subtitle) } // emailDivider renders a subtle stone-colored horizontal rule func emailDivider() string { return `
 
` } // emailFooter renders the footer with copyright func emailFooter(year int) string { return fmt.Sprintf(` %s

© %d Casera. All rights reserved.

support@casera.app

`, 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(`
%s
`, 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(`

%s

%s

`, code, emailFontStack, expiryText) } // emailCalloutBox renders a linen-background callout with optional accent border func emailCalloutBox(content string) string { return fmt.Sprintf(`
%s
`, content) } // emailAlertBox renders a warning/info box with colored left accent func emailAlertBox(title, body, accentColor, bgColor, titleColor string) string { return fmt.Sprintf(`

%s

%s

`, 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(`
%s

%s

%s

`, 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(`

%s

%s

%s

`, 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 Casera - Verify Your Email" name := firstName if name == "" { name = "there" } bodyContent := fmt.Sprintf(` %s
%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 Casera! 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 Casera account, you can safely ignore this email. - The Casera 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 Casera!" name := firstName if name == "" { name = "there" } features := emailFeatureItem("🏠", "Manage Properties", "Track all your homes and rentals in one place", "#EDF2ED", "#2D3436") + emailFeatureItem("✅", "Task Management", "Never miss maintenance with smart scheduling", "#EDF2ED", "#2D3436") + emailFeatureItem("👷", "Contractor Directory", "Keep your trusted pros organized", "#FDF3EE", "#2D3436") + emailFeatureItem("📄", "Document Storage", "Store warranties, manuals, and important records", "#FEF3C7", "#2D3436") bodyContent := fmt.Sprintf(` %s
%s`, emailHeader("Welcome to Casera!"), emailFontStack, name, emailCalloutBox(features), emailButton("Open Casera", "casera://home", "#C4856A"), emailFontStack, emailFooter(time.Now().Year())) htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(`Welcome to Casera! 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 support@casera.app - The Casera 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 Casera!" name := firstName if name == "" { name = "there" } features := emailFeatureItem("🏠", "Manage Properties", "Track all your homes and rentals in one place", "#EDF2ED", "#2D3436") + emailFeatureItem("✅", "Task Management", "Never miss maintenance with smart scheduling", "#EDF2ED", "#2D3436") + emailFeatureItem("👷", "Contractor Directory", "Keep your trusted pros organized", "#FDF3EE", "#2D3436") + emailFeatureItem("📄", "Document Storage", "Store warranties, manuals, and important records", "#FEF3C7", "#2D3436") bodyContent := fmt.Sprintf(` %s
%s`, emailHeader("Welcome to Casera!"), emailFontStack, name, emailCalloutBox(features), emailButton("Open Casera", "casera://home", "#C4856A"), emailFontStack, emailFooter(time.Now().Year())) htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(`Welcome to Casera! 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 support@casera.app - The Casera 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 Casera" 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
%s`, emailHeader("You're all set!"), emailFontStack, name, tips, emailButton("Get Started", "casera://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 Casera: 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? support@casera.app - The Casera 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 := "Casera - Verify Your Email" name := firstName if name == "" { name = "there" } bodyContent := fmt.Sprintf(` %s
%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 Casera 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 := "Casera - Password Reset Request" name := firstName if name == "" { name = "there" } bodyContent := fmt.Sprintf(` %s
%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 Casera 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 := "Casera - 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
%s`, emailHeader("Password changed"), emailFontStack, name, emailFontStack, changeTime, emailAlertBox("Wasn't you?", "If you didn't make this change, contact us immediately at support@casera.app 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 support@casera.app or reset your password right away. - The Casera 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("Casera - 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(`

Completion %s

`, emailFontStack, photoLabel) for i, img := range images { imagesHTML += fmt.Sprintf(` Completion photo %d`, img.ContentID, i+1) } imagesText = fmt.Sprintf("\n\n[%d completion photo(s) attached]", len(images)) } // Task detail card taskCard := fmt.Sprintf(`

%s

Completed by %s
%s

%s
`, emailFontStack, emailFontStack, taskTitle, emailFontStack, completedByName, completedTime, imagesHTML) bodyContent := fmt.Sprintf(` %s
%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 Casera 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("Casera - Tasks Report for %s", residenceName) name := recipientName if name == "" { name = "there" } // Build stat cells statRow := fmt.Sprintf(`

%d

Total

%d

Done

%d

Pending

%d

Overdue

`, emailFontStack, totalTasks, emailFontStack, emailFontStack, completed, emailFontStack, emailFontStack, pending, emailFontStack, emailFontStack, overdue, emailFontStack) bodyContent := fmt.Sprintf(` %s
%s`, emailHeader("Tasks report"), emailFontStack, name, residenceName, emailCalloutBox(statRow), emailButton("Open Casera", "casera://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 Casera 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 Casera - Add your first property" name := firstName if name == "" { name = "there" } trackingPixel := fmt.Sprintf(``, baseURL, trackingID) features := emailFeatureItem("🏠", "Track All Your Homes", "Manage single-family homes, apartments, or investment properties", "#EDF2ED", "#2D3436") + emailFeatureItem("📅", "Never Miss Maintenance", "Set up recurring tasks with smart reminders", "#EDF2ED", "#2D3436") + emailFeatureItem("📄", "Store Documents", "Keep warranties, manuals, and records in one place", "#FEF3C7", "#2D3436") + emailFeatureItem("👷", "Manage Contractors", "Keep your trusted pros organized and accessible", "#FDF3EE", "#2D3436") bodyContent := fmt.Sprintf(` %s
%s`, emailHeader("Add your first property"), emailFontStack, name, emailCalloutBox(features), emailButton("Add Your First Property", "casera://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 Casera app and tap + to get started. - The Casera 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 Casera" name := firstName if name == "" { name = "there" } trackingPixel := fmt.Sprintf(``, baseURL, trackingID) features := emailFeatureItem("🌡️", "HVAC Filter Replacement", "Monthly or quarterly", "#EDF2ED", "#2D3436") + emailFeatureItem("💧", "Water Heater Flush", "Annually", "#EDF2ED", "#2D3436") + emailFeatureItem("🌿", "Lawn Care", "Weekly or bi-weekly", "#EDF2ED", "#2D3436") + emailFeatureItem("🕶", "Gutter Cleaning", "Seasonal", "#FEF3C7", "#2D3436") + emailFeatureItem("🔥", "Smoke Detector Test", "Monthly", "#FDF3EE", "#2D3436") bodyContent := fmt.Sprintf(` %s
%s`, emailHeader("Create your first task"), emailFontStack, name, emailCalloutBox(features), emailButton("Create Your First Task", "casera://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 Casera 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 }