diff --git a/cmd/messages.go b/cmd/messages.go
index 22b6fcab..6acd2b48 100644
--- a/cmd/messages.go
+++ b/cmd/messages.go
@@ -71,7 +71,8 @@ func handleGetMessages(r *fastglue.Request) error {
att := messages[i].Attachments[j]
messages[i].Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name)
}
- resolveContentCIDs(&messages[i], rootURL)
+ resolveQuotedCIDs(app, &messages[i])
+ resolveAttachmentCIDs(&messages[i], rootURL)
}
// Process CSAT status for all messages (will only affect CSAT messages)
@@ -134,7 +135,8 @@ func handleGetMessage(r *fastglue.Request) error {
att := message.Attachments[j]
message.Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name)
}
- resolveContentCIDs(&message, rootURL)
+ resolveQuotedCIDs(app, &message)
+ resolveAttachmentCIDs(&message, rootURL)
return r.SendEnvelope(message)
}
@@ -269,9 +271,9 @@ func handleSendMessage(r *fastglue.Request) error {
return r.SendEnvelope(message)
}
-// resolveContentCIDs replaces inline image cid: references in email message content
+// resolveAttachmentCIDs replaces inline image cid: references in email message content
// with actual attachment URLs and resolves relative /uploads/ paths to absolute URLs.
-func resolveContentCIDs(msg *cmodels.Message, rootURL string) {
+func resolveAttachmentCIDs(msg *cmodels.Message, rootURL string) {
for _, att := range msg.Attachments {
if att.ContentID != "" && att.URL != "" {
msg.Content = strings.ReplaceAll(msg.Content, "cid:"+att.ContentID, att.URL)
@@ -282,3 +284,16 @@ func resolveContentCIDs(msg *cmodels.Message, rootURL string) {
msg.Content = strings.ReplaceAll(msg.Content, `src='/uploads/`, `src='`+rootURL+`/uploads/`)
}
}
+
+// resolveQuotedCIDs replaces cid: refs to media on other messages with signed URLs.
+func resolveQuotedCIDs(app *App, msg *cmodels.Message) {
+ refs, err := app.conversation.GetInlineMediaRefs(msg)
+ if err != nil {
+ app.lo.Error("error fetching inline media refs", "conversation_uuid", msg.ConversationUUID, "error", err)
+ return
+ }
+ for _, ref := range refs {
+ url := app.media.GetURL(ref.UUID, ref.ContentType, ref.Filename)
+ msg.Content = strings.ReplaceAll(msg.Content, "cid:"+ref.ContentID, url)
+ }
+}
diff --git a/cmd/messages_test.go b/cmd/messages_test.go
index a41c905c..48674d09 100644
--- a/cmd/messages_test.go
+++ b/cmd/messages_test.go
@@ -7,7 +7,7 @@ import (
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
)
-func TestResolveContentCIDs(t *testing.T) {
+func TestResolveAttachmentCIDs(t *testing.T) {
tests := []struct {
name string
msg cmodels.Message
@@ -270,9 +270,9 @@ func TestResolveContentCIDs(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- resolveContentCIDs(&tt.msg, tt.rootURL)
+ resolveAttachmentCIDs(&tt.msg, tt.rootURL)
if tt.msg.Content != tt.want {
- t.Errorf("resolveContentCIDs()\n got = %s\n want = %s", tt.msg.Content, tt.want)
+ t.Errorf("resolveAttachmentCIDs()\n got = %s\n want = %s", tt.msg.Content, tt.want)
}
})
}
diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go
index 931750da..8f6b0c2d 100644
--- a/internal/conversation/conversation.go
+++ b/internal/conversation/conversation.go
@@ -140,6 +140,7 @@ type mediaStore interface {
Attach(id int, model string, modelID int) error
SetContentID(id int, contentID string) error
GetByModel(id int, model string) ([]mmodels.Media, error)
+ GetByContentIDs(contentIDs []string, conversationUUID string) ([]mmodels.Media, error)
ContentIDExists(contentID string) (bool, string, error)
Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error)
UploadAndInsert(fileName, contentType, contentID string, modelType null.String, modelID null.Int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (mmodels.Media, error)
diff --git a/internal/conversation/message.go b/internal/conversation/message.go
index 2f8edef1..9a24922a 100644
--- a/internal/conversation/message.go
+++ b/internal/conversation/message.go
@@ -1005,6 +1005,26 @@ func extractInlineImageUUIDs(content string) []string {
return out
}
+// extractInlineContentIDs returns unique content_ids referenced via
in the body.
+func extractInlineContentIDs(content string) []string {
+ matches := imgSrcPattern.FindAllStringSubmatch(content, -1)
+ seen := make(map[string]bool, len(matches))
+ out := make([]string, 0, len(matches))
+ for _, m := range matches {
+ url := m[1]
+ if !strings.HasPrefix(url, "cid:") {
+ continue
+ }
+ cid := strings.TrimPrefix(url, "cid:")
+ if cid == "" || seen[cid] {
+ continue
+ }
+ seen[cid] = true
+ out = append(out, cid)
+ }
+ return out
+}
+
// rewriteInlineImagesToCID rewrites every
to
. Already-cid form is left alone.
func rewriteInlineImagesToCID(content string) string {
return imgSrcPattern.ReplaceAllStringFunc(content, func(match string) string {
@@ -1061,35 +1081,26 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error {
}
for _, attachment := range message.Attachments {
- // Check if this attachment already exists by the content ID, as inline images can be repeated across conversations.
contentID := attachment.ContentID
if contentID != "" {
- // Make content ID MORE unique by prefixing it with the conversation UUID, as content id is not globally unique practically,
- // different messages can have the same content ID, I do not have the message ID at this point, so I am using sticking with the conversation UUID
- // to make it more unique.
- contentID = message.ConversationUUID + "_" + contentID
+ storedCID, exists, mediaUUID := m.findExistingMedia(contentID, message.ConversationUUID)
- exists, uuid, err := m.mediaStore.ContentIDExists(contentID)
- if err != nil {
- m.lo.Error("error checking media existence by content ID", "content_id", contentID, "error", err)
+ // Make body's cid match the stored content_id so the read path can find it.
+ if storedCID != contentID {
+ message.Content = strings.ReplaceAll(message.Content, fmt.Sprintf("cid:%s", contentID), fmt.Sprintf("cid:%s", storedCID))
}
- // This attachment already exists, replace the cid:content_id with the media relative url, not using absolute path as the root path can change.
if exists {
- m.lo.Debug("attachment with content ID already exists replacing content ID with media relative URL", "content_id", contentID, "media_uuid", uuid)
- message.Content = strings.ReplaceAll(message.Content, fmt.Sprintf("cid:%s", attachment.ContentID), "/uploads/"+uuid)
+ m.lo.Debug("inline attachment exists, reusing", "content_id", storedCID, "media_uuid", mediaUUID)
continue
}
-
- // Attachment does not exist, replace the content ID with the new more unique content ID.
- message.Content = strings.ReplaceAll(message.Content, fmt.Sprintf("cid:%s", attachment.ContentID), fmt.Sprintf("cid:%s", contentID))
+ contentID = storedCID
}
// Sanitize filename.
attachment.Name = stringutil.SanitizeFilename(attachment.Name)
- m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", contentID, "size", attachment.Size, "content_type", attachment.ContentType,
- "content_id", contentID, "disposition", attachment.Disposition)
+ m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", contentID, "size", attachment.Size, "content_type", attachment.ContentType, "disposition", attachment.Disposition)
// Upload and insert entry in media table.
attachReader := bytes.NewReader(attachment.Content)
@@ -1187,6 +1198,30 @@ func (m *Manager) messageExistsBySourceID(messageSourceIDs []string) (int, error
return conversationID, nil
}
+// GetInlineMediaRefs returns media referenced via cid: in the body but linked to other messages (quoted history).
+func (m *Manager) GetInlineMediaRefs(message *models.Message) ([]mmodels.Media, error) {
+ cids := extractInlineContentIDs(message.Content)
+ if len(cids) == 0 {
+ return nil, nil
+ }
+ existing := make(map[string]bool, len(message.Attachments))
+ for _, a := range message.Attachments {
+ if a.ContentID != "" {
+ existing[a.ContentID] = true
+ }
+ }
+ missing := make([]string, 0, len(cids))
+ for _, cid := range cids {
+ if !existing[cid] {
+ missing = append(missing, cid)
+ }
+ }
+ if len(missing) == 0 {
+ return nil, nil
+ }
+ return m.mediaStore.GetByContentIDs(missing, message.ConversationUUID)
+}
+
// fetchMessageAttachments fetches attachments (also inline images) for a single message ID.
func (m *Manager) fetchMessageAttachments(messageID int) (attachment.Attachments, error) {
var attachments attachment.Attachments
@@ -1392,3 +1427,16 @@ func (m *Manager) getMediaPreview(media mmodels.Media) string {
func inlineContentID(uuid string) string {
return "ldsk-" + uuid
}
+
+// findExistingMedia resolves an inbound cid to its stored form: ldsk-* is left as-is, others are namespaced by conversation to avoid cross-conversation collisions.
+func (m *Manager) findExistingMedia(rawContentID, conversationUUID string) (string, bool, string) {
+ storedCID := rawContentID
+ if !strings.HasPrefix(rawContentID, "ldsk-") {
+ storedCID = conversationUUID + "_" + rawContentID
+ }
+ exists, mediaUUID, err := m.mediaStore.ContentIDExists(storedCID)
+ if err != nil {
+ m.lo.Error("error checking media existence by content ID", "content_id", storedCID, "error", err)
+ }
+ return storedCID, exists, mediaUUID
+}
diff --git a/internal/conversation/message_test.go b/internal/conversation/message_test.go
index d796ced3..f94eb23c 100644
--- a/internal/conversation/message_test.go
+++ b/internal/conversation/message_test.go
@@ -397,6 +397,74 @@ func TestExtractInlineImageUUIDs(t *testing.T) {
}
}
+func TestExtractInlineContentIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want []string
+ }{
+ {
+ name: "empty_body",
+ body: "",
+ want: []string{},
+ },
+ {
+ name: "ignores_non_cid_src",
+ body: `
`,
+ want: []string{},
+ },
+ {
+ name: "single_cid_extracted",
+ body: `
`,
+ want: []string{"ldsk-" + testUUID},
+ },
+ {
+ name: "mixed_cid_and_url_returns_only_cids",
+ body: `
`,
+ want: []string{"ldsk-" + testUUID2},
+ },
+ {
+ name: "single_quotes_around_src",
+ body: `
`,
+ want: []string{"ldsk-" + testUUID},
+ },
+ {
+ name: "empty_cid_skipped",
+ body: `
`,
+ want: []string{},
+ },
+ {
+ name: "multi_with_dedup_and_order",
+ body: `

`,
+ want: []string{"ldsk-" + testUUID2, "ldsk-" + testUUID},
+ },
+ {
+ name: "src_after_other_attributes",
+ body: `
`,
+ want: []string{"ldsk-" + testUUID},
+ },
+ {
+ name: "uppercase_cid_prefix_not_matched",
+ body: `
`,
+ want: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := extractInlineContentIDs(tt.body)
+ if len(got) != len(tt.want) {
+ t.Fatalf("got %v, want %v", got, tt.want)
+ }
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("index %d: got %q, want %q", i, got[i], tt.want[i])
+ }
+ }
+ })
+ }
+}
+
func TestRewriteInlineImagesToCID(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/media/media.go b/internal/media/media.go
index 8f1e8b0d..baaf22e5 100644
--- a/internal/media/media.go
+++ b/internal/media/media.go
@@ -21,6 +21,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
+ "github.com/lib/pq"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
@@ -88,6 +89,7 @@ type queries struct {
GetByModel *sqlx.Stmt `query:"get-model-media"`
GetUnlinkedMessageMedia *sqlx.Stmt `query:"get-unlinked-message-media"`
ContentIDExists *sqlx.Stmt `query:"content-id-exists"`
+ GetByContentIDs *sqlx.Stmt `query:"get-media-by-content-ids"`
SetContentID *sqlx.Stmt `query:"set-media-content-id"`
}
@@ -191,6 +193,19 @@ func (m *Manager) ContentIDExists(contentID string) (bool, string, error) {
return true, uuid, nil
}
+// GetByContentIDs returns media rows matching any of the given content_ids, scoped to the given conversation to prevent cross-conversation lookup.
+func (m *Manager) GetByContentIDs(contentIDs []string, conversationUUID string) ([]models.Media, error) {
+ out := []models.Media{}
+ if len(contentIDs) == 0 || conversationUUID == "" {
+ return out, nil
+ }
+ if err := m.queries.GetByContentIDs.Select(&out, pq.Array(contentIDs), conversationUUID); err != nil {
+ m.lo.Error("error fetching media by content_ids", "error", err)
+ return nil, fmt.Errorf("fetching media by content_ids: %w", err)
+ }
+ return out, nil
+}
+
// GetBlob retrieves the raw binary content of a media file by its name.
func (m *Manager) GetBlob(name string) ([]byte, error) {
return m.store.GetBlob(name)
diff --git a/internal/media/queries.sql b/internal/media/queries.sql
index b3e8c1cb..2abdecdc 100644
--- a/internal/media/queries.sql
+++ b/internal/media/queries.sql
@@ -53,6 +53,14 @@ WHERE model_type = 'messages'
-- name: content-id-exists
SELECT uuid FROM media WHERE content_id = $1;
+-- name: get-media-by-content-ids
+SELECT m.id, m.created_at, m.updated_at, m."uuid", m.store, m.filename, m.content_type, m.content_id, m.model_id, m.model_type, m.disposition, m."size", m.meta
+FROM media m
+INNER JOIN conversation_messages cm ON cm.id = m.model_id
+WHERE m.model_type = 'messages'
+ AND m.content_id = ANY($1)
+ AND cm.conversation_id = (SELECT id FROM conversations WHERE uuid = $2::uuid LIMIT 1);
+
-- name: set-media-content-id
UPDATE media
SET content_id = $2