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, `
`, template.HTMLEscapeString(message)) + fmt.Fprintf(w, ``, 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(`