Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion app/spec/stores/contact-store-spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,48 @@
import _ from 'underscore';
import { Thread } from '../../src/flux/models/thread';
import { Contact } from '../../src/flux/models/contact';
import ContactStore from '../../src/flux/stores/contact-store';
import ContactStore, {
contactSearchFetchLimit,
contactsMatchingEmailPrefix,
prioritizeContactsMatchingEmailPrefix,
} from '../../src/flux/stores/contact-store';

describe('contactSearchFetchLimit', () => {
it('still fetches contacts when no legacy account rows are loaded', () => {
expect(contactSearchFetchLimit(5, 0)).toBe(5);
});

it('allows room to deduplicate contacts from multiple accounts', () => {
Comment on lines +7 to +15

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions restate the one-line implementation of contactSearchFetchLimit rather than exercising the behavior that regressed, so they wouldn't catch a recurrence of the reported bug.

The ContactStore suite below is still xdescribe'd, so searchContacts itself has no coverage at all. If you're up for it, re-enabling that suite and adding a case for your scenario — a support@… contact that should be returned for the query support — would be far more valuable than the helper tests, and would give us a failing test to confirm the root cause against.


Generated by Claude Code

expect(contactSearchFetchLimit(5, 3)).toBe(15);
});
});

describe('recipient autocomplete helpers', () => {
it('extracts distinct email-prefix matches from recent sent threads', () => {
const ovotrack = new Contact({ name: 'Marco Harmsen', email: 'support@ovotrack.nl' });
const compass = new Contact({
name: 'Compass Foundation, LLC',
email: 'support@compassfoundation.io',
});
const namedSupport = new Contact({ name: 'Product Support', email: 'help@example.com' });
const threads = [
new Thread({ participants: [ovotrack, namedSupport] }),
new Thread({ participants: [compass, ovotrack] }),
];

expect(contactsMatchingEmailPrefix(threads, 'SUPPORT@')).toEqual([ovotrack, compass]);
});

it('ranks email-prefix matches ahead of display-name-only matches', () => {
const namedSupport = new Contact({ name: 'Microsoft Support', email: 'mscsup9@microsoft.com' });
const addressMatch = new Contact({ name: 'Marco Harmsen', email: 'support@ovotrack.nl' });

expect(prioritizeContactsMatchingEmailPrefix([namedSupport, addressMatch], 'support')).toEqual([
addressMatch,
namedSupport,
]);
});
});

xdescribe('ContactStore', function () {
beforeEach(function () {
Expand Down
4 changes: 2 additions & 2 deletions app/src/components/participants-text-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export default class ParticipantsTextField extends React.Component<ParticipantsT
const CustomComponent = p.customComponent;
if (CustomComponent) return <CustomComponent token={p} />;
if (p instanceof Contact) {
return <Menu.NameEmailContent name={p.fullName()} email={p.email} key={p.id} />;
return <Menu.NameEmailContent name={p.fullName()} email={p.email} key={p.id || p.email} />;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you say what this fixes? I'd guess contacts synthesized from thread participants sometimes lack an id, in which case the fallback makes sense — but it'd be worth a short comment, or splitting it into its own commit, so it doesn't read as unrelated.


Generated by Claude Code

} else if (p instanceof ContactGroup) {
return p.name;
}
Expand Down Expand Up @@ -234,7 +234,7 @@ export default class ParticipantsTextField extends React.Component<ParticipantsT
ContactStore.searchContactGroups(input),
ContactStore.searchContacts(input),
])
).flat()
).flat() as Contact[]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cast isn't accurate — the array really does contain ContactGroup instances, since searchContactGroups results are flattened in alongside the contacts (and the p instanceof ContactGroup branch on line 73 depends on that). Asserting Contact[] here discards type information that the rest of the component relies on rather than fixing a type error.

If searchContacts becoming async broke inference here, (Contact | ContactGroup)[] would be the honest annotation.


Generated by Claude Code

}
shouldBreakOnKeydown={this._shouldBreakOnKeydown}
onInputTrySubmit={this._onInputTrySubmit}
Expand Down
97 changes: 82 additions & 15 deletions app/src/flux/stores/contact-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,44 @@ import DatabaseStore from './database-store';
import { AccountStore } from './account-store';
import ComponentRegistry from '../../registries/component-registry';
import { ContactGroup } from 'mailspring-exports';
import { Thread } from '../models/thread';
import {
SearchQueryToken,
TextQueryExpression,
ToQueryExpression,
} from '../../services/search/search-query-ast';

