Files
honeyDueAPI/internal/services/email_service.go
Trey t 469f21a833 Add PDF reports, file uploads, admin auth, and comprehensive tests
Features:
- PDF service for generating task reports with ReportLab-style formatting
- Storage service for file uploads (local and S3-compatible)
- Admin authentication middleware with JWT support
- Admin user model and repository

Infrastructure:
- Updated Docker configuration for admin panel builds
- Email service enhancements for task notifications
- Updated router with admin and file upload routes
- Environment configuration updates

Tests:
- Unit tests for handlers (auth, residence, task)
- Unit tests for models (user, residence, task)
- Unit tests for repositories (user, residence, task)
- Unit tests for services (residence, task)
- Integration test setup
- Test utilities for mocking database and services

Database:
- Admin user seed data
- Updated test data seeds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:36:20 -06:00

510 lines
16 KiB
Go

package services
import (
"bytes"
"fmt"
"html/template"
"io"
"time"
"github.com/rs/zerolog/log"
"gopkg.in/gomail.v2"
"github.com/treytartt/mycrib-api/internal/config"
)
// EmailService handles sending emails
type EmailService struct {
cfg *config.EmailConfig
dialer *gomail.Dialer
}
// NewEmailService creates a new email service
func NewEmailService(cfg *config.EmailConfig) *EmailService {
dialer := gomail.NewDialer(cfg.Host, cfg.Port, cfg.User, cfg.Password)
return &EmailService{
cfg: cfg,
dialer: dialer,
}
}
// SendEmail sends an email
func (s *EmailService) SendEmail(to, subject, htmlBody, textBody string) error {
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
}
// SendEmailWithAttachment sends an email with an attachment
func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *EmailAttachment) error {
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
}
// SendWelcomeEmail sends a welcome email with verification code
func (s *EmailService) SendWelcomeEmail(to, firstName, code string) error {
subject := "Welcome to MyCrib - Verify Your Email"
name := firstName
if name == "" {
name = "there"
}
htmlBody := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to MyCrib!</h1>
</div>
<p>Hi %s,</p>
<p>Thank you for creating a MyCrib account. To complete your registration, please verify your email address by entering the following code:</p>
<div class="code">%s</div>
<p>This code will expire in 24 hours.</p>
<p>If you didn't create a MyCrib account, you can safely ignore this email.</p>
<p>Best regards,<br>The MyCrib Team</p>
<div class="footer">
<p>&copy; %d MyCrib. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, code, time.Now().Year())
textBody := fmt.Sprintf(`
Welcome to MyCrib!
Hi %s,
Thank you for creating a MyCrib account. To complete your registration, please verify your email address by entering the following code:
%s
This code will expire in 24 hours.
If you didn't create a MyCrib account, you can safely ignore this email.
Best regards,
The MyCrib Team
`, name, code)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendVerificationEmail sends an email verification code
func (s *EmailService) SendVerificationEmail(to, firstName, code string) error {
subject := "MyCrib - Verify Your Email"
name := firstName
if name == "" {
name = "there"
}
htmlBody := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<h1>Verify Your Email</h1>
<p>Hi %s,</p>
<p>Please use the following code to verify your email address:</p>
<div class="code">%s</div>
<p>This code will expire in 24 hours.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
<p>Best regards,<br>The MyCrib Team</p>
<div class="footer">
<p>&copy; %d MyCrib. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, code, time.Now().Year())
textBody := fmt.Sprintf(`
Verify Your Email
Hi %s,
Please use the following code to verify your email address:
%s
This code will expire in 24 hours.
If you didn't request this, you can safely ignore this email.
Best regards,
The MyCrib 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 := "MyCrib - Password Reset Request"
name := firstName
if name == "" {
name = "there"
}
htmlBody := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<h1>Password Reset Request</h1>
<p>Hi %s,</p>
<p>We received a request to reset your password. Use the following code to complete the reset:</p>
<div class="code">%s</div>
<p>This code will expire in 15 minutes.</p>
<div class="warning">
<strong>Security Notice:</strong> If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
</div>
<p>Best regards,<br>The MyCrib Team</p>
<div class="footer">
<p>&copy; %d MyCrib. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, code, time.Now().Year())
textBody := fmt.Sprintf(`
Password Reset Request
Hi %s,
We received a request to reset your password. Use the following code to complete the reset:
%s
This code will expire in 15 minutes.
SECURITY NOTICE: If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
Best regards,
The MyCrib 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 := "MyCrib - Your Password Has Been Changed"
name := firstName
if name == "" {
name = "there"
}
htmlBody := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<h1>Password Changed</h1>
<p>Hi %s,</p>
<p>Your MyCrib password was successfully changed on %s.</p>
<div class="warning">
<strong>Didn't make this change?</strong> If you didn't change your password, please contact us immediately at support@mycrib.com or reset your password.
</div>
<p>Best regards,<br>The MyCrib Team</p>
<div class="footer">
<p>&copy; %d MyCrib. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC"), time.Now().Year())
textBody := fmt.Sprintf(`
Password Changed
Hi %s,
Your MyCrib password was successfully changed on %s.
DIDN'T MAKE THIS CHANGE? If you didn't change your password, please contact us immediately at support@mycrib.com or reset your password.
Best regards,
The MyCrib Team
`, name, time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC"))
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendTaskCompletedEmail sends an email notification when a task is completed
func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, completedByName, residenceName string) error {
subject := fmt.Sprintf("MyCrib - Task Completed: %s", taskTitle)
name := recipientName
if name == "" {
name = "there"
}
htmlBody := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.task-box { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 8px; margin: 20px 0; }
.task-title { font-size: 18px; font-weight: bold; color: #155724; margin: 0; }
.task-meta { color: #666; font-size: 14px; margin-top: 10px; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Task Completed!</h1>
</div>
<p>Hi %s,</p>
<p>A task has been completed at <strong>%s</strong>:</p>
<div class="task-box">
<p class="task-title">%s</p>
<p class="task-meta">Completed by: %s<br>Completed on: %s</p>
</div>
<p>Best regards,<br>The MyCrib Team</p>
<div class="footer">
<p>&copy; %d MyCrib. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, residenceName, taskTitle, completedByName, time.Now().UTC().Format("January 2, 2006 at 3:04 PM"), time.Now().Year())
textBody := fmt.Sprintf(`
Task Completed!
Hi %s,
A task has been completed at %s:
Task: %s
Completed by: %s
Completed on: %s
Best regards,
The MyCrib Team
`, name, residenceName, taskTitle, completedByName, time.Now().UTC().Format("January 2, 2006 at 3:04 PM"))
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("MyCrib - Tasks Report for %s", residenceName)
name := recipientName
if name == "" {
name = "there"
}
htmlBody := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.summary-box { background: #f8f9fa; border: 1px solid #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0; }
.summary-grid { display: flex; flex-wrap: wrap; gap: 20px; }
.summary-item { flex: 1; min-width: 80px; text-align: center; }
.summary-number { font-size: 28px; font-weight: bold; color: #333; }
.summary-label { font-size: 12px; color: #666; text-transform: uppercase; }
.completed { color: #28a745; }
.pending { color: #ffc107; }
.overdue { color: #dc3545; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Tasks Report</h1>
<p style="color: #666; margin: 0;">%s</p>
</div>
<p>Hi %s,</p>
<p>Here's your tasks report for <strong>%s</strong>. The full report is attached as a PDF.</p>
<div class="summary-box">
<h3 style="margin-top: 0;">Summary</h3>
<table width="100%%" cellpadding="10" cellspacing="0">
<tr>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number">%d</div>
<div class="summary-label">Total Tasks</div>
</td>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number completed">%d</div>
<div class="summary-label">Completed</div>
</td>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number pending">%d</div>
<div class="summary-label">Pending</div>
</td>
<td align="center">
<div class="summary-number overdue">%d</div>
<div class="summary-label">Overdue</div>
</td>
</tr>
</table>
</div>
<p>Open the attached PDF for the complete list of tasks with details.</p>
<p>Best regards,<br>The MyCrib Team</p>
<div class="footer">
<p>&copy; %d MyCrib. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, residenceName, name, residenceName, totalTasks, completed, pending, overdue, time.Now().Year())
textBody := fmt.Sprintf(`
Tasks Report for %s
Hi %s,
Here's your tasks report for %s. The full report is attached as a PDF.
Summary:
- Total Tasks: %d
- Completed: %d
- Pending: %d
- Overdue: %d
Open the attached PDF for the complete list of tasks with details.
Best regards,
The MyCrib 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)
}
// 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
}