From fe0d3b8db15dfee182392e6069c7cd70986067a6 Mon Sep 17 00:00:00 2001
From: Thomas Rothe
Date: Fri, 14 Aug 2026 19:28:34 +0200
Subject: [PATCH 1/2] feat(smtp): replace CID references in HTML with
attachment preview URLs
---
modules/smtp/handler.go | 34 +++++++++++------
modules/smtp/handler_test.go | 72 ++++++++++++++++++++++++++++++++++++
2 files changed, 95 insertions(+), 11 deletions(-)
diff --git a/modules/smtp/handler.go b/modules/smtp/handler.go
index 289812c..0daa6f9 100644
--- a/modules/smtp/handler.go
+++ b/modules/smtp/handler.go
@@ -114,7 +114,8 @@ func (s *session) Data(r io.Reader) error {
// Generate event UUID for attachment storage.
eventUUID := event.GenerateUUID()
- // Store attachments.
+ // Store attachments and collect CID→URL mappings for inline images.
+ cidMap := make(map[string]string) // cid -> preview URL
if s.backend.attachments != nil && len(attachments) > 0 {
for _, att := range attachments {
path := eventUUID + "/" + att.Filename
@@ -129,6 +130,17 @@ func (s *session) Data(r io.Reader) error {
attUUID, eventUUID, att.Filename, path, len(att.content), att.Type, att.ContentID,
)
}
+ if att.ContentID != "" {
+ cid := strings.Trim(att.ContentID, "<>")
+ cidMap[cid] = "/api/smtp/attachments/" + eventUUID + "/preview/" + attUUID
+ }
+ }
+ }
+
+ // Replace cid: references in HTML with attachment preview URLs.
+ if parsed.HTML != "" && len(cidMap) > 0 {
+ for cid, url := range cidMap {
+ parsed.HTML = strings.ReplaceAll(parsed.HTML, "cid:"+cid, url)
}
}
@@ -167,16 +179,16 @@ type parsedAttachment struct {
// ParsedEmail is the structure stored as event payload.
// Field names match the original PHP Buggregator Message::jsonSerialize().
type ParsedEmail struct {
- ID *string `json:"id"`
- Subject string `json:"subject"`
- From []EmailAddress `json:"from"`
- To []EmailAddress `json:"to"`
- Cc []EmailAddress `json:"cc"`
- Bcc []string `json:"bcc"`
- ReplyTo []EmailAddress `json:"reply_to"`
- Text string `json:"text"`
- HTML string `json:"html"`
- Raw string `json:"raw"`
+ ID *string `json:"id"`
+ Subject string `json:"subject"`
+ From []EmailAddress `json:"from"`
+ To []EmailAddress `json:"to"`
+ Cc []EmailAddress `json:"cc"`
+ Bcc []string `json:"bcc"`
+ ReplyTo []EmailAddress `json:"reply_to"`
+ Text string `json:"text"`
+ HTML string `json:"html"`
+ Raw string `json:"raw"`
}
func parseEmail(raw []byte, recipients []string) (*ParsedEmail, []parsedAttachment, error) {
diff --git a/modules/smtp/handler_test.go b/modules/smtp/handler_test.go
index 60c7790..439d11f 100644
--- a/modules/smtp/handler_test.go
+++ b/modules/smtp/handler_test.go
@@ -300,6 +300,78 @@ func TestParseEmail_RFC2047Subject(t *testing.T) {
}
}
+func TestParseEmail_InlineAttachmentWithCID(t *testing.T) {
+ // Build a multipart/related email with an inline image referenced via cid:
+ raw := []byte("From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: CID Test\r\n" +
+ "Content-Type: multipart/related; boundary=\"rel\"\r\n\r\n" +
+ "--rel\r\nContent-Type: text/html\r\n\r\n" +
+ "
\r\n" +
+ "--rel\r\nContent-Type: image/png\r\nContent-Disposition: inline; filename=\"logo.png\"\r\n" +
+ "Content-ID: \r\nContent-Transfer-Encoding: base64\r\n\r\n" +
+ "iVBORw0KGgo=\r\n" +
+ "--rel--")
+
+ parsed, atts, err := parseEmail(raw, []string{"recipient@example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // HTML should contain the cid: reference (replacement happens in Data(), not parseEmail).
+ if !strings.Contains(parsed.HTML, "cid:logo123@example.com") {
+ t.Errorf("HTML should still contain cid: reference, got %q", parsed.HTML)
+ }
+
+ // Should have one attachment with ContentID set.
+ if len(atts) != 1 {
+ t.Fatalf("expected 1 attachment, got %d", len(atts))
+ }
+ if atts[0].ContentID != "" {
+ t.Errorf("ContentID = %q, want %q", atts[0].ContentID, "")
+ }
+ if atts[0].Filename != "logo.png" {
+ t.Errorf("Filename = %q, want %q", atts[0].Filename, "logo.png")
+ }
+}
+
+func TestReplaceCIDReferences(t *testing.T) {
+ // Simulate the CID replacement logic from Data().
+ html := `
`
+ cidMap := map[string]string{
+ "logo@example.com": "/api/smtp/attachments/evt-uuid/preview/att-uuid-1",
+ "banner@example.com": "/api/smtp/attachments/evt-uuid/preview/att-uuid-2",
+ }
+
+ for cid, url := range cidMap {
+ html = strings.ReplaceAll(html, "cid:"+cid, url)
+ }
+
+ if strings.Contains(html, "cid:") {
+ t.Errorf("HTML still contains cid: references: %s", html)
+ }
+ if !strings.Contains(html, "/api/smtp/attachments/evt-uuid/preview/att-uuid-1") {
+ t.Error("missing logo preview URL")
+ }
+ if !strings.Contains(html, "/api/smtp/attachments/evt-uuid/preview/att-uuid-2") {
+ t.Error("missing banner preview URL")
+ }
+}
+
+func TestReplaceCIDReferences_NoCID(t *testing.T) {
+ // When there are no CID references, HTML should remain unchanged.
+ html := `No images
`
+ cidMap := map[string]string{}
+
+ if len(cidMap) > 0 {
+ for cid, url := range cidMap {
+ html = strings.ReplaceAll(html, "cid:"+cid, url)
+ }
+ }
+
+ if html != `No images
` {
+ t.Errorf("HTML was modified unexpectedly: %s", html)
+ }
+}
+
func TestPreviewMapper(t *testing.T) {
m := &previewMapper{}
From 2d414a52f7d2181bcde48ec2f7359e558cd3995c Mon Sep 17 00:00:00 2001
From: Thomas Rothe
Date: Fri, 14 Aug 2026 20:53:57 +0200
Subject: [PATCH 2/2] fix(smtp): correct expected ContentID in inline
attachment test
parseEmail already strips angle brackets from Content-ID at parse
time (handler.go:322), so the test's expected value was wrong.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01HTP5juELjuCdbVECrKVMSs
---
modules/smtp/handler_test.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/modules/smtp/handler_test.go b/modules/smtp/handler_test.go
index 439d11f..8102ce2 100644
--- a/modules/smtp/handler_test.go
+++ b/modules/smtp/handler_test.go
@@ -325,8 +325,8 @@ func TestParseEmail_InlineAttachmentWithCID(t *testing.T) {
if len(atts) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(atts))
}
- if atts[0].ContentID != "" {
- t.Errorf("ContentID = %q, want %q", atts[0].ContentID, "")
+ if atts[0].ContentID != "logo123@example.com" {
+ t.Errorf("ContentID = %q, want %q", atts[0].ContentID, "logo123@example.com")
}
if atts[0].Filename != "logo.png" {
t.Errorf("Filename = %q, want %q", atts[0].Filename, "logo.png")