diff --git a/internal/handler/fetch_integration_test.go b/internal/handler/fetch_integration_test.go index bfa1a16..7fcd969 100644 --- a/internal/handler/fetch_integration_test.go +++ b/internal/handler/fetch_integration_test.go @@ -23,8 +23,21 @@ import ( "github.com/smallchungus/disttaskqueue/internal/testutil" ) -func TestRenderHandler_WritesPDFAndReturnsUpload(t *testing.T) { - const fakeMime = "From: a@b.com\r\nSubject: hi\r\nContent-Type: text/html\r\n\r\n

Hello

" +func TestRenderHandler_ParsesMultipartAndSavesAttachments(t *testing.T) { + const msgRaw = "From: alice@example.com\r\n" + + "Subject: hi\r\n" + + "Date: Fri, 18 Apr 2026 10:30:45 +0000\r\n" + + "Content-Type: multipart/mixed; boundary=MIX\r\n\r\n" + + "--MIX\r\n" + + "Content-Type: text/html; charset=utf-8\r\n\r\n" + + "

Hello

\r\n" + + "--MIX\r\n" + + "Content-Type: application/pdf; name=report.pdf\r\n" + + "Content-Disposition: attachment; filename=report.pdf\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "SGVsbG9QREY=\r\n" + + "--MIX--\r\n" + const fakePDF = "%PDF-1.7\nfake\n%%EOF" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -37,17 +50,14 @@ func TestRenderHandler_WritesPDFAndReturnsUpload(t *testing.T) { jobID := uuid.New() mimeDir := filepath.Join(dataDir, "mime") - if err := os.MkdirAll(mimeDir, 0o755); err != nil { + if err := os.MkdirAll(mimeDir, 0o750); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(mimeDir, jobID.String()+".eml"), []byte(fakeMime), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(mimeDir, jobID.String()+".eml"), []byte(msgRaw), 0o600); err != nil { t.Fatal(err) } - h := handler.NewRenderHandler(handler.RenderConfig{ - DataDir: dataDir, - PDFEndpoint: srv.URL, - }) + h := handler.NewRenderHandler(handler.RenderConfig{DataDir: dataDir, PDFEndpoint: srv.URL}) job := store.Job{ID: jobID, Stage: "render"} next, err := h.Process(context.Background(), job) @@ -55,15 +65,35 @@ func TestRenderHandler_WritesPDFAndReturnsUpload(t *testing.T) { t.Fatalf("process: %v", err) } if next != "upload" { - t.Fatalf("next: %q, want upload", next) + t.Fatalf("next: %q", next) + } + + pdf, err := os.ReadFile(filepath.Join(dataDir, "pdf", jobID.String()+".pdf")) + if err != nil || string(pdf) != fakePDF { + t.Fatalf("pdf: %v / %q", err, pdf) } - written, err := os.ReadFile(filepath.Join(dataDir, "pdf", jobID.String()+".pdf")) + metaB, err := os.ReadFile(filepath.Join(dataDir, "meta", jobID.String()+".json")) if err != nil { - t.Fatalf("read pdf: %v", err) + t.Fatalf("meta: %v", err) + } + var meta handler.RenderMeta + if err := json.Unmarshal(metaB, &meta); err != nil { + t.Fatalf("parse meta: %v", err) + } + if meta.Subject != "hi" || meta.FromEmail != "alice@example.com" { + t.Fatalf("meta: %+v", meta) } - if string(written) != fakePDF { - t.Fatalf("pdf: got %q, want %q", written, fakePDF) + if len(meta.AttachmentNames) != 1 || meta.AttachmentNames[0] != "report.pdf" { + t.Fatalf("attachment names: %v", meta.AttachmentNames) + } + + attach, err := os.ReadFile(filepath.Join(dataDir, "attachments", jobID.String(), "report.pdf")) + if err != nil { + t.Fatalf("attachment: %v", err) + } + if string(attach) != "HelloPDF" { + t.Fatalf("attachment content: %q", attach) } } @@ -177,6 +207,33 @@ func TestUploadHandler_UploadsToDateTreeAndReturnsTerminal(t *testing.T) { dataDir := t.TempDir() jobID := uuid.New() + + // Write meta.json + metaDir := filepath.Join(dataDir, "meta") + if err := os.MkdirAll(metaDir, 0o750); err != nil { + t.Fatal(err) + } + meta := handler.RenderMeta{ + Subject: "hi", + FromEmail: "alice@example.com", + ReceivedAt: time.Date(2026, 4, 17, 10, 30, 45, 0, time.UTC), + AttachmentNames: []string{"report.pdf"}, + } + metaB, _ := json.Marshal(meta) + if err := os.WriteFile(filepath.Join(metaDir, jobID.String()+".json"), metaB, 0o600); err != nil { + t.Fatal(err) + } + + // Write attachment file + attachDir := filepath.Join(dataDir, "attachments", jobID.String()) + if err := os.MkdirAll(attachDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(attachDir, "report.pdf"), []byte("PDF-DATA-2"), 0o600); err != nil { + t.Fatal(err) + } + + // Write email PDF pdfDir := filepath.Join(dataDir, "pdf") if err := os.MkdirAll(pdfDir, 0o755); err != nil { t.Fatal(err) diff --git a/internal/handler/mime.go b/internal/handler/mime.go new file mode 100644 index 0000000..9d3ce86 --- /dev/null +++ b/internal/handler/mime.go @@ -0,0 +1,162 @@ +package handler + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "net/textproto" + "strings" + "time" +) + +type ParsedMessage struct { + HTML []byte + Text []byte + Attachments []Attachment + Subject string + From string + FromEmail string + ReceivedAt time.Time +} + +type Attachment struct { + Filename string + ContentType string + Content []byte +} + +func parseMessage(raw []byte) (ParsedMessage, error) { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return ParsedMessage{}, fmt.Errorf("read message: %w", err) + } + + out := ParsedMessage{ + Subject: msg.Header.Get("Subject"), + From: msg.Header.Get("From"), + } + if addr, err := mail.ParseAddress(out.From); err == nil { + out.FromEmail = addr.Address + } + if d := msg.Header.Get("Date"); d != "" { + if t, err := mail.ParseDate(d); err == nil { + out.ReceivedAt = t + } + } + + ct := msg.Header.Get("Content-Type") + if ct == "" { + ct = "text/plain" + } + + headers := textproto.MIMEHeader(msg.Header) + if err := walkPart(&out, ct, headers, msg.Body); err != nil { + return ParsedMessage{}, fmt.Errorf("walk: %w", err) + } + return out, nil +} + +func walkPart(out *ParsedMessage, contentType string, headers textproto.MIMEHeader, body io.Reader) error { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = "text/plain" + params = map[string]string{} + } + + attachName := "" + isAttachment := false + if disp := headers.Get("Content-Disposition"); disp != "" { + d, dp, err := mime.ParseMediaType(disp) + if err == nil { + if d == "attachment" { + isAttachment = true + } + if n := dp["filename"]; n != "" { + attachName = n + if d != "inline" { + isAttachment = true + } + } + } + } + if attachName == "" && params["name"] != "" && !strings.HasPrefix(mediaType, "multipart/") && !strings.HasPrefix(mediaType, "text/") { + attachName = params["name"] + isAttachment = true + } + + if isAttachment { + decoded, err := decodePart(headers, body) + if err != nil { + return fmt.Errorf("decode attachment: %w", err) + } + out.Attachments = append(out.Attachments, Attachment{ + Filename: attachName, + ContentType: mediaType, + Content: decoded, + }) + return nil + } + + if strings.HasPrefix(mediaType, "multipart/") { + boundary := params["boundary"] + if boundary == "" { + return fmt.Errorf("multipart without boundary") + } + mr := multipart.NewReader(body, boundary) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("next part: %w", err) + } + partCT := part.Header.Get("Content-Type") + if partCT == "" { + partCT = "text/plain" + } + if err := walkPart(out, partCT, part.Header, part); err != nil { + return err + } + } + return nil + } + + decoded, err := decodePart(headers, body) + if err != nil { + return fmt.Errorf("decode leaf: %w", err) + } + switch mediaType { + case "text/html": + if len(out.HTML) == 0 { + out.HTML = decoded + } + case "text/plain": + if len(out.Text) == 0 { + out.Text = decoded + } + } + return nil +} + +func decodePart(headers textproto.MIMEHeader, body io.Reader) ([]byte, error) { + raw, err := io.ReadAll(body) + if err != nil { + return nil, err + } + enc := strings.ToLower(strings.TrimSpace(headers.Get("Content-Transfer-Encoding"))) + switch enc { + case "base64": + cleaned := strings.ReplaceAll(strings.ReplaceAll(string(raw), "\r", ""), "\n", "") + return base64.StdEncoding.DecodeString(cleaned) + case "quoted-printable": + return io.ReadAll(quotedprintable.NewReader(bytes.NewReader(raw))) + default: + return raw, nil + } +} diff --git a/internal/handler/mime_test.go b/internal/handler/mime_test.go new file mode 100644 index 0000000..1691c41 --- /dev/null +++ b/internal/handler/mime_test.go @@ -0,0 +1,112 @@ +package handler + +import ( + "strings" + "testing" +) + +const simpleHTML = `From: alice@example.com +To: bob@example.com +Subject: hi +Date: Fri, 18 Apr 2026 10:30:45 +0000 +Content-Type: text/html; charset="utf-8" + +

Hello

+` + +const altMultipart = `From: "Alice" +Subject: Re: project update +Date: Fri, 18 Apr 2026 10:30:45 +0000 +Content-Type: multipart/alternative; boundary="ALT" + +--ALT +Content-Type: text/plain; charset="utf-8" + +Hello from the plain side. +--ALT +Content-Type: text/html; charset="utf-8" + +

Hello from the HTML side.

+--ALT-- +` + +const mixedWithAttachment = `From: alice@example.com +Subject: with attachment +Date: Fri, 18 Apr 2026 10:30:45 +0000 +Content-Type: multipart/mixed; boundary="MIX" + +--MIX +Content-Type: text/html; charset="utf-8" + +

See attached.

+--MIX +Content-Type: application/pdf; name="report.pdf" +Content-Disposition: attachment; filename="report.pdf" +Content-Transfer-Encoding: base64 + +SGVsbG9QREY= +--MIX-- +` + +// normalizeCRLF makes fixtures use CRLF line endings like real email. +func normalizeCRLF(s string) string { + return strings.ReplaceAll(s, "\n", "\r\n") +} + +func TestParseMessage_SinglePartHTML(t *testing.T) { + m, err := parseMessage([]byte(normalizeCRLF(simpleHTML))) + if err != nil { + t.Fatalf("parse: %v", err) + } + if string(m.HTML) != "

Hello

\r\n" { + t.Fatalf("html: %q", m.HTML) + } + if m.FromEmail != "alice@example.com" { + t.Fatalf("from_email: %q", m.FromEmail) + } + if m.Subject != "hi" { + t.Fatalf("subject: %q", m.Subject) + } +} + +func TestParseMessage_MultipartAlternative_PrefersHTML(t *testing.T) { + m, err := parseMessage([]byte(normalizeCRLF(altMultipart))) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !strings.Contains(string(m.HTML), "HTML") { + t.Fatalf("html: %q", m.HTML) + } + if !strings.Contains(string(m.Text), "plain side") { + t.Fatalf("text: %q", m.Text) + } + if m.FromEmail != "alice@example.com" { + t.Fatalf("from_email: %q", m.FromEmail) + } + if m.Subject != "Re: project update" { + t.Fatalf("subject: %q", m.Subject) + } +} + +func TestParseMessage_MultipartMixed_ExtractsAttachment(t *testing.T) { + m, err := parseMessage([]byte(normalizeCRLF(mixedWithAttachment))) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !strings.Contains(string(m.HTML), "See attached") { + t.Fatalf("html: %q", m.HTML) + } + if len(m.Attachments) != 1 { + t.Fatalf("attachments: got %d, want 1", len(m.Attachments)) + } + a := m.Attachments[0] + if a.Filename != "report.pdf" { + t.Fatalf("filename: %q", a.Filename) + } + if a.ContentType != "application/pdf" { + t.Fatalf("content_type: %q", a.ContentType) + } + if string(a.Content) != "HelloPDF" { + t.Fatalf("content: %q (want HelloPDF, base64-decoded)", a.Content) + } +} diff --git a/internal/handler/paths.go b/internal/handler/paths.go index affe7ea..3e78970 100644 --- a/internal/handler/paths.go +++ b/internal/handler/paths.go @@ -2,7 +2,9 @@ package handler import ( "fmt" + "strings" "time" + "unicode" ) // DateTreeFolders returns the year/month/day folder names for the given time. @@ -14,3 +16,104 @@ func DateTreeFolders(t time.Time) []string { fmt.Sprintf("%02d", t.Day()), } } + +const ( + maxSubjectSlugLen = 40 + maxFilenameLen = 200 +) + +// SubjectSlug lowercases, replaces non-alphanumeric with '-', collapses runs, +// trims dashes, and caps length. Empty input becomes "untitled". +func SubjectSlug(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "untitled" + } + + var b strings.Builder + b.Grow(len(s)) + lastDash := true // strip leading dashes + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.TrimRight(b.String(), "-") + if out == "" { + return "untitled" + } + if len(out) > maxSubjectSlugLen { + out = strings.TrimRight(out[:maxSubjectSlugLen], "-") + } + return out +} + +// FromSlug extracts the local part from an email address, lowercases it, and +// replaces '+' and '.' with '-' for readability. +func FromSlug(email string) string { + if email == "" { + return "unknown" + } + at := strings.IndexByte(email, '@') + if at <= 0 { + return SubjectSlug(email) + } + local := strings.ToLower(email[:at]) + local = strings.ReplaceAll(local, "+", "-") + local = strings.ReplaceAll(local, ".", "-") + return local +} + +// EmailFolderName returns the per-email folder name used inside the date tree. +// Format: "___". +func EmailFolderName(receivedAt time.Time, subject, fromEmail string) string { + t := receivedAt.UTC() + return fmt.Sprintf("%04d-%02d-%02d_%02d%02d%02d_%s_%s", + t.Year(), int(t.Month()), t.Day(), + t.Hour(), t.Minute(), t.Second(), + SubjectSlug(subject), + FromSlug(fromEmail), + ) +} + +// SanitizeFilename replaces path separators and null bytes with '-', collapses +// runs, strips leading/trailing dots and dashes, and caps length. +func SanitizeFilename(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "unnamed" + } + var b strings.Builder + b.Grow(len(name)) + lastDash := false + for _, r := range name { + switch r { + case 0, '/', '\\': + if !lastDash { + b.WriteByte('-') + lastDash = true + } + default: + b.WriteRune(r) + lastDash = false + } + } + out := strings.Trim(b.String(), ". -") + if strings.Contains(out, "..") { + out = strings.ReplaceAll(out, "..", "-") + out = strings.Trim(out, ". -") + } + if out == "" { + return "unnamed" + } + if len(out) > maxFilenameLen { + out = out[:maxFilenameLen] + } + return out +} diff --git a/internal/handler/paths_test.go b/internal/handler/paths_test.go index b3c2234..10ea841 100644 --- a/internal/handler/paths_test.go +++ b/internal/handler/paths_test.go @@ -2,6 +2,7 @@ package handler import ( "reflect" + "strings" "testing" "time" ) @@ -13,3 +14,61 @@ func TestDateTreeFolders(t *testing.T) { t.Fatalf("got %v, want %v", got, want) } } + +func TestSubjectSlug_BasicAndWeirdChars(t *testing.T) { + cases := []struct { + in, want string + }{ + {"Re: project update", "re-project-update"}, + {"Your Monthly Statement for Apr 2026!", "your-monthly-statement-for-apr-2026"}, + {" leading/trailing ", "leading-trailing"}, + {"", "untitled"}, + {strings.Repeat("a", 80), strings.Repeat("a", 40)}, + } + for _, c := range cases { + if got := SubjectSlug(c.in); got != c.want { + t.Errorf("SubjectSlug(%q): got %q, want %q", c.in, got, c.want) + } + } +} + +func TestFromSlug_UsesLocalPart(t *testing.T) { + cases := []struct { + in, want string + }{ + {"alice@example.com", "alice"}, + {"BOB+news@example.com", "bob-news"}, + {"", "unknown"}, + } + for _, c := range cases { + if got := FromSlug(c.in); got != c.want { + t.Errorf("FromSlug(%q): got %q, want %q", c.in, got, c.want) + } + } +} + +func TestEmailFolderName_FormatsCorrectly(t *testing.T) { + tm := time.Date(2026, 4, 18, 10, 30, 45, 0, time.UTC) + got := EmailFolderName(tm, "Re: project update", "alice@example.com") + want := "2026-04-18_103045_re-project-update_alice" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestSanitizeFilename_StripsSlashesAndDots(t *testing.T) { + cases := []struct { + in, want string + }{ + {"report.pdf", "report.pdf"}, + {"../../etc/passwd", "etc-passwd"}, + {"a/b/c.txt", "a-b-c.txt"}, + {"", "unnamed"}, + {strings.Repeat("x", 300), strings.Repeat("x", 200)}, + } + for _, c := range cases { + if got := SanitizeFilename(c.in); got != c.want { + t.Errorf("SanitizeFilename(%q): got %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/handler/render.go b/internal/handler/render.go index c7fb49b..a7a55f7 100644 --- a/internal/handler/render.go +++ b/internal/handler/render.go @@ -3,12 +3,11 @@ package handler import ( "bytes" "context" - "errors" + "encoding/json" "fmt" - "net/mail" "os" "path/filepath" - "strings" + "time" "github.com/smallchungus/disttaskqueue/internal/pdf" "github.com/smallchungus/disttaskqueue/internal/store" @@ -28,18 +27,44 @@ func NewRenderHandler(cfg RenderConfig) *RenderHandler { return &RenderHandler{cfg: cfg, client: pdf.New(cfg.PDFEndpoint)} } +// RenderMeta is the cross-stage data written by render and read by upload. +type RenderMeta struct { + Subject string `json:"subject"` + FromEmail string `json:"from_email"` + ReceivedAt time.Time `json:"received_at"` + AttachmentNames []string `json:"attachment_names"` +} + func (h *RenderHandler) Process(ctx context.Context, job store.Job) (string, error) { - mimePath := filepath.Join(h.cfg.DataDir, "mime", job.ID.String()+".eml") - rawMime, err := os.ReadFile(mimePath) //nolint:gosec // path derived from trusted job ID, not user input + mimePath := filepath.Join(h.cfg.DataDir, "mime", job.ID.String()+".eml") //nolint:gosec // trusted job ID + rawMime, err := os.ReadFile(mimePath) //nolint:gosec // trusted job ID if err != nil { return "", fmt.Errorf("read mime: %w", err) } - html, err := htmlFromMime(rawMime) + parsed, err := parseMessage(rawMime) if err != nil { return "", fmt.Errorf("parse mime: %w", err) } + // Write attachments first (even if render fails, we don't waste them). + attachDir := filepath.Join(h.cfg.DataDir, "attachments", job.ID.String()) + attachmentNames := make([]string, 0, len(parsed.Attachments)) + if len(parsed.Attachments) > 0 { + if err := os.MkdirAll(attachDir, 0o750); err != nil { + return "", fmt.Errorf("mkdir attachments: %w", err) + } + } + for _, a := range parsed.Attachments { + name := SanitizeFilename(a.Filename) + if err := os.WriteFile(filepath.Join(attachDir, name), a.Content, 0o600); err != nil { //nolint:gosec // trusted job ID + return "", fmt.Errorf("write attachment %s: %w", name, err) + } + attachmentNames = append(attachmentNames, name) + } + + // Render the body to PDF. + html := buildHTMLWrapper(parsed) pdfBytes, err := h.client.RenderHTML(ctx, html) if err != nil { return "", fmt.Errorf("render: %w", err) @@ -47,53 +72,59 @@ func (h *RenderHandler) Process(ctx context.Context, job store.Job) (string, err pdfDir := filepath.Join(h.cfg.DataDir, "pdf") if err := os.MkdirAll(pdfDir, 0o750); err != nil { - return "", fmt.Errorf("mkdir: %w", err) + return "", fmt.Errorf("mkdir pdf: %w", err) } - pdfPath := filepath.Join(pdfDir, job.ID.String()+".pdf") - if err := os.WriteFile(pdfPath, pdfBytes, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(pdfDir, job.ID.String()+".pdf"), pdfBytes, 0o600); err != nil { //nolint:gosec // trusted job ID return "", fmt.Errorf("write pdf: %w", err) } - return "upload", nil -} - -func htmlFromMime(raw []byte) ([]byte, error) { - msg, err := mail.ReadMessage(bytes.NewReader(raw)) + // Write cross-stage metadata. + meta := RenderMeta{ + Subject: parsed.Subject, + FromEmail: parsed.FromEmail, + ReceivedAt: parsed.ReceivedAt, + AttachmentNames: attachmentNames, + } + if meta.ReceivedAt.IsZero() { + meta.ReceivedAt = job.CreatedAt + } + metaB, err := json.Marshal(meta) if err != nil { - return nil, fmt.Errorf("read message: %w", err) + return "", fmt.Errorf("marshal meta: %w", err) } - subject := msg.Header.Get("Subject") - from := msg.Header.Get("From") - date := msg.Header.Get("Date") - - body := &bytes.Buffer{} - if _, err := body.ReadFrom(msg.Body); err != nil { - return nil, fmt.Errorf("read body: %w", err) + metaDir := filepath.Join(h.cfg.DataDir, "meta") + if err := os.MkdirAll(metaDir, 0o750); err != nil { + return "", fmt.Errorf("mkdir meta: %w", err) } + if err := os.WriteFile(filepath.Join(metaDir, job.ID.String()+".json"), metaB, 0o600); err != nil { //nolint:gosec // trusted job ID + return "", fmt.Errorf("write meta: %w", err) + } + + return "upload", nil +} - if subject == "" && from == "" && body.Len() == 0 { - return nil, errors.New("empty message") +func buildHTMLWrapper(p ParsedMessage) []byte { + body := p.HTML + if len(body) == 0 { + body = []byte("
" + htmlEscape(string(p.Text)) + "
") } out := &bytes.Buffer{} - fmt.Fprintf(out, "%s", htmlEscape(subject)) - fmt.Fprintf(out, "
") - fmt.Fprintf(out, "
From: %s
", htmlEscape(from)) - fmt.Fprintf(out, "
Date: %s
", htmlEscape(date)) - fmt.Fprintf(out, "
Subject: %s
", htmlEscape(subject)) - fmt.Fprintf(out, "
") - contentType := msg.Header.Get("Content-Type") - if contentType == "" || strings.HasPrefix(contentType, "text/html") { - out.Write(body.Bytes()) - } else { - fmt.Fprintf(out, "
%s
", htmlEscape(body.String())) + fmt.Fprintf(out, `%s`, htmlEscape(p.Subject)) + fmt.Fprintf(out, `
`) + fmt.Fprintf(out, `
From: %s
`, htmlEscape(p.From)) + if !p.ReceivedAt.IsZero() { + fmt.Fprintf(out, `
Date: %s
`, htmlEscape(p.ReceivedAt.Format(time.RFC1123))) } - out.WriteString("") - return out.Bytes(), nil + fmt.Fprintf(out, `
Subject: %s
`, htmlEscape(p.Subject)) + fmt.Fprintf(out, `
`) + out.Write(body) + out.WriteString(``) + return out.Bytes() } func htmlEscape(s string) string { - r := bytes.NewBuffer(nil) + var r bytes.Buffer for _, c := range []byte(s) { switch c { case '<': diff --git a/internal/handler/upload.go b/internal/handler/upload.go index e82c456..7bdd84e 100644 --- a/internal/handler/upload.go +++ b/internal/handler/upload.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -40,6 +41,20 @@ func (h *UploadHandler) Process(ctx context.Context, job store.Job) (string, err return "", errors.New("upload: job missing user_id") } + // Load cross-stage metadata from render. + metaPath := filepath.Join(h.cfg.DataDir, "meta", job.ID.String()+".json") //nolint:gosec // trusted job ID + metaB, err := os.ReadFile(metaPath) //nolint:gosec // trusted job ID + if err != nil { + return "", fmt.Errorf("read meta: %w", err) + } + var meta RenderMeta + if err := json.Unmarshal(metaB, &meta); err != nil { + return "", fmt.Errorf("parse meta: %w", err) + } + if meta.ReceivedAt.IsZero() { + meta.ReceivedAt = job.CreatedAt + } + client, err := drive.New(ctx, drive.Config{ Store: h.cfg.Store, UserID: *job.UserID, @@ -51,7 +66,8 @@ func (h *UploadHandler) Process(ctx context.Context, job store.Job) (string, err return "", fmt.Errorf("drive client: %w", err) } - folders := DateTreeFolders(job.CreatedAt) + // Walk date-tree + email folder. + folders := append(DateTreeFolders(meta.ReceivedAt), EmailFolderName(meta.ReceivedAt, meta.Subject, meta.FromEmail)) parent := h.cfg.RootFolderID pathSoFar := "" for _, f := range folders { @@ -60,12 +76,10 @@ func (h *UploadHandler) Process(ctx context.Context, job store.Job) (string, err } else { pathSoFar = path.Join(pathSoFar, f) } - if cached, ok, err := h.cache.Get(ctx, *job.UserID, pathSoFar); err == nil && ok { parent = cached continue } - folderID, err := client.EnsureFolder(ctx, parent, f) if err != nil { return "", fmt.Errorf("ensure folder %s: %w", f, err) @@ -74,14 +88,24 @@ func (h *UploadHandler) Process(ctx context.Context, job store.Job) (string, err parent = folderID } - pdfBytes, err := os.ReadFile(filepath.Join(h.cfg.DataDir, "pdf", job.ID.String()+".pdf")) + // Upload email.pdf. + pdfBytes, err := os.ReadFile(filepath.Join(h.cfg.DataDir, "pdf", job.ID.String()+".pdf")) //nolint:gosec // trusted job ID if err != nil { return "", fmt.Errorf("read pdf: %w", err) } + if _, err := client.Upload(ctx, parent, "email.pdf", "application/pdf", pdfBytes); err != nil { + return "", fmt.Errorf("upload pdf: %w", err) + } - name := job.ID.String() + ".pdf" - if _, err := client.Upload(ctx, parent, name, "application/pdf", pdfBytes); err != nil { - return "", fmt.Errorf("upload: %w", err) + // Upload attachments (if any). + for _, name := range meta.AttachmentNames { + data, err := os.ReadFile(filepath.Join(h.cfg.DataDir, "attachments", job.ID.String(), name)) //nolint:gosec // trusted job ID + if err != nil { + return "", fmt.Errorf("read attachment %s: %w", name, err) + } + if _, err := client.Upload(ctx, parent, name, "application/octet-stream", data); err != nil { + return "", fmt.Errorf("upload attachment %s: %w", name, err) + } } return "", nil