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 0000000..ec9fadc Binary files /dev/null and b/static/icons/icon-180.png differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png index 5019f20..3979e04 100644 Binary files a/static/icons/icon-192.png and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png index 7a00b3f..aa924b1 100644 Binary files a/static/icons/icon-512.png and b/static/icons/icon-512.png differ 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}}
-