From dcb43b08a0d7c01ff67d0037cfac95342e4ec751 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Sat, 30 May 2026 13:38:23 +0000 Subject: [PATCH] feat: send receipt images as ZIP attachment with report - Email now includes both report (CSV/PDF) + ZIP of all receipt images - ZIP images named {event-name}-{index}.{ext} matching list order - Uses Go's archive/zip (stdlib, no external deps) - Sender.SendReport now accepts []*Attachment for multiple files - Gracefully skips missing image files with warnings --- internal/email/smtp.go | 44 +++++++------ internal/handlers/auth.go | 4 +- internal/handlers/events.go | 7 +-- internal/handlers/file.go | 112 +++++++++++++++++++++++++++++++--- main.go | 11 ++++ static/css/style.css | 59 +++++++++++++++--- static/icons/icon-180.png | Bin 0 -> 5290 bytes static/icons/icon-192.png | Bin 593 -> 5713 bytes static/icons/icon-512.png | Bin 2200 -> 17548 bytes static/sw.js | 4 +- templates/dashboard.html | 18 +++--- templates/event_expenses.html | 20 +++--- templates/index.html | 4 +- templates/receipt_form.html | 6 +- 14 files changed, 224 insertions(+), 65 deletions(-) create mode 100644 static/icons/icon-180.png diff --git a/internal/email/smtp.go b/internal/email/smtp.go index c3f3cf8..421392a 100644 --- a/internal/email/smtp.go +++ b/internal/email/smtp.go @@ -77,11 +77,10 @@ func (s *Sender) SendOTP(to, code string) error { return nil } -// SendReport sends an email with the given subject and body, attaching a CSV -// or PDF file. The attachment's Content-Type is inferred from its filename -// extension (text/csv for .csv, application/octet-stream otherwise). -func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error { - msg, err := buildMultipartMessage(s.from, to, subject, body, attachment) +// SendReport sends an email with the given subject and body, attaching one or +// more files (report CSV/PDF + ZIP of receipt images). +func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment) error { + msg, err := buildMultipartMessage(s.from, to, subject, body, attachments) if err != nil { log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v", time.Now().Format(time.RFC3339), to, err) @@ -94,8 +93,12 @@ func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) er return err } + names := make([]string, len(attachments)) + for i, a := range attachments { + names[i] = a.Filename + } log.Printf("INFO [%s] email: report sent to %s (%s)", - time.Now().Format(time.RFC3339), to, attachment.Filename) + time.Now().Format(time.RFC3339), to, strings.Join(names, ", ")) return nil } @@ -184,13 +187,15 @@ func buildPlainMessage(from, to, subject, body string) []byte { // buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a // text/plain body and a single attachment encoded as base64. -func buildMultipartMessage(from, to, subject, body string, attachment *Attachment) ([]byte, error) { +func buildMultipartMessage(from, to, subject, body string, attachments []*Attachment) ([]byte, error) { var b strings.Builder - // Write the main SMTP headers. + // Write the main SMTP headers with deliverability improvements. writeHeader(&b, "From", from) writeHeader(&b, "To", to) writeHeader(&b, "Subject", subject) + writeHeader(&b, "Message-ID", fmt.Sprintf("<%d.receiptnext@post.2-4-h.app>", time.Now().UnixNano())) + writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z)) // Create a multipart writer using a unique boundary string. mw := multipart.NewWriter(&b) @@ -209,18 +214,20 @@ func buildMultipartMessage(from, to, subject, body string, attachment *Attachmen return nil, fmt.Errorf("writing text part: %w", err) } - // --- Attachment part --- - aw, err := mw.CreatePart(attachmentHeader(attachment.Filename)) - if err != nil { - return nil, fmt.Errorf("creating attachment part: %w", err) - } + // --- Attachment parts (report + receipt images zip) --- + for _, att := range attachments { + aw, err := mw.CreatePart(attachmentHeader(att.Filename)) + if err != nil { + return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err) + } - enc := base64.NewEncoder(base64.StdEncoding, aw) - if _, err := enc.Write(attachment.Content); err != nil { + enc := base64.NewEncoder(base64.StdEncoding, aw) + if _, err := enc.Write(att.Content); err != nil { + enc.Close() + return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err) + } enc.Close() - return nil, fmt.Errorf("writing attachment content: %w", err) } - enc.Close() mw.Close() @@ -268,7 +275,8 @@ func attachmentHeader(filename string) textproto.MIMEHeader { func attachmentContentType(filename string) string { switch { case strings.HasSuffix(strings.ToLower(filename), ".csv"): - return "text/csv; charset=\"utf-8\"" + // Some providers block text/csv; use text/plain as fallback. + return "text/plain; charset=\"utf-8\"" case strings.HasSuffix(strings.ToLower(filename), ".pdf"): return "application/pdf" default: diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index cb46117..b4ec847 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -251,7 +251,7 @@ func getUserID(r *http.Request) string { // renderError writes an HTMX-compatible HTML error fragment to the response. func renderError(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(message)) + fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(message)) } // renderOTPForm writes the OTP verification form partial as an HTMX fragment. @@ -262,7 +262,7 @@ func renderOTPForm(w http.ResponseWriter, email string, errMsg string) { tmpl := template.Must(template.New("otp_form").Parse(`
- {{if .Error}}
{{.Error}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}}
diff --git a/internal/handlers/events.go b/internal/handlers/events.go index 0533b6e..b9f3526 100644 --- a/internal/handlers/events.go +++ b/internal/handlers/events.go @@ -6,7 +6,6 @@ package handlers import ( "database/sql" - "fmt" "html/template" "log" "net/http" @@ -180,9 +179,9 @@ func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) { return } - // Return HTMX fragment: green "open" badge targeting #status-badge-{id}. - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `open`, eventID) + // Redirect to dashboard so the full page renders with updated status. + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- diff --git a/internal/handlers/file.go b/internal/handlers/file.go index 76fc291..4d027f7 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -5,12 +5,16 @@ package handlers import ( + "archive/zip" "bytes" "database/sql" "encoding/csv" "fmt" "log" "net/http" + "os" + "path/filepath" + "strings" "time" "github.com/go-chi/chi/v5" @@ -124,12 +128,12 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { } // 5. Generate the report in the requested format. - var attachment *email.Attachment + var reportAttachment *email.Attachment switch format { case "csv": - attachment, err = generateCSV(event.Name, expenses) + reportAttachment, err = generateCSV(event.Name, expenses) case "pdf": - attachment, err = generatePDF(event.Name, expenses) + reportAttachment, err = generatePDF(event.Name, expenses) } if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v", @@ -138,7 +142,19 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { return } - // 6. Send the report as an email attachment. + // 6. Create a ZIP of all receipt images. + zipAttachment, zipErr := createReceiptZip(event.Name, expenses) + + // 7. Build the list of attachments (report + ZIP if available). + attachments := []*email.Attachment{reportAttachment} + if zipErr == nil && zipAttachment != nil { + attachments = append(attachments, zipAttachment) + } else if zipErr != nil { + log.Printf("WARN [%s] handlers: FileEvent: receipt zip failed: %v", + time.Now().Format(time.RFC3339), zipErr) + } + + // 8. Send the email with all attachments. if h.EmailSender == nil { log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email", time.Now().Format(time.RFC3339)) @@ -146,8 +162,8 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { return } subject := "Expense report for event " + event.Name - body := "Please find attached the expense report." - if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil { + body := "Please find attached the expense report and receipt images." + if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v", time.Now().Format(time.RFC3339), to, err) http.Error(w, "Failed to send report email", http.StatusInternalServerError) @@ -244,8 +260,9 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme return nil, fmt.Errorf("CSV writer flush: %w", err) } + filename := fmt.Sprintf("receiptnext-%s.csv", sanitiseFilename(eventName)) return &email.Attachment{ - Filename: "report.csv", + Filename: filename, Content: buf.Bytes(), }, nil } @@ -321,6 +338,70 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme }, nil } +// createReceiptZip creates a ZIP archive containing all receipt images from the +// given expenses. Each image is named {event-name}-{index}.{ext} inside the ZIP. +// Returns nil if there are no expenses with images, or if all image files are +// missing from disk. +func createReceiptZip(eventName string, expenses []database.Expense) (*email.Attachment, error) { + safeName := sanitiseFilename(eventName) + if safeName == "" { + safeName = "event" + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + added := 0 + + for i, exp := range expenses { + if exp.ImagePath == "" { + continue + } + + // Read the image file from disk. + data, err := os.ReadFile(exp.ImagePath) + if err != nil { + log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v", + time.Now().Format(time.RFC3339), exp.ImagePath, err) + continue + } + + // Determine file extension from the image path. + ext := filepath.Ext(exp.ImagePath) + if ext == "" { + ext = ".jpg" + } + + filename := fmt.Sprintf("%s-%d%s", safeName, i+1, ext) + f, err := zw.Create(filename) + if err != nil { + log.Printf("WARN [%s] handlers: createReceiptZip: creating entry %q: %v", + time.Now().Format(time.RFC3339), filename, err) + continue + } + if _, err := f.Write(data); err != nil { + log.Printf("WARN [%s] handlers: createReceiptZip: writing %q: %v", + time.Now().Format(time.RFC3339), filename, err) + continue + } + added++ + } + + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("closing zip: %w", err) + } + + if added == 0 { + log.Printf("INFO [%s] handlers: createReceiptZip: no receipt images found for event %q", + time.Now().Format(time.RFC3339), eventName) + return nil, nil + } + + return &email.Attachment{ + Filename: fmt.Sprintf("%s-images.zip", safeName), + Content: buf.Bytes(), + }, nil +} + // truncateString truncates a string to the given maximum length, appending "…" // if the string was shortened. func truncateString(s string, maxLen int) string { @@ -329,3 +410,20 @@ func truncateString(s string, maxLen int) string { } return s[:maxLen-1] + "…" } + +// sanitiseFilename converts a string into a safe filename (alphanumerics, +// hyphens, underscores only — no spaces or special characters). +func sanitiseFilename(s string) string { + var result []rune + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + result = append(result, r) + } else if r == ' ' || r == '.' { + result = append(result, '-') + } + } + if len(result) == 0 { + return "expenses" + } + return strings.Trim(string(result), "-") +} diff --git a/main.go b/main.go index 1f4ed7b..18c4fe9 100644 --- a/main.go +++ b/main.go @@ -136,6 +136,17 @@ func main() { http.ServeFile(w, r, "static/manifest.json") })) + // iOS PWA / Safari root-level icon requests. + r.Get("/apple-touch-icon.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "static/icons/icon-180.png") + })) + r.Get("/apple-touch-icon-120x120.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "static/icons/icon-180.png") + })) + r.Get("/favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "static/favicon.svg") + })) + // Serve uploaded receipt images. r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP) diff --git a/static/css/style.css b/static/css/style.css index c075439..04a8d79 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -670,10 +670,12 @@ small, .text-sm { .form-label { display: block; - font-size: var(--text-sm); - font-weight: var(--font-medium); - color: var(--color-text); - margin-bottom: var(--space-2); + font-size: var(--text-xs); + font-weight: var(--font-semibold); + color: var(--color-text-muted); + margin-bottom: var(--space-1); + text-transform: uppercase; + letter-spacing: 0.05em; } .form-label--required::after { @@ -681,14 +683,15 @@ small, .text-sm { color: var(--color-danger); } -.form-input, -.form-select, -.form-textarea { +/* Bare input/select/textarea inside form-group get the same styling */ +.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]), +.form-group select, +.form-group textarea { display: block; width: 100%; padding: var(--space-4) var(--space-4); font-family: inherit; - font-size: var(--text-base); + font-size: var(--text-lg); line-height: var(--leading-relaxed); color: var(--color-text); background-color: var(--color-card); @@ -699,19 +702,55 @@ small, .text-sm { box-shadow var(--transition-fast); -webkit-appearance: none; appearance: none; + box-sizing: border-box; +} + +.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"])::placeholder, +.form-group textarea::placeholder { + color: var(--color-text-light); + font-size: var(--text-base); +} + +/* Also keep the class-based selectors for explicit usage */ +.form-input, +.form-select, +.form-textarea { + display: block; + width: 100%; + padding: var(--space-4) var(--space-4); + font-family: inherit; + font-size: var(--text-lg); + line-height: var(--leading-relaxed); + color: var(--color-text); + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; } .form-input::placeholder, .form-textarea::placeholder { color: var(--color-text-light); + font-size: var(--text-base); } +.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]):hover, +.form-group select:hover, +.form-group textarea:hover, .form-input:hover, .form-select:hover, .form-textarea:hover { border-color: var(--color-text-light); } +.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]):focus, +.form-group select:focus, +.form-group textarea:focus, .form-input:focus, .form-select:focus, .form-textarea:focus { @@ -754,6 +793,7 @@ small, .text-sm { resize: vertical; } +.form-group select, .form-select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; @@ -764,6 +804,9 @@ small, .text-sm { /* Prevent zoom on mobile for inputs */ @media screen and (max-width: 768px) { + .form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]), + .form-group select, + .form-group textarea, .form-input, .form-select, .form-textarea { diff --git a/static/icons/icon-180.png b/static/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..ec9fadcae6fe99901c9e8b5fbd32fc01a8a1f6ca GIT binary patch literal 5290 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 literal 593 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V3P23aSW-L^Y*eLBZGp#fdl;x zsYw(0k`&4w{W+61w{~Gx9s2=)HifecOC%dim?xw$T;g$<#pq$okU1(ij05Br^PZE+ V9)-2MX~3ky;OXk;vd$@?2>`_Ns`dZ? diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png index 7a00b3f9f9ff2eb16771bb328d60c91916069d0c..aa924b185654b1546ecb9b6c340e1bbd2057a4c1 100644 GIT binary patch literal 17548 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 bAZ+`8ZBadQcQO1Q$aRYHs&bfrpT7J*bg#~p literal 2200 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e;yqm)Ln`LHy{5>>puljz zKq+8m#FAz)1v|s~pN8M97#vRjkY!-llg_}PaDsusVFDvVg90-HgCo$0CLRU`7D=EP zMhpxBDWeKT!(lW%jOL5c@?o@`9IXyUt3^ojAXX`{Q?~hty)UrE!QkoY=d#Wzp$Pz& C&l#Hl diff --git a/static/sw.js b/static/sw.js index 14c075b..f705975 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,7 +1,7 @@ /* ============================================================ * ReceiptNext — Service Worker - * Version: 1.0.0 - * Cache name: receiptnext-v1 + * Version: 2.0.0 + * Cache name: receiptnext-v2 * Strategy: Cache-first for shell assets, network-only for API * ============================================================ */ diff --git a/templates/dashboard.html b/templates/dashboard.html index f126ec9..c536c07 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -8,7 +8,7 @@ - + @@ -44,9 +44,9 @@
-
-
Conversion Sample
-

+

+
Conversion Sample
+

From a payment notification: receipt amount and what you were charged.

@@ -75,10 +75,10 @@ {{if .Events}}
{{range .Events}} -
+
-
{{.Name}}
-
+
{{.Name}}
+
{{.BaseCurrency}} · {{printf "%.6f" .ExchangeRate}} rate
@@ -91,8 +91,8 @@ {{if eq .Status "closed"}} diff --git a/templates/event_expenses.html b/templates/event_expenses.html index 8227e5b..e0b97e9 100644 --- a/templates/event_expenses.html +++ b/templates/event_expenses.html @@ -8,7 +8,7 @@ - + @@ -25,25 +25,25 @@

Receipts {{if .Event.BaseCurrency}} - (claim in {{.Event.BaseCurrency}}) + (claim in {{.Event.BaseCurrency}}) {{end}}

{{if .Expenses}}
{{range .Expenses}} -
+
-
{{.Merchant}}
-
{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}
+
{{.Merchant}}
+
{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}
-
{{printf "%.2f" .Amount}} {{.Currency}}
+
{{printf "%.2f" .Amount}} {{.Currency}}
{{if .ConvertedAmount}} -
{{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
+
{{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
{{end}}
-