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 = `
+
+
+
+ |
+ Completion Photo` + func() string {
+ if len(images) > 1 {
+ return "s"
+ }
+ return ""
+ }() + `:
+ |
+
`
+
+ for i, img := range images {
+ imagesHTML += fmt.Sprintf(`
+
+
+
+ |
+
`, img.ContentID, i+1)
+ }
+
+ imagesHTML += `
+
`
+
+ 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)