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
\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 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) != "" + htmlEscape(string(p.Text)) + "") } out := &bytes.Buffer{} - fmt.Fprintf(out, "
%s", htmlEscape(body.String())) + fmt.Fprintf(out, `