Skip to content

Commit 2ad7131

Browse files
wesmclaude
andcommitted
Fix In-Reply-To multi-ID parsing, duplicate labels, and resume message
deriveThreadKey: parse In-Reply-To as a msg-id list per RFC 2822 instead of trimming the whole header string. Multi-ID headers like "<a@x> <b@x>" now correctly extract the first ID instead of producing malformed keys like "a@x> <b@x". Label merge: exclude the current mailbox when appending from msgIDToLabels to avoid duplicate label IDs that violate the message_labels primary key constraint. Trash/Spam-only messages on Gmail would fail ingest because the mailbox appeared twice. Resume message: tell IMAP users that the next run restarts from the beginning (skipping already-imported messages) instead of the misleading "Run again to resume." Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 36ad57a commit 2ad7131

File tree

4 files changed

+41
-13
lines changed

4 files changed

+41
-13
lines changed

cmd/msgvault/cmd/syncfull.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,11 @@ func runFullSync(ctx context.Context, s *store.Store, oauthMgr *oauth.Manager, s
278278
summary, err := syncer.Full(ctx, src.Identifier)
279279
if err != nil {
280280
if ctx.Err() != nil {
281-
fmt.Println("\nSync interrupted. Run again to resume.")
281+
if opts.NoResume {
282+
fmt.Println("\nSync interrupted. Run again to restart (already-imported messages will be skipped).")
283+
} else {
284+
fmt.Println("\nSync interrupted. Run again to resume.")
285+
}
282286
return nil
283287
}
284288
return fmt.Errorf("sync failed: %w", err)

internal/imap/client.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -777,12 +777,18 @@ func (c *Client) GetMessagesRawBatch(ctx context.Context, messageIDs []string) (
777777
// Merge labels from other mailboxes via the
778778
// label map built during listing. The map keys
779779
// on RFC822 Message-ID and maps to the other
780-
// mailbox names the message appears in.
780+
// mailbox names the message appears in. Skip the
781+
// current mailbox to avoid duplicates that would
782+
// violate the message_labels primary key.
781783
if c.msgIDToLabels != nil &&
782784
msgBuf.Envelope != nil &&
783785
msgBuf.Envelope.MessageID != "" {
784786
if extra, ok := c.msgIDToLabels[msgBuf.Envelope.MessageID]; ok {
785-
labels = append(labels, extra...)
787+
for _, lbl := range extra {
788+
if lbl != mailbox {
789+
labels = append(labels, lbl)
790+
}
791+
}
786792
}
787793
}
788794

internal/sync/sync.go

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -716,27 +716,40 @@ func joinEmails(addrs []mime.Address) string {
716716
// replies, then to the message's own Message-ID. Returns "" when
717717
// no threading info is available.
718718
//
719-
// Angle brackets are stripped for consistency: parseReferences
720-
// already strips them, but InReplyTo/MessageID retain them from
721-
// the raw header. Without normalization the root message (using
722-
// MessageID "<X>") and its replies (using References "X") would
723-
// get different thread keys.
719+
// InReplyTo is parsed as a msg-id list per RFC 2822 (it may
720+
// contain multiple IDs and comments); only the first valid ID
721+
// is used. Angle brackets are stripped for consistency with
722+
// parseReferences.
724723
func deriveThreadKey(parsed *mime.Message) string {
725724
if len(parsed.References) > 0 {
726725
return parsed.References[0]
727726
}
728727
if parsed.InReplyTo != "" {
729-
return stripAngleBrackets(parsed.InReplyTo)
728+
if ids := parseMsgIDList(parsed.InReplyTo); len(ids) > 0 {
729+
return ids[0]
730+
}
730731
}
731732
if parsed.MessageID != "" {
732-
return stripAngleBrackets(parsed.MessageID)
733+
if ids := parseMsgIDList(parsed.MessageID); len(ids) > 0 {
734+
return ids[0]
735+
}
733736
}
734737
return ""
735738
}
736739

737-
// stripAngleBrackets removes surrounding < > from a message ID.
738-
func stripAngleBrackets(s string) string {
739-
return strings.Trim(s, "<>")
740+
// parseMsgIDList splits a header value containing one or more
741+
// angle-bracketed message-IDs (as in References or In-Reply-To)
742+
// and returns them with brackets stripped. Tokens without angle
743+
// brackets are ignored per RFC 2822 msg-id syntax.
744+
func parseMsgIDList(s string) []string {
745+
var result []string
746+
for _, tok := range strings.Fields(s) {
747+
tok = strings.Trim(tok, "<>")
748+
if tok != "" {
749+
result = append(result, tok)
750+
}
751+
}
752+
return result
740753
}
741754

742755
// extractSubjectFromSnippet attempts to extract a subject from the message snippet.

internal/sync/sync_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,11 @@ func TestDeriveThreadKey(t *testing.T) {
15761576
msg: &mime.Message{MessageID: "<self@ex>"},
15771577
wantKey: "self@ex",
15781578
},
1579+
{
1580+
name: "Multi-ID InReplyTo uses first entry",
1581+
msg: &mime.Message{InReplyTo: "<a@ex> <b@ex>", MessageID: "<self@ex>"},
1582+
wantKey: "a@ex",
1583+
},
15791584
{
15801585
name: "Empty when no threading info",
15811586
msg: &mime.Message{},

0 commit comments

Comments
 (0)