Include completion photos in task completed emails
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -118,10 +118,11 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
|||||||
documentService := services.NewDocumentService(documentRepo, residenceRepo)
|
documentService := services.NewDocumentService(documentRepo, residenceRepo)
|
||||||
notificationService := services.NewNotificationService(notificationRepo, deps.PushClient)
|
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.SetNotificationService(notificationService)
|
||||||
taskService.SetEmailService(deps.EmailService)
|
taskService.SetEmailService(deps.EmailService)
|
||||||
taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses
|
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)
|
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
|
||||||
taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo)
|
taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo)
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ type EmailAttachment struct {
|
|||||||
Data []byte
|
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
|
// SendEmailWithAttachment sends an email with an attachment
|
||||||
func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *EmailAttachment) error {
|
func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *EmailAttachment) error {
|
||||||
m := gomail.NewMessage()
|
m := gomail.NewMessage()
|
||||||
@@ -84,6 +92,39 @@ func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody s
|
|||||||
return nil
|
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
|
// baseEmailTemplate returns the styled email wrapper
|
||||||
func baseEmailTemplate() string {
|
func baseEmailTemplate() string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
@@ -631,7 +672,8 @@ The Casera Team
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendTaskCompletedEmail sends an email notification when a task is completed
|
// 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)
|
subject := fmt.Sprintf("Casera - Task Completed: %s", taskTitle)
|
||||||
|
|
||||||
name := recipientName
|
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")
|
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 Photos Section -->
|
||||||
|
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-top: 24px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px 0;">Completion Photo` + func() string {
|
||||||
|
if len(images) > 1 {
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}() + `:</p>
|
||||||
|
</td>
|
||||||
|
</tr>`
|
||||||
|
|
||||||
|
for i, img := range images {
|
||||||
|
imagesHTML += fmt.Sprintf(`
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<img src="cid:%s" alt="Completion photo %d" style="max-width: 100%%; height: auto; border-radius: 8px; border: 1px solid #E5E7EB;" />
|
||||||
|
</td>
|
||||||
|
</tr>`, img.ContentID, i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagesHTML += `
|
||||||
|
</table>`
|
||||||
|
|
||||||
|
imagesText = fmt.Sprintf("\n\n[%d completion photo(s) attached]", len(images))
|
||||||
|
}
|
||||||
|
|
||||||
bodyContent := fmt.Sprintf(`
|
bodyContent := fmt.Sprintf(`
|
||||||
%s
|
%s
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
@@ -658,6 +733,7 @@ func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, comp
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
%s
|
||||||
|
|
||||||
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
|
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
|
||||||
</td>
|
</td>
|
||||||
@@ -665,6 +741,7 @@ func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, comp
|
|||||||
%s`,
|
%s`,
|
||||||
emailHeader("Task Completed!"),
|
emailHeader("Task Completed!"),
|
||||||
name, residenceName, taskTitle, completedByName, completedTime,
|
name, residenceName, taskTitle, completedByName, completedTime,
|
||||||
|
imagesHTML,
|
||||||
emailFooter(time.Now().Year()))
|
emailFooter(time.Now().Year()))
|
||||||
|
|
||||||
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
|
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
|
||||||
@@ -678,12 +755,16 @@ A task has been completed at %s:
|
|||||||
|
|
||||||
Task: %s
|
Task: %s
|
||||||
Completed by: %s
|
Completed by: %s
|
||||||
Completed on: %s
|
Completed on: %s%s
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
The Casera Team
|
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)
|
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -32,6 +36,7 @@ type TaskService struct {
|
|||||||
residenceService *ResidenceService
|
residenceService *ResidenceService
|
||||||
notificationService *NotificationService
|
notificationService *NotificationService
|
||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
|
storageService *StorageService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTaskService creates a new task service
|
// NewTaskService creates a new task service
|
||||||
@@ -57,6 +62,11 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) {
|
|||||||
s.residenceService = rs
|
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.
|
// getSummaryForUser returns an empty summary placeholder.
|
||||||
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
|
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
|
||||||
// Clients should calculate summary from kanban data instead (which already includes all tasks).
|
// 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()
|
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
|
// Notify all users
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
isCompleter := user.ID == completion.CompletedByID
|
isCompleter := user.ID == completion.CompletedByID
|
||||||
@@ -746,24 +762,96 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
|
|||||||
prefs, err := s.notificationService.GetPreferences(user.ID)
|
prefs, err := s.notificationService.GetPreferences(user.ID)
|
||||||
if err != nil || (prefs != nil && prefs.EmailTaskCompleted) {
|
if err != nil || (prefs != nil && prefs.EmailTaskCompleted) {
|
||||||
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
|
// 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(
|
if err := s.emailService.SendTaskCompletedEmail(
|
||||||
u.Email,
|
u.Email,
|
||||||
u.GetFullName(),
|
u.GetFullName(),
|
||||||
task.Title,
|
task.Title,
|
||||||
completedByName,
|
completedByName,
|
||||||
residenceName,
|
residenceName,
|
||||||
|
images,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
||||||
} else {
|
} 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
|
// GetCompletion gets a task completion by ID
|
||||||
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
||||||
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
||||||
|
|||||||
Reference in New Issue
Block a user