export const contactSearchFetchLimit = (limit: number, accountCount: number) =>
limit * Math.max(accountCount, 1);
Comment on lines +15 to +16

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is reasonable defensively, but I don't think accountCount === 0 is reachable in a way that would cause the symptom you hit.

AccountStore._accounts is populated synchronously in the store's constructor from AppEnv.config.get('accounts') (app/src/flux/stores/account-store.ts:78) — it reads from config.json, not from the database, and it happens in every window before any store consumer runs. There's no asynchronous "Account row" load that could leave the array empty while contacts exist. accounts() is only empty when the user genuinely has zero linked accounts, in which case the Contact table is empty too and there's no composer to autocomplete in.

A useful cross-check: if LIMIT 0 were the cause, topContacts() would be equally broken for every user, since it does the same multiplication — and it isn't.

So I'd expect this change to be a no-op for the bug you reported. Happy to be proven wrong if you're seeing accountCount === 0 in practice — if so, could you share how you confirmed it (e.g. AccountStore.accounts() in the dev tools console at the moment autocomplete fails)? That'd be a significant bug in its own right.


Generated by Claude Code


export const contactsMatchingEmailPrefix = (threads: Thread[], _search: string) => {
const search = _search.trim().toLowerCase();
const byEmail = new Map<string, Contact>();

for (const thread of threads) {
for (const participant of thread.participants || []) {
const email = (participant.email || '').trim().toLowerCase();
if (email.startsWith(search) && !byEmail.has(email)) {
byEmail.set(email, participant);
}
}
}

return Array.from(byEmail.values());
};

export const prioritizeContactsMatchingEmailPrefix = (contacts: Contact[], _search: string) => {
const search = _search.trim().toLowerCase();
const emailMatches: Contact[] = [];
const otherMatches: Contact[] = [];

for (const contact of contacts) {
const email = (contact.email || '').trim().toLowerCase();
(email.startsWith(search) ? emailMatches : otherMatches).push(contact);
}

return emailMatches.concat(otherMatches);
};

