Skip to content

Commit 553e4a7

Browse files
committed
Speed up Find Similar with precompute and inbox cache
POST /api/similar previously took many seconds because it re-fetched 1000 emails from JMAP on every call and ran O(N²) Levenshtein over email bodies that could be tens of thousands of characters. - Precompute normalized subject, sender, and body tokens once per email so the inner pairwise compare avoids re-normalizing and re-tokenizing the same strings. - Replace Levenshtein on bodies with Jaccard overlap of word tokens (length >= 3). O(L) per pair instead of O(L1*L2). - Short-circuit each weighted stage as soon as the partial score plus the maximum remaining contribution falls below the threshold. - Cache the 1000-email inbox fetch server-side for 60 seconds, with invalidation on archive/unarchive, so successive "Find Similar" and rapid "Archive & Find Next" calls don't re-pull from JMAP. BenchmarkFindSimilarEmails over a synthetic 1000-email corpus runs in ~4.3 ms. The public similarity API is unchanged and all existing tests pass; new tests cover Jaccard, tokenizeBody, cache hit, cache invalidation, TTL expiration, and the no-cache-on-error path.
1 parent a9c7e8e commit 553e4a7

6 files changed

Lines changed: 514 additions & 55 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,12 @@ mailboxzero/
8484
- **File:** `similarity.go`
8585
- **Purpose:** Advanced fuzzy matching for email similarity
8686
- **Algorithm:**
87-
- **Subject Similarity (40% weight):** Levenshtein distance with normalization
88-
- **Sender Similarity (40% weight):** Email address comparison
89-
- **Content Similarity (20% weight):** Body/preview text analysis
87+
- **Subject Similarity (40% weight):** Levenshtein distance over pre-normalized strings
88+
- **Sender Similarity (40% weight):** Levenshtein distance over pre-normalized sender email address
89+
- **Content Similarity (20% weight):** Jaccard similarity over normalized word tokens (length ≥ 3) extracted from the preview/body
9090
- **Features:**
91+
- Per-email features (`subjectNorm`, `senderNorm`, `bodyTokens`) precomputed once per call so the inner pairwise compare avoids re-normalizing or re-tokenizing the same strings
92+
- Threshold-aware short-circuit: each weighted stage bails out as soon as the partial score plus the maximum remaining contribution falls below `threshold`
9193
- String normalization (lowercase, punctuation removal)
9294
- Common word detection for similarity boosting
9395
- Configurable threshold matching
@@ -108,6 +110,7 @@ mailboxzero/
108110
- JSON API responses
109111
- Error handling and logging
110112
- Static file serving
113+
- Inbox cache for `/api/similar`: the 1000-email JMAP fetch is cached in-process for 60 seconds (`inboxCacheTTL`) and invalidated at the end of `handleArchive`/`handleUnarchive`, so successive "Find Similar" / "Archive & Find Next" calls don't re-pull from JMAP
111114

