fix: JMAP fetching and moving (#1446)

masukomi (a.k.a. Kay Rhodes) created

## What?
in `jmap.go`

- added a `resolveUID` function
- added a `resolveUIDByQuery` function

## Why?
Email body fetch always failed with "jmap: no cached ID"
(backend/jmap/jmap.go)
FetchEmailBody relied on p.idToJMAPID being pre-populated by FetchEmails
on the same Provider instance. However, the fetcher layer creates a
fresh Provider for every call, so the map is always empty when
FetchEmailBody runs.

The fix adds a resolveUID method that checks the in-memory cache first
(fast path when the daemon reuses the same instance), then falls back to
resolveUIDByQuery. The fallback issues an Email/query for the folder,
reads the JMAP string IDs directly from QueryResponse.IDs, hashes each
one with FNV-32a, and returns the match — also warming the cache as a
side effect.

Change summary

backend/jmap/jmap.go    | 73 +++++++++++++++++++++++++++++++++++-------
daemonclient/service.go |  2 +
2 files changed, 63 insertions(+), 12 deletions(-)

Detailed changes

backend/jmap/jmap.go 🔗

@@ -282,8 +282,8 @@ func searchLimit(query backend.SearchQuery) uint32 {
 	return 100
 }
 
-func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, string, []backend.Attachment, error) {
-	jmapID, err := p.lookupJMAPID(uid)
+func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, string, []backend.Attachment, error) {
+	jmapID, err := p.resolveUID(folder, uid)
 	if err != nil {
 		return "", "", nil, err
 	}
@@ -362,8 +362,8 @@ func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID
 	return io.ReadAll(reader)
 }
 
-func (p *Provider) MarkAsRead(_ context.Context, _ string, uid uint32) error {
-	jmapID, err := p.lookupJMAPID(uid)
+func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) error {
+	jmapID, err := p.resolveUID(folder, uid)
 	if err != nil {
 		return err
 	}
@@ -380,8 +380,8 @@ func (p *Provider) MarkAsRead(_ context.Context, _ string, uid uint32) error {
 	return err
 }
 
-func (p *Provider) MarkAsUnread(_ context.Context, _ string, uid uint32) error {
-	jmapID, err := p.lookupJMAPID(uid)
+func (p *Provider) MarkAsUnread(_ context.Context, folder string, uid uint32) error {
+	jmapID, err := p.resolveUID(folder, uid)
 	if err != nil {
 		return err
 	}
@@ -398,8 +398,8 @@ func (p *Provider) MarkAsUnread(_ context.Context, _ string, uid uint32) error {
 	return err
 }
 
-func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
-	jmapID, err := p.lookupJMAPID(uid)
+func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error {
+	jmapID, err := p.resolveUID(folder, uid)
 	if err != nil {
 		return err
 	}
@@ -428,8 +428,8 @@ func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
 	return err
 }
 
-func (p *Provider) ArchiveEmail(_ context.Context, _ string, uid uint32) error {
-	jmapID, err := p.lookupJMAPID(uid)
+func (p *Provider) ArchiveEmail(_ context.Context, folder string, uid uint32) error {
+	jmapID, err := p.resolveUID(folder, uid)
 	if err != nil {
 		return err
 	}
@@ -450,8 +450,8 @@ func (p *Provider) ArchiveEmail(_ context.Context, _ string, uid uint32) error {
 	return err
 }
 
-func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string) error {
-	jmapID, err := p.lookupJMAPID(uid)
+func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder string) error {
+	jmapID, err := p.resolveUID(srcFolder, uid)
 	if err != nil {
 		return err
 	}
@@ -678,6 +678,55 @@ func (p *Provider) Close() error {
 // Verify interface compliance at compile time.
 var _ backend.Provider = (*Provider)(nil)
 
+// resolveUID returns the JMAP ID for the given uint32 UID. It checks the
+// in-memory cache first (fast path when FetchEmails ran on the same instance),
+// then falls back to querying the mailbox so the backend works correctly even
+// when a fresh Provider instance is created per call.
+func (p *Provider) resolveUID(folder string, uid uint32) (jmapclient.ID, error) {
+	if id, err := p.lookupJMAPID(uid); err == nil {
+		return id, nil
+	}
+	return p.resolveUIDByQuery(folder, uid)
+}
+
+// resolveUIDByQuery fetches all email IDs in the folder from JMAP via
+// Email/query, hashes each one, and returns the ID whose hash matches uid.
+// It also warms the local cache as a side effect.
+func (p *Provider) resolveUIDByQuery(folder string, uid uint32) (jmapclient.ID, error) {
+	mboxID, err := p.resolveMailboxID(folder)
+	if err != nil {
+		return "", fmt.Errorf("jmap: resolving mailbox for UID lookup: %w", err)
+	}
+
+	req := &jmapclient.Request{}
+	req.Invoke(&email.Query{
+		Account: p.accountID,
+		Filter:  &email.FilterCondition{InMailbox: mboxID},
+		Limit:   10000,
+	})
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("jmap: querying IDs for UID lookup: %w", err)
+	}
+
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	for _, inv := range resp.Responses {
+		if r, ok := inv.Args.(*email.QueryResponse); ok {
+			for _, id := range r.IDs {
+				h := jmapIDToUID(id)
+				p.idToJMAPID[h] = id
+				if h == uid {
+					return id, nil
+				}
+			}
+		}
+	}
+	return "", fmt.Errorf("jmap: no email found for UID %d in folder %q", uid, folder)
+}
+
 // lookupJMAPID resolves a uint32 UID hash back to the JMAP string ID.
 func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) {
 	p.mu.Lock()

daemonclient/service.go 🔗

@@ -8,6 +8,8 @@ import (
 	"time"
 
 	"github.com/floatpane/matcha/backend"
+	_ "github.com/floatpane/matcha/backend/jmap"    // register jmap backend for directService
+	_ "github.com/floatpane/matcha/backend/maildir" // register maildir backend for directService
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/daemonrpc"
 )