diff --git a/internal/mail/imap.go b/internal/mail/imap.go index 3536af5..6fe6f32 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -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() diff --git a/internal/mail/resolve_thread_test.go b/internal/mail/resolve_thread_test.go new file mode 100644 index 0000000..054c489 --- /dev/null +++ b/internal/mail/resolve_thread_test.go @@ -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 == "" { + return &database.Task{ThreadID: "thread-abc"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-2", "", "", 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 == "" { + return &database.Task{ThreadID: "thread-c"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-3", "", " ", 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 == "" { + return &database.Task{ThreadID: "thread-b"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-4", "", " ", 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", "", " ", 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 == "" { + return &database.Task{ThreadID: "thread-ref"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-6", "", "", 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", "", "", lookup) + if got != "msg-7" { + t.Fatalf("expected msg-7, got %s", got) + } +} + +var nilLookup = func(string) (*database.Task, error) { return nil, nil }