112115
#### 5. Frontend Interface (`web/`)
113116
- **Template:** `index.html` - Responsive dual-pane layout
@@ -247,7 +250,7 @@ go run main.go
247250
```
248251

249252
**Mock Mode Features:**
250-
- Uses realistic sample email data (40+ emails from various senders)
253+
- Uses realistic sample email data (~32–52 emails: 10 senders × 3–5 messages plus 2 unique)
251254
- No real JMAP connection required
252255
- Sample emails include groups of similar messages for testing similarity matching
253256
- Simulates archiving operations without affecting real emails

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ mock_mode: false # Set to true to use built-in sample data (no JMAP nee
143143
The application uses fuzzy matching with weighted scoring:
144144
145145
- **Subject Similarity** (40%): Compares email subjects using Levenshtein distance
146-
- **Sender Similarity** (40%): Compares sender email addresses
147-
- **Content Similarity** (20%): Compares email preview/body content
146+
- **Sender Similarity** (40%): Compares sender email addresses using Levenshtein distance
147+
- **Content Similarity** (20%): Jaccard overlap of normalized word tokens (length ≥ 3) extracted from the preview/body
148148
149149
Additional boosters:
150150
- Common words in subjects increase similarity

internal/server/server.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"log"
88
"net/http"
99
"strconv"
10+
"sync"
11+
"time"
1012

1113
"mailboxzero/internal/config"
1214
"mailboxzero/internal/jmap"
@@ -15,10 +17,16 @@ import (
1517
"github.com/gorilla/mux"
1618
)
1719

20+
const inboxCacheTTL = 60 * time.Second
21+
1822
type Server struct {
1923
config *config.Config
2024
jmapClient jmap.JMAPClient
2125
templates *template.Template
26+
27+
inboxMu sync.Mutex
28+
inboxCache []jmap.Email
29+
inboxCachedAt time.Time
2230
}
2331

2432
type PageData struct {
@@ -114,7 +122,7 @@ func (s *Server) handleFindSimilar(w http.ResponseWriter, r *http.Request) {
114122
return
115123
}
116124

117-
emails, err := s.jmapClient.GetInboxEmails(1000)
125+
emails, err := s.getInboxForSimilarity()
118126
if err != nil {
119127
http.Error(w, fmt.Sprintf("Failed to get emails: %v", err), http.StatusInternalServerError)
120128
return
@@ -167,6 +175,7 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
167175
http.Error(w, fmt.Sprintf("Failed to archive emails: %v", err), http.StatusInternalServerError)
168176
return
169177
}
178+
s.invalidateInboxCache()
170179

171180
response := map[string]interface{}{
172181
"success": true,
@@ -194,6 +203,7 @@ func (s *Server) handleUnarchive(w http.ResponseWriter, r *http.Request) {
194203
http.Error(w, fmt.Sprintf("Failed to unarchive emails: %v", err), http.StatusInternalServerError)
195204
return
196205
}
206+
s.invalidateInboxCache()
197207

198208
response := map[string]interface{}{
199209
"success": true,
@@ -209,3 +219,36 @@ func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) {
209219
w.Header().Set("Content-Type", "application/json")
210220
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
211221
}
222+
223+
// getInboxForSimilarity returns the cached inbox slice used by /api/similar.
224+
// The cache avoids re-fetching 1000 emails over JMAP on every click of "Find
225+
// Similar" or every iteration of the rapid "Archive & Find Next" sweep.
226+
// handleArchive and handleUnarchive invalidate it so the next call sees the
227+
// post-archive state.
228+
func (s *Server) getInboxForSimilarity() ([]jmap.Email, error) {
229+
s.inboxMu.Lock()
230+
if s.inboxCache != nil && time.Since(s.inboxCachedAt) < inboxCacheTTL {
231+
cached := s.inboxCache
232+
s.inboxMu.Unlock()
233+
return cached, nil
234+
}
235+
s.inboxMu.Unlock()
236+
237+
emails, err := s.jmapClient.GetInboxEmails(1000)
238+
if err != nil {
239+
return nil, err
240+
}
241+
242+
s.inboxMu.Lock()
243+
s.inboxCache = emails
244+
s.inboxCachedAt = time.Now()
245+
s.inboxMu.Unlock()
246+
return emails, nil
247+
}
248+
249+
func (s *Server) invalidateInboxCache() {
250+
s.inboxMu.Lock()
251+
s.inboxCache = nil
252+
s.inboxCachedAt = time.Time{}
253+
s.inboxMu.Unlock()
254+
}

internal/server/server_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package server
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"mailboxzero/internal/config"
78
"mailboxzero/internal/jmap"
89
"net/http"
910
"net/http/httptest"
1011
"os"
1112
"strings"
1213
"testing"
14+
"time"
1315
)
1416

1517
// setupTestServer creates a test server with mock JMAP client
@@ -826,3 +828,219 @@ func TestServer_ConfigValues(t *testing.T) {
826828
t.Errorf("Test server DefaultSimilarity = %v, want 75", server.config.DefaultSimilarity)
827829
}
828830
}
831+
832+
// countingJMAPClient wraps a JMAPClient and counts how many times the
833+
// full 1000-email inbox fetch used by handleFindSimilar is invoked.
834+
type countingJMAPClient struct {
835+
jmap.JMAPClient
836+
getInboxCalls int
837+
}
838+
839+
func (c *countingJMAPClient) GetInboxEmails(limit int) ([]jmap.Email, error) {
840+
c.getInboxCalls++
841+
return c.JMAPClient.GetInboxEmails(limit)
842+
}
843+
844+
func setupCountingTestServer(t *testing.T) (*Server, *countingJMAPClient) {
845+
t.Helper()
846+
847+
cfg := &config.Config{
848+
Server: struct {
849+
Port int `yaml:"port"`
850+
Host string `yaml:"host"`
851+
}{Port: 8080, Host: "localhost"},
852+
DryRun: true,
853+
DefaultSimilarity: 75,
854+
MockMode: true,
855+
}
856+
857+
counter := &countingJMAPClient{JMAPClient: jmap.NewMockClient()}
858+
859+
tmpDir := t.TempDir()
860+
templatePath := tmpDir + "/web/templates"
861+
if err := os.MkdirAll(templatePath, 0755); err != nil {
862+
t.Fatalf("Failed to create template directory: %v", err)
863+
}
864+
if err := os.WriteFile(templatePath+"/index.html", []byte("<html></html>"), 0644); err != nil {
865+
t.Fatalf("Failed to write template file: %v", err)
866+
}
867+
868+
oldWd, _ := os.Getwd()
869+
os.Chdir(tmpDir)
870+
t.Cleanup(func() { os.Chdir(oldWd) })
871+
872+
server, err := New(cfg, counter)
873+
if err != nil {
874+
t.Fatalf("Failed to create server: %v", err)
875+
}
876+
877+
return server, counter
878+
}
879+
880+
func TestHandleFindSimilar_CachesInbox(t *testing.T) {
881+
server, counter := setupCountingTestServer(t)
882+
883+
body, _ := json.Marshal(SimilarRequest{SimilarityThreshold: 75.0})
884+
for i := 0; i < 3; i++ {
885+
req := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
886+
req.Header.Set("Content-Type", "application/json")
887+
w := httptest.NewRecorder()
888+
server.handleFindSimilar(w, req)
889+
if w.Code != http.StatusOK {
890+
t.Fatalf("call %d: status = %v, want 200", i, w.Code)
891+
}
892+
}
893+
894+
if counter.getInboxCalls != 1 {
895+
t.Errorf("expected 1 inbox fetch across 3 calls, got %d", counter.getInboxCalls)
896+
}
897+
}
898+
899+
func TestHandleArchive_InvalidatesInboxCache(t *testing.T) {
900+
server, counter := setupCountingTestServer(t)
901+
902+
body, _ := json.Marshal(SimilarRequest{SimilarityThreshold: 75.0})
903+
req := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
904+
req.Header.Set("Content-Type", "application/json")
905+
server.handleFindSimilar(httptest.NewRecorder(), req)
906+
907+
if counter.getInboxCalls != 1 {
908+
t.Fatalf("setup: expected 1 inbox fetch, got %d", counter.getInboxCalls)
909+
}
910+
911+
// Archive an arbitrary id — under dry-run this is a no-op against the mock
912+
// but the handler still invalidates the cache.
913+
archiveBody, _ := json.Marshal(ArchiveRequest{EmailIDs: []string{"email-0-0"}})
914+
archiveReq := httptest.NewRequest("POST", "/api/archive", bytes.NewReader(archiveBody))
915+
archiveReq.Header.Set("Content-Type", "application/json")
916+
archiveW := httptest.NewRecorder()
917+
server.handleArchive(archiveW, archiveReq)
918+
if archiveW.Code != http.StatusOK {
919+
t.Fatalf("archive status = %v, want 200", archiveW.Code)
920+
}
921+
922+
// Next /api/similar should re-fetch.
923+
req2 := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
924+
req2.Header.Set("Content-Type", "application/json")
925+
server.handleFindSimilar(httptest.NewRecorder(), req2)
926+
927+
if counter.getInboxCalls != 2 {
928+
t.Errorf("expected 2 inbox fetches after archive, got %d", counter.getInboxCalls)
929+
}
930+
}
931+
932+
func TestGetInboxForSimilarity_TTLExpiration(t *testing.T) {
933+
server, counter := setupCountingTestServer(t)
934+
935+
body, _ := json.Marshal(SimilarRequest{SimilarityThreshold: 75.0})
936+
req := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
937+
req.Header.Set("Content-Type", "application/json")
938+
server.handleFindSimilar(httptest.NewRecorder(), req)
939+
940+
if counter.getInboxCalls != 1 {
941+
t.Fatalf("setup: expected 1 inbox fetch, got %d", counter.getInboxCalls)
942+
}
943+
944+
// Backdate the cache timestamp past the TTL window so the next call
945+
// must re-fetch instead of serving stale data indefinitely.
946+
server.inboxMu.Lock()
947+
server.inboxCachedAt = time.Now().Add(-2 * inboxCacheTTL)
948+
server.inboxMu.Unlock()
949+
950+
req2 := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
951+
req2.Header.Set("Content-Type", "application/json")
952+
server.handleFindSimilar(httptest.NewRecorder(), req2)
953+
954+
if counter.getInboxCalls != 2 {
955+
t.Errorf("expected 2 inbox fetches after TTL expiration, got %d", counter.getInboxCalls)
956+
}
957+
}
958+
959+
// erroringJMAPClient always fails on GetInboxEmails; the embedded JMAPClient
960+
// supplies stub implementations for every other interface method via the mock.
961+
type erroringJMAPClient struct {
962+
jmap.JMAPClient
963+
calls int
964+
err error
965+
}
966+
967+
func (c *erroringJMAPClient) GetInboxEmails(limit int) ([]jmap.Email, error) {
968+
c.calls++
969+
return nil, c.err
970+
}
971+
972+
func TestGetInboxForSimilarity_ErrorNotCached(t *testing.T) {
973+
cfg := &config.Config{
974+
Server: struct {
975+
Port int `yaml:"port"`
976+
Host string `yaml:"host"`
977+
}{Port: 8080, Host: "localhost"},
978+
DryRun: true,
979+
DefaultSimilarity: 75,
980+
MockMode: true,
981+
}
982+
983+
failing := &erroringJMAPClient{
984+
JMAPClient: jmap.NewMockClient(),
985+
err: errors.New("jmap transient failure"),
986+
}
987+
988+
tmpDir := t.TempDir()
989+
templatePath := tmpDir + "/web/templates"
990+
if err := os.MkdirAll(templatePath, 0755); err != nil {
991+
t.Fatalf("Failed to create template directory: %v", err)
992+
}
993+
if err := os.WriteFile(templatePath+"/index.html", []byte("<html></html>"), 0644); err != nil {
994+
t.Fatalf("Failed to write template file: %v", err)
995+
}
996+
997+
oldWd, _ := os.Getwd()
998+
os.Chdir(tmpDir)
999+
t.Cleanup(func() { os.Chdir(oldWd) })
1000+
1001+
server, err := New(cfg, failing)
1002+
if err != nil {
1003+
t.Fatalf("Failed to create server: %v", err)
1004+
}
1005+
1006+
body, _ := json.Marshal(SimilarRequest{SimilarityThreshold: 75.0})
1007+
1008+
// Both calls must propagate the JMAP error (HTTP 500) AND make their own
1009+
// underlying fetch. Caching a nil/empty result on error would poison the
1010+
// cache for the full TTL window after a single transient failure.
1011+
for i := 0; i < 2; i++ {
1012+
req := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
1013+
req.Header.Set("Content-Type", "application/json")
1014+
w := httptest.NewRecorder()
1015+
server.handleFindSimilar(w, req)
1016+
if w.Code != http.StatusInternalServerError {
1017+
t.Errorf("call %d: status = %v, want 500", i, w.Code)
1018+
}
1019+
}
1020+
1021+
if failing.calls != 2 {
1022+
t.Errorf("expected 2 underlying fetches across 2 failing /api/similar calls, got %d", failing.calls)
1023+
}
1024+
}
1025+
1026+
func TestHandleUnarchive_InvalidatesInboxCache(t *testing.T) {
1027+
server, counter := setupCountingTestServer(t)
1028+
1029+
body, _ := json.Marshal(SimilarRequest{SimilarityThreshold: 75.0})
1030+
req := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
1031+
req.Header.Set("Content-Type", "application/json")
1032+
server.handleFindSimilar(httptest.NewRecorder(), req)
1033+
1034+
unarchiveBody, _ := json.Marshal(ArchiveRequest{EmailIDs: []string{"email-0-0"}})
1035+
unarchiveReq := httptest.NewRequest("POST", "/api/unarchive", bytes.NewReader(unarchiveBody))
1036+
unarchiveReq.Header.Set("Content-Type", "application/json")
1037+
server.handleUnarchive(httptest.NewRecorder(), unarchiveReq)
1038+
1039+
req2 := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body))
1040+
req2.Header.Set("Content-Type", "application/json")
1041+
server.handleFindSimilar(httptest.NewRecorder(), req2)
1042+
1043+
if counter.getInboxCalls != 2 {
1044+
t.Errorf("expected 2 inbox fetches after unarchive, got %d", counter.getInboxCalls)
1045+
}
1046+
}

0 commit comments

Comments
 (0)