Add References header fallback for thread resolution #13
+40
-7
@@ -283,6 +283,39 @@ func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveThreadID determines the thread_id for a message.
|
||||
// It tries In-Reply-To first, then falls back to References (last-to-first).
|
||||
// Returns the message's own ID if no parent is found.
|
||||
func resolveThreadID(messageID, inReplyTo, references string, lookup func(string) (*database.Task, error)) string {
|
||||
if inReplyTo != "" {
|
||||
if parent, err := lookup(inReplyTo); err == nil && parent != nil {
|
||||
return parent.ThreadID
|
||||
}
|
||||
}
|
||||
|
||||
for _, refID := range parseReferences(references) {
|
||||
if parent, err := lookup(refID); err == nil && parent != nil {
|
||||
return parent.ThreadID
|
||||
}
|
||||
}
|
||||
|
||||
return messageID
|
||||
}
|
||||
|
||||
// parseReferences splits a References header value into individual message IDs,
|
||||
// returned in reverse order (most recent first).
|
||||
func parseReferences(header string) []string {
|
||||
if header == "" {
|
||||
return nil
|
||||
}
|
||||
raw := strings.Fields(header)
|
||||
// Reverse: last (most recent) first.
|
||||
for i, j := 0, len(raw)-1; i < j; i, j = i+1, j-1 {
|
||||
raw[i], raw[j] = raw[j], raw[i]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// processMessage handles a single fetched email message.
|
||||
func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.FetchMessageBuffer, bodySection *imap.FetchItemBodySection) {
|
||||
env := buf.Envelope
|
||||
@@ -333,6 +366,7 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
||||
// 4. Threading: determine thread_id.
|
||||
threadID := messageID
|
||||
inReplyTo := ""
|
||||
references := ""
|
||||
|
||||
// Parse body to get In-Reply-To / References headers and plain text.
|
||||
bodyPlain := ""
|
||||
@@ -340,10 +374,12 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
||||
if rawBody != nil {
|
||||
mr, err := gomessage.CreateReader(io.NopCloser(strings.NewReader(string(rawBody))))
|
||||
if err == nil {
|
||||
// Extract In-Reply-To header.
|
||||
if irt, err := mr.Header.Text("In-Reply-To"); err == nil && irt != "" {
|
||||
inReplyTo = strings.TrimSpace(irt)
|
||||
}
|
||||
if refs, err := mr.Header.Text("References"); err == nil && refs != "" {
|
||||
references = refs
|
||||
}
|
||||
|
||||
// Read body parts for plain text.
|
||||
for {
|
||||
@@ -361,12 +397,9 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
||||
}
|
||||
}
|
||||
|
||||
if inReplyTo != "" {
|
||||
parent, err := database.FindByMessageID(w.db, inReplyTo)
|
||||
if err == nil && parent != nil {
|
||||
threadID = parent.ThreadID
|
||||
}
|
||||
}
|
||||
threadID = resolveThreadID(messageID, inReplyTo, references, func(mid string) (*database.Task, error) {
|
||||
return database.FindByMessageID(w.db, mid)
|
||||
})
|
||||
|
||||
// 5. Save task with status RECEIVED.
|
||||
taskUUID := uuid.New().String()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
)
|
||||
|
||||
func TestResolveThreadID_NoHeaders_ReturnsMessageID(t *testing.T) {
|
||||
got := resolveThreadID("msg-1", "", "", nilLookup)
|
||||
if got != "msg-1" {
|
||||
t.Fatalf("expected msg-1, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_InReplyToMatches_ReturnsParentThreadID(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<parent>" {
|
||||
return &database.Task{ThreadID: "thread-abc"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-2", "<parent>", "", lookup)
|
||||
if got != "thread-abc" {
|
||||
t.Fatalf("expected thread-abc, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_ReferencesOnly_LastIDMatches(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<C>" {
|
||||
return &database.Task{ThreadID: "thread-c"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-3", "", "<A> <B> <C>", lookup)
|
||||
if got != "thread-c" {
|
||||
t.Fatalf("expected thread-c, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_ReferencesOnly_MiddleIDMatches(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<B>" {
|
||||
return &database.Task{ThreadID: "thread-b"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-4", "", "<A> <B> <C>", lookup)
|
||||
if got != "thread-b" {
|
||||
t.Fatalf("expected thread-b, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_ReferencesOnly_NoMatch_ReturnsMessageID(t *testing.T) {
|
||||
got := resolveThreadID("msg-5", "", "<X> <Y>", nilLookup)
|
||||
if got != "msg-5" {
|
||||
t.Fatalf("expected msg-5, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_InReplyToMiss_ReferencesMatches(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<ref>" {
|
||||
return &database.Task{ThreadID: "thread-ref"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-6", "<unknown>", "<ref>", lookup)
|
||||
if got != "thread-ref" {
|
||||
t.Fatalf("expected thread-ref, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_LookupError_ReturnsMessageID(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
return nil, errors.New("db error")
|
||||
}
|
||||
got := resolveThreadID("msg-7", "<parent>", "", lookup)
|
||||
if got != "msg-7" {
|
||||
t.Fatalf("expected msg-7, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
var nilLookup = func(string) (*database.Task, error) { return nil, nil }
|
||||
Reference in New Issue
Block a user