/**
Public: ContactStore provides convenience methods for searching contacts and
Expand Down Expand Up @@ -36,17 +74,21 @@ class ContactStore extends MailspringStore {
//
// Returns an {Array} of matching {Contact} models
//
searchContacts(_search: string, options: { limit?: number } = {}) {
async searchContacts(_search: string, options: { limit?: number } = {}) {
const limit = Math.max(options.limit ? options.limit : 5, 0);
const search = _search.toLowerCase();
const search = _search.trim().toLowerCase();

const accountCount = AccountStore.accounts().length;
const extensions = ComponentRegistry.findComponentsMatching({
role: 'ContactSearchResults',
});

if (!search || search.length === 0) {
return Promise.resolve([]);
return [];
}

if (limit === 0) {
return [];
}
Comment on lines +90 to 92

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is unreachable. On line 78, Math.max(options.limit ? options.limit : 5, 0) maps a passed-in 0 to 5 (because 0 is falsy), so limit can only be 0 when a caller passes a negative number. Worth dropping.


Generated by Claude Code


// Note that we ask for LIMIT * accountCount because we want to
Expand All @@ -55,27 +97,52 @@ class ContactStore extends MailspringStore {
// (which is very slow), we just ask for more items.
const query = DatabaseStore.findAll<Contact>(Contact)
.search(search)
.limit(limit * accountCount)
.limit(contactSearchFetchLimit(limit, accountCount))
.where(Contact.attributes.refs.greaterThan(0))
.where(Contact.attributes.hidden.equal(false))
.order(Contact.attributes.refs.descending());

return query.then(async (_results) => {
let results = this._distinctByEmail(this._omitFindInMailDisabled(_results));
for (const ext of extensions) {
results = await ext.findAdditionalContacts(search, results);
}
if (results.length > limit) {
results.length = limit;
}
return results;
}) as any as Promise<Contact[]>;
const historySearch = this._searchSentRecipientContacts(search, limit).catch((err) => {
console.warn('Unable to search sent-recipient history for autocomplete', err);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this sits on the keystroke path, a failure here would log once per character typed. AppEnv.reportError is the convention elsewhere in the codebase for swallowed errors, and it de-dupes.


Generated by Claude Code

return [];
});

const [_results, historyResults] = await Promise.all([query, historySearch]);
let results = this._distinctByEmail(
this._omitFindInMailDisabled(historyResults.concat(_results))
);
for (const ext of extensions) {
results = await ext.findAdditionalContacts(search, results);
}
results = prioritizeContactsMatchingEmailPrefix(this._distinctByEmail(results), search);
if (results.length > limit) {
results.length = limit;
}
Comment on lines +110 to +120

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines change the ranking of every autocomplete result, for everyone — which I think deserves to be an explicit product decision rather than a side effect of the fix.

Two compounding effects:

  • historyResults.concat(_results) puts thread-derived contacts first, and _distinctByEmail returns Object.values(uniq) in insertion order, so history wins over the refs DESC ordering from the database query.
  • prioritizeContactsMatchingEmailPrefix then floats every local-part prefix match above everything else.

Then results.length = limit truncates to 5. So a contact you've emailed once, whose address happens to start with what you typed, can push out the contact you email daily. For example, typing sup would rank a rarely-used support@somevendor.com above a frequently-used Support Team <team@example.com>.

If prefix-boosting is desirable (it might well be!), I'd suggest making it a tiebreaker within the refs ordering rather than a hard partition — e.g. sort by (isPrefixMatch, refs) instead of concatenating two buckets.


Generated by Claude Code

return results;
}

_searchSentRecipientContacts(search: string, limit: number): Promise<Contact[]> {
if (search.length < 2) {
return Promise.resolve([]);
}

const recipientSearch = new ToQueryExpression(
new TextQueryExpression(new SearchQueryToken(search))
);
const threadLimit = Math.min(Math.max(limit * 20, 100), 500);

return DatabaseStore.findAll<Thread>(Thread)
.structuredSearch(recipientSearch)
.where(Thread.attributes.lastMessageSentTimestamp.greaterThan(new Date(0)))
.order(Thread.attributes.lastMessageSentTimestamp.descending())
.limit(threadLimit)
.then((threads) => contactsMatchingEmailPrefix(threads, search).slice(0, limit));
}
Comment on lines +124 to 140

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the part I'd most want to see split out and reworked — it's a substantial new feature rather than a limit fix, and as written I think it will cause noticeable typing lag on large mailboxes. Three specific issues:

1. It runs synchronously on the render thread, on every keystroke. TokenizingTextField._refreshCompletions is called from every input event with no debounce (app/src/components/tokenizing-text-field.tsx:847), and Mailspring uses better-sqlite3, which blocks. The existing thread search deliberately avoids this by marking the query as background — see app/internal_packages/thread-search/lib/search-query-subscription.ts:50 and search-bar-util.ts:41, both of which call .background() for exactly this query shape. Adding .background() here would move it onto the worker process.

2. The FTS subselect is unbounded. LocalSearchQueryBackend's visitMatch (app/src/services/search/search-query-backend-local.ts:270) emits id IN (SELECT content_id FROM ThreadSearch WHERE ThreadSearch MATCH ...) with no inner LIMIT — unlike SearchMatcher.whereSQL, which caps at 1000. So for a common token like support, SQLite materializes every matching thread id before the outer ORDER BY lastMessageSentTimestamp DESC LIMIT 100 is applied.

3. It doesn't actually restrict to people you've written to. lastMessageSentTimestamp > 0 means "this thread contains at least one sent message", and contactsMatchingEmailPrefix then scans thread.participants — everyone on the thread, including senders and anyone CC'd. So a support@ address that merely appeared alongside you on a thread will be suggested as if you'd emailed it.

Combined with the prefix filter below, the query casts a wide net (to_ : "support"* matches the domain too) and then discards nearly everything it fetched.


Generated by Claude Code


topContacts({ limit = 5 } = {}) {
const accountCount = AccountStore.accounts().length;
return DatabaseStore.findAll<Contact>(Contact)
.limit(limit * accountCount)
.limit(contactSearchFetchLimit(limit, accountCount))
.where(Contact.attributes.refs.greaterThan(0))
.where(Contact.attributes.hidden.equal(false))
.order(Contact.attributes.refs.descending())
Expand Down