From 7a57a902bb15777114b0a997d269af137ddffcb5 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 17 Dec 2025 15:27:45 -0600 Subject: [PATCH] Include completion photos in task completed emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EmbeddedImage struct and SendEmailWithEmbeddedImages method for inline images - Update SendTaskCompletedEmail to accept and display completion photos - Read images from disk via StorageService and embed with Content-ID references - Wire StorageService to TaskService for image access - Photos display inline in HTML email body, works across all email clients 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/router/router.go | 3 +- internal/services/email_service.go | 87 ++++++++++++++++++++++++++- internal/services/task_service.go | 94 +++++++++++++++++++++++++++++- 3 files changed, 177 insertions(+), 7 deletions(-) diff --git a/internal/router/router.go b/internal/router/router.go index 7b05fa1..567bb0d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -118,10 +118,11 @@ func SetupRouter(deps *Dependencies) *echo.Echo { documentService := services.NewDocumentService(documentRepo, residenceRepo) notificationService := services.NewNotificationService(notificationRepo, deps.PushClient) - // Wire up notification, email, and residence services to task service + // Wire up notification, email, residence, and storage services to task service taskService.SetNotificationService(notificationService) taskService.SetEmailService(deps.EmailService) taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses + taskService.SetStorageService(deps.StorageService) // For reading completion images for email subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo) diff --git a/internal/services/email_service.go b/internal/services/email_service.go index cb4b312..2837b64 100644 --- a/internal/services/email_service.go +++ b/internal/services/email_service.go @@ -54,6 +54,14 @@ type EmailAttachment struct { 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 { m := gomail.NewMessage() @@ -84,6 +92,39 @@ func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody s return nil } +// SendEmailWithEmbeddedImages sends an email with inline embedded images +func (s *EmailService) SendEmailWithEmbeddedImages(to, subject, htmlBody, textBody string, images []EmbeddedImage) 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) + + // 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 +} + // baseEmailTemplate returns the styled email wrapper func baseEmailTemplate() string { return ` @@ -631,7 +672,8 @@ The Casera Team } // SendTaskCompletedEmail sends an email notification when a task is completed -func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, completedByName, residenceName string) error { +// 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 @@ -641,6 +683,39 @@ func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, comp 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 { + imagesHTML = ` + + + + + ` + + for i, img := range images { + imagesHTML += fmt.Sprintf(` + + + `, img.ContentID, i+1) + } + + imagesHTML += ` +
+

Completion Photo` + func() string { + if len(images) > 1 { + return "s" + } + return "" + }() + `:

+
+ Completion photo %d +
` + + imagesText = fmt.Sprintf("\n\n[%d completion photo(s) attached]", len(images)) + } + bodyContent := fmt.Sprintf(` %s @@ -658,6 +733,7 @@ func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, comp + %s

Best regards,
The Casera Team

@@ -665,6 +741,7 @@ func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, comp %s`, emailHeader("Task Completed!"), name, residenceName, taskTitle, completedByName, completedTime, + imagesHTML, emailFooter(time.Now().Year())) htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) @@ -678,12 +755,16 @@ A task has been completed at %s: Task: %s Completed by: %s -Completed on: %s +Completed on: %s%s Best regards, The Casera Team -`, name, residenceName, taskTitle, completedByName, completedTime) +`, 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) } diff --git a/internal/services/task_service.go b/internal/services/task_service.go index cc79151..5f7aa13 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -3,6 +3,10 @@ package services import ( "context" "errors" + "fmt" + "os" + "path/filepath" + "strings" "time" "github.com/rs/zerolog/log" @@ -32,6 +36,7 @@ type TaskService struct { residenceService *ResidenceService notificationService *NotificationService emailService *EmailService + storageService *StorageService } // NewTaskService creates a new task service @@ -57,6 +62,11 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) { s.residenceService = rs } +// SetStorageService sets the storage service (for reading completion images for emails) +func (s *TaskService) SetStorageService(ss *StorageService) { + s.storageService = ss +} + // getSummaryForUser returns an empty summary placeholder. // DEPRECATED: Summary calculation has been removed from CRUD responses for performance. // Clients should calculate summary from kanban data instead (which already includes all tasks). @@ -721,6 +731,12 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio completedByName = completion.CompletedBy.GetFullName() } + // Load completion images for email (only if storage service is available) + var emailImages []EmbeddedImage + if s.storageService != nil && len(completion.Images) > 0 { + emailImages = s.loadCompletionImagesForEmail(completion.Images) + } + // Notify all users for _, user := range users { isCompleter := user.ID == completion.CompletedByID @@ -746,24 +762,96 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio prefs, err := s.notificationService.GetPreferences(user.ID) if err != nil || (prefs != nil && prefs.EmailTaskCompleted) { // Send email if we couldn't get prefs (fail-open) or if email notifications are enabled - go func(u models.User) { + go func(u models.User, images []EmbeddedImage) { if err := s.emailService.SendTaskCompletedEmail( u.Email, u.GetFullName(), task.Title, completedByName, residenceName, + images, ); err != nil { log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email") } else { - log.Info().Str("email", u.Email).Uint("task_id", task.ID).Msg("Task completion email sent") + log.Info().Str("email", u.Email).Uint("task_id", task.ID).Int("images", len(images)).Msg("Task completion email sent") } - }(user) + }(user, emailImages) } } } } +// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding +func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage { + var emailImages []EmbeddedImage + + uploadDir := s.storageService.GetUploadDir() + + for i, img := range images { + // Resolve file path from stored URL + filePath := s.resolveImageFilePath(img.ImageURL, uploadDir) + if filePath == "" { + log.Warn().Str("image_url", img.ImageURL).Msg("Could not resolve image file path") + continue + } + + // Read file from disk + data, err := os.ReadFile(filePath) + if err != nil { + log.Warn().Err(err).Str("path", filePath).Msg("Failed to read completion image for email") + continue + } + + // Determine content type from extension + contentType := s.getContentTypeFromPath(filePath) + + // Create embedded image with unique Content-ID + emailImages = append(emailImages, EmbeddedImage{ + ContentID: fmt.Sprintf("completion-image-%d", i+1), + Filename: filepath.Base(filePath), + ContentType: contentType, + Data: data, + }) + + log.Debug().Str("path", filePath).Int("size", len(data)).Msg("Loaded completion image for email") + } + + return emailImages +} + +// resolveImageFilePath converts a stored URL to an actual file path +func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string { + if storedURL == "" { + return "" + } + + // Handle /uploads/... URLs + if strings.HasPrefix(storedURL, "/uploads/") { + relativePath := strings.TrimPrefix(storedURL, "/uploads/") + return filepath.Join(uploadDir, relativePath) + } + + // Handle relative paths + return filepath.Join(uploadDir, storedURL) +} + +// getContentTypeFromPath returns the MIME type based on file extension +func (s *TaskService) getContentTypeFromPath(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + default: + return "application/octet-stream" + } +} + // GetCompletion gets a task completion by ID func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) { completion, err := s.taskRepo.FindCompletionByID(completionID)