Skip to content

Commit 11e2ae3

Browse files
authored
Merge pull request #346 from Powerhamster/feature/smtp-embedded-images
feat(smtp): replace CID references in HTML with attachment preview URLs
2 parents b492013 + 2d414a5 commit 11e2ae3

2 files changed

Lines changed: 95 additions & 11 deletions

File tree

modules/smtp/handler.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ func (s *session) Data(r io.Reader) error {
114114
// Generate event UUID for attachment storage.
115115
eventUUID := event.GenerateUUID()
116116

117-
// Store attachments.
117+
// Store attachments and collect CID→URL mappings for inline images.
118+
cidMap := make(map[string]string) // cid -> preview URL
118119
if s.backend.attachments != nil && len(attachments) > 0 {
119120
for _, att := range attachments {
120121
path := eventUUID + "/" + att.Filename
@@ -129,6 +130,17 @@ func (s *session) Data(r io.Reader) error {
129130
attUUID, eventUUID, att.Filename, path, len(att.content), att.Type, att.ContentID,
130131
)
131132
}
133+
if att.ContentID != "" {
134+
cid := strings.Trim(att.ContentID, "<>")
135+
cidMap[cid] = "/api/smtp/attachments/" + eventUUID + "/preview/" + attUUID
136+
}
137+
}
138+
}
139+
140+
// Replace cid: references in HTML with attachment preview URLs.
141+
if parsed.HTML != "" && len(cidMap) > 0 {
142+
for cid, url := range cidMap {
143+
parsed.HTML = strings.ReplaceAll(parsed.HTML, "cid:"+cid, url)
132144
}
133145
}
134146

@@ -167,16 +179,16 @@ type parsedAttachment struct {
167179
// ParsedEmail is the structure stored as event payload.
168180
// Field names match the original PHP Buggregator Message::jsonSerialize().
169181
type ParsedEmail struct {
170-
ID *string `json:"id"`
171-
Subject string `json:"subject"`
172-
From []EmailAddress `json:"from"`
173-
To []EmailAddress `json:"to"`
174-
Cc []EmailAddress `json:"cc"`
175-
Bcc []string `json:"bcc"`
176-
ReplyTo []EmailAddress `json:"reply_to"`
177-
Text string `json:"text"`
178-
HTML string `json:"html"`
179-
Raw string `json:"raw"`
182+
ID *string `json:"id"`
183+
Subject string `json:"subject"`
184+
From []EmailAddress `json:"from"`
185+
To []EmailAddress `json:"to"`
186+
Cc []EmailAddress `json:"cc"`
187+
Bcc []string `json:"bcc"`
188+
ReplyTo []EmailAddress `json:"reply_to"`
189+
Text string `json:"text"`
190+
HTML string `json:"html"`
191+
Raw string `json:"raw"`
180192
}
181193

182194
func parseEmail(raw []byte, recipients []string) (*ParsedEmail, []parsedAttachment, error) {

modules/smtp/handler_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,78 @@ func TestParseEmail_RFC2047Subject(t *testing.T) {
300300
}
301301
}
302302

303+
func TestParseEmail_InlineAttachmentWithCID(t *testing.T) {
304+
// Build a multipart/related email with an inline image referenced via cid:
305+
raw := []byte("From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: CID Test\r\n" +
306+
"Content-Type: multipart/related; boundary=\"rel\"\r\n\r\n" +
307+
"--rel\r\nContent-Type: text/html\r\n\r\n" +
308+
"<html><body><img src=\"cid:logo123@example.com\"></body></html>\r\n" +
309+
"--rel\r\nContent-Type: image/png\r\nContent-Disposition: inline; filename=\"logo.png\"\r\n" +
310+
"Content-ID: <logo123@example.com>\r\nContent-Transfer-Encoding: base64\r\n\r\n" +
311+
"iVBORw0KGgo=\r\n" +
312+
"--rel--")
313+
314+
parsed, atts, err := parseEmail(raw, []string{"recipient@example.com"})
315+
if err != nil {
316+
t.Fatal(err)
317+
}
318+
319+
// HTML should contain the cid: reference (replacement happens in Data(), not parseEmail).
320+
if !strings.Contains(parsed.HTML, "cid:logo123@example.com") {
321+
t.Errorf("HTML should still contain cid: reference, got %q", parsed.HTML)
322+
}
323+
324+
// Should have one attachment with ContentID set.
325+
if len(atts) != 1 {
326+
t.Fatalf("expected 1 attachment, got %d", len(atts))
327+
}
328+
if atts[0].ContentID != "logo123@example.com" {
329+
t.Errorf("ContentID = %q, want %q", atts[0].ContentID, "logo123@example.com")
330+
}
331+
if atts[0].Filename != "logo.png" {
332+
t.Errorf("Filename = %q, want %q", atts[0].Filename, "logo.png")
333+
}
334+
}
335+
336+
func TestReplaceCIDReferences(t *testing.T) {
337+
// Simulate the CID replacement logic from Data().
338+
html := `<html><body><img src="cid:logo@example.com"><img src="cid:banner@example.com"></body></html>`
339+
cidMap := map[string]string{
340+
"logo@example.com": "/api/smtp/attachments/evt-uuid/preview/att-uuid-1",
341+
"banner@example.com": "/api/smtp/attachments/evt-uuid/preview/att-uuid-2",
342+
}
343+
344+
for cid, url := range cidMap {
345+
html = strings.ReplaceAll(html, "cid:"+cid, url)
346+
}
347+
348+
if strings.Contains(html, "cid:") {
349+
t.Errorf("HTML still contains cid: references: %s", html)
350+
}
351+
if !strings.Contains(html, "/api/smtp/attachments/evt-uuid/preview/att-uuid-1") {
352+
t.Error("missing logo preview URL")
353+
}
354+
if !strings.Contains(html, "/api/smtp/attachments/evt-uuid/preview/att-uuid-2") {
355+
t.Error("missing banner preview URL")
356+
}
357+
}
358+
359+
func TestReplaceCIDReferences_NoCID(t *testing.T) {
360+
// When there are no CID references, HTML should remain unchanged.
361+
html := `<html><body><p>No images</p></body></html>`
362+
cidMap := map[string]string{}
363+
364+
if len(cidMap) > 0 {
365+
for cid, url := range cidMap {
366+
html = strings.ReplaceAll(html, "cid:"+cid, url)
367+
}
368+
}
369+
370+
if html != `<html><body><p>No images</p></body></html>` {
371+
t.Errorf("HTML was modified unexpectedly: %s", html)
372+
}
373+
}
374+
303375
func TestPreviewMapper(t *testing.T) {
304376
m := &previewMapper{}
305377

0 commit comments

Comments
 (0)