Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 70 additions & 13 deletions internal/handler/fetch_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<h1>Hello</h1>"
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" +
"<p>Hello</p>\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) {
Expand All @@ -37,33 +50,50 @@ 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)
if err != nil {
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)
}
}

Expand Down Expand Up @@ -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)
Expand Down
162 changes: 162 additions & 0 deletions internal/handler/mime.go
Original file line number Diff line number Diff line change
@@ -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
}
}
112 changes: 112 additions & 0 deletions internal/handler/mime_test.go
Original file line number Diff line number Diff line change
@@ -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"

<h1>Hello</h1>
`

const altMultipart = `From: "Alice" <alice@example.com>
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"

<p>Hello from the <b>HTML</b> side.</p>
--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"

<p>See attached.</p>
--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) != "<h1>Hello</h1>\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), "<b>HTML</b>") {
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)
}
}
Loading
Loading