fixing folders and image rendering in client

This commit is contained in:
2026-08-15 18:38:11 +01:00
parent 6f0c305367
commit 15678c1b6e
16 changed files with 929 additions and 26 deletions
+15 -6
View File
@@ -30,6 +30,11 @@ type Attachment struct {
Filename string
ContentType string
Data []byte
// ContentID is the part's Content-Id header (RFC 2392), angle brackets stripped,
// or "" if absent. An HTML body referencing this part inline (<img src="cid:...">)
// only renders once that reference is resolved against this — see
// webui.inlineContentIDImages, the caller responsible for doing so.
ContentID string
}
// Message is the parsed result. TextBody/HTMLBody are independently populated when
@@ -114,11 +119,12 @@ func walkMultipart(m *Message, r io.Reader, boundary string) error {
if filename == "" {
filename = params["name"]
}
contentID := strings.Trim(part.Header.Get("Content-Id"), "<>")
switch {
case disp == "attachment" || (filename != "" && disp != "inline"):
m.Attachments = append(m.Attachments, Attachment{
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
})
case mediaType == "text/html":
m.HTMLBody += string(data)
@@ -127,12 +133,15 @@ func walkMultipart(m *Message, r io.Reader, boundary string) error {
m.TextBody += "\n"
}
m.TextBody += string(data)
case filename != "":
// Inline non-text part (e.g. an embedded image) with no explicit
// disposition — still worth surfacing as a downloadable attachment
// rather than silently dropping it.
case filename != "" || contentID != "":
// Inline non-text part (e.g. an embedded image referenced by the HTML
// body via cid:) with no explicit disposition and possibly no filename
// either — still worth surfacing rather than silently dropping it.
if filename == "" {
filename = contentID
}
m.Attachments = append(m.Attachments, Attachment{
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
})
}
}
+46
View File
@@ -89,3 +89,49 @@ func TestParseNestedMultipartMixedWithAlternativeBody(t *testing.T) {
t.Fatalf("attachments = %+v", m.Attachments)
}
}
func TestParseInlineImageCapturesContentID(t *testing.T) {
raw := "" +
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p><img src=\"cid:img1@example.com\"></p>\r\n" +
"--B\r\nContent-Type: image/png\r\nContent-Disposition: inline; filename=\"header.png\"\r\n" +
"Content-Id: <img1@example.com>\r\n" +
"Content-Transfer-Encoding: BASE64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n" +
"--B--\r\n"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(m.HTMLBody) != `<p><img src="cid:img1@example.com"></p>` {
t.Errorf("HTMLBody = %q", m.HTMLBody)
}
if len(m.Attachments) != 1 {
t.Fatalf("attachments = %+v", m.Attachments)
}
if m.Attachments[0].ContentID != "img1@example.com" {
t.Errorf("ContentID = %q, want angle brackets stripped", m.Attachments[0].ContentID)
}
if got := string(m.Attachments[0].Data); got != "Hello, world!" {
t.Errorf("attachment data = %q, want decoded base64", got)
}
}
func TestParseInlineImageWithNoFilenameStillCaptured(t *testing.T) {
raw := "" +
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p>hi</p>\r\n" +
"--B\r\nContent-Type: image/png\r\n" +
"Content-Id: <noname@example.com>\r\n\r\nrawbytes" +
"\r\n--B--\r\n"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
// No filename anywhere on the part — must not be silently dropped just because
// there's nothing to name it; falls back to the Content-Id itself.
if len(m.Attachments) != 1 || m.Attachments[0].ContentID != "noname@example.com" {
t.Fatalf("attachments = %+v", m.Attachments)
}
}