From a7a3757378096f04478752cf89a945722f6629e4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:27:34 +0100 Subject: [PATCH] feat: suggest directory people and search the sent folder for recipients in the composer --- .../__tests__/live-stalwart-compose.test.ts | 40 ++++++++ .../__tests__/recipient-suggestions.test.ts | 92 ++++++++++++++++++ src/lib/recipient-suggestions.ts | 64 +++++++++++++ src/screens/ComposeScreen.tsx | 93 +++++++++++++++++-- 4 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 src/lib/__tests__/recipient-suggestions.test.ts create mode 100644 src/lib/recipient-suggestions.ts diff --git a/src/api/__tests__/live-stalwart-compose.test.ts b/src/api/__tests__/live-stalwart-compose.test.ts index 1fb53247..e7912e08 100644 --- a/src/api/__tests__/live-stalwart-compose.test.ts +++ b/src/api/__tests__/live-stalwart-compose.test.ts @@ -38,6 +38,8 @@ import { htmlToPlainText } from '../../lib/compose-html'; import { buildEmbeddedSignatureHtml, spliceSignature } from '../../lib/signature-utils'; import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files'; import { servedLoginOf } from '../../lib/draft-login'; +import { mergeRecipientSuggestions } from '../../lib/recipient-suggestions'; +import { useContactsStore } from '../../stores/contacts-store'; import { useAccountStore } from '../../stores/account-store'; import { useTemplatesStore } from '../../stores/templates-store'; @@ -315,6 +317,44 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => { }, 60_000); }); + // The composer's suggestions: "Search the server" finds a recipient in + // the Sent folder the recent list never loaded, and directory people are + // offered (parity 06). + describe('recipient suggestions', () => { + it('finds a Sent recipient on demand and suggests people from the directory', async () => { + const rcpt = `rcpt-${RUN}@example.net`; + const saved = await saveDraftVersion({ + from: [{ email: USER }], to: [{ name: `Rcpt ${RUN}`, email: rcpt }], subject: `suggest ${tag}`, textBody: 'hi', + }, sent); + createdEmails.push(saved.id); + useContactsStore.getState().reset(); + useContactsStore.setState({ sentMailboxId: sent, recentRecipientsLoaded: true }); + + const local = useContactsStore.getState().getAutocomplete(`rcpt-${RUN}`, 16); + expect(local).toEqual([]); + // The server indexes a new message shortly after it is stored. + let server: Awaited['searchRecipients']>> = []; + await vi.waitFor(async () => { + server = await useContactsStore.getState().searchRecipients(`rcpt-${RUN}`); + expect(server.map((s) => s.email)).toContain(rcpt); + }, { timeout: 20_000, interval: 1_000 }); + const merged = mergeRecipientSuggestions({ + local, isContact: () => false, directory: [], server, exclude: new Set(), limit: 8, + }); + expect(merged).toEqual([{ name: `Rcpt ${RUN}`, email: rcpt }]); + + await useContactsStore.getState().fetchDirectory(); + const me = USER.split('@')[0]; + const withDirectory = mergeRecipientSuggestions({ + local: [], isContact: () => false, + directory: useContactsStore.getState().lookupDirectory(me, 16), + server: [], exclude: new Set(), limit: 8, + }); + expect(withDirectory.map((s) => s.email.toLowerCase())).toContain(USER.toLowerCase()); + useContactsStore.getState().reset(); + }, 60_000); + }); + describe('the body of a composed message (compose pass 2026-11)', () => { it('keeps the quote marks, numbered list, signature delimiter and image text of the text part, and one signature', async () => { // The composer's body after a From switch with text typed under the diff --git a/src/lib/__tests__/recipient-suggestions.test.ts b/src/lib/__tests__/recipient-suggestions.test.ts new file mode 100644 index 00000000..8af072e6 --- /dev/null +++ b/src/lib/__tests__/recipient-suggestions.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mergeRecipientSuggestions } from '../recipient-suggestions'; +import type { RecipientSuggestion } from '../../stores/contacts-store'; + +// The composer's suggestions in the webmail's order: contacts and groups, +// directory people, recent recipients, then what "Search the server" found +// in the Sent folder (parity 06 autocomplete row). + +const contact = (name: string, email: string): RecipientSuggestion => ({ name, email }); +const contactEmails = new Set(['ann@example.com', 'nameless@example.com']); +const isContact = (s: RecipientSuggestion) => !!s.group || contactEmails.has(s.email); + +describe('mergeRecipientSuggestions', () => { + it('puts directory people after contacts and before recent recipients', () => { + const out = mergeRecipientSuggestions({ + local: [contact('Ann', 'ann@example.com'), contact('Recent Rob', 'rob@example.com')], + isContact, + directory: [contact('Dana Directory', 'dana@example.com')], + server: [], + exclude: new Set(), + limit: 8, + }); + expect(out.map((s) => s.email)).toEqual(['ann@example.com', 'dana@example.com', 'rob@example.com']); + }); + + it('lists an address once and lends a directory name to a contact without one', () => { + const out = mergeRecipientSuggestions({ + local: [contact('', 'nameless@example.com'), contact('Recent Dana', 'dana@example.com')], + isContact, + directory: [contact('Nora Nameless', 'Nameless@example.com'), contact('Dana Directory', 'dana@example.com')], + server: [], + exclude: new Set(), + limit: 8, + }); + expect(out).toEqual([ + { name: 'Nora Nameless', email: 'nameless@example.com' }, + { name: 'Dana Directory', email: 'dana@example.com' }, + ]); + }); + + it('adds server hits below the rest, skipping known and selected addresses', () => { + const local = Array.from({ length: 10 }, (_, i) => contact(`Recent ${i}`, `r${i}@example.com`)); + const out = mergeRecipientSuggestions({ + local, + isContact, + directory: [], + server: [contact('R0 again', 'r0@example.com'), contact('Picked', 'picked@example.com'), contact('Found', 'found@example.com'), contact('R9', 'r9@example.com')], + exclude: new Set(['picked@example.com']), + limit: 8, + }); + // Eight local ones, then the server's new ones; r9 fell past the limit + // and comes back from the server. + expect(out.map((s) => s.email)).toEqual([ + ...local.slice(0, 8).map((s) => s.email), 'found@example.com', 'r9@example.com', + ]); + }); + + it('keeps groups and leaves out addresses already on the message', () => { + const group: RecipientSuggestion = { name: 'Team', email: '', group: { id: 'g1', memberCount: 3 } }; + const out = mergeRecipientSuggestions({ + local: [group, contact('Ann', 'ann@example.com')], + isContact, + directory: [contact('Ann', 'ANN@example.com')], + server: [], + exclude: new Set(['ann@example.com']), + limit: 8, + }); + expect(out).toEqual([group]); + }); +}); + +// No React Native renderer in the test env: the composer is checked for the +// wiring. +describe('composer suggestions wiring', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); + + it('merges contacts, directory people and server hits', () => { + expect(src).toMatch(/mergeRecipientSuggestions\(\{\s*local: getAutocomplete\(q, 16\),/); + expect(src).toMatch(/directory: lookupDirectory\(q, 16\),/); + expect(src).toMatch(/server: serverHits\?\.query === q \? serverHits\.results : \[\],/); + expect(src).toMatch(/if \(directoryStatus === 'idle' && !loginMovedFrom\(ownerLoginRef\.current\)\) void fetchDirectory\(\);/); + }); + + it('offers "Search the server" when a Sent folder is known, on the composer\'s login only', () => { + expect(src).toMatch(/const results = await searchRecipients\(q\);/); + expect(src).toMatch(/if \(!q \|\| searchingServer \|\| loginMovedFrom\(ownerLoginRef\.current\)\) return;/); + expect(src).toMatch(/onSearchServer=\{canSearchServer \?/); + expect(src).toMatch(/t\('email_composer\.autocomplete_search_server', 'Search the server'\)/); + }); +}); diff --git a/src/lib/recipient-suggestions.ts b/src/lib/recipient-suggestions.ts new file mode 100644 index 00000000..275196a2 --- /dev/null +++ b/src/lib/recipient-suggestions.ts @@ -0,0 +1,64 @@ +import type { RecipientSuggestion } from '../stores/contacts-store'; + +// The composer's recipient suggestions, in the webmail's order +// (stores/contact-store.ts getAutocomplete, email-composer.tsx +// handleServerSearch): contacts and groups, then people in the company +// directory (RFC 9670 principals), then recent recipients from the Sent +// folder, then what an on-demand search of the Sent folder found. An address +// shows once, first where it comes up; a directory entry lends its name to a +// contact suggestion that has none. + +export interface SuggestionSources { + /** getAutocomplete's answer: contacts and groups first, then recent recipients. */ + local: RecipientSuggestion[]; + /** Whether a local suggestion is a contact or a group (the rest are recent recipients). */ + isContact: (s: RecipientSuggestion) => boolean; + /** lookupDirectory's answer. */ + directory: RecipientSuggestion[]; + /** searchRecipients' answer for the same query, once asked for. */ + server: RecipientSuggestion[]; + /** Addresses already on the message (lowercase); groups are always offered. */ + exclude: ReadonlySet; + /** How many contact, directory and recent suggestions to show. Server hits come on top. */ + limit: number; +} + +export function mergeRecipientSuggestions(src: SuggestionSources): RecipientSuggestion[] { + const out: RecipientSuggestion[] = []; + const seen = new Map(); + const offer = (s: RecipientSuggestion): boolean => { + if (s.group) { + out.push(s); + return true; + } + const key = s.email.trim().toLowerCase(); + if (!key || seen.has(key) || src.exclude.has(key)) return false; + const copy = { ...s }; + seen.set(key, copy); + out.push(copy); + return true; + }; + + const contacts = src.local.filter((s) => src.isContact(s)); + const recent = src.local.filter((s) => !src.isContact(s)); + for (const s of contacts) offer(s); + for (const s of src.directory) { + const known = seen.get(s.email.trim().toLowerCase()); + if (known) { + if (!known.name && s.name) known.name = s.name; + continue; + } + offer(s); + } + for (const s of recent) offer(s); + const shown = out.slice(0, src.limit); + // What fell past the limit may come back from the server search. + const shownKeys = new Set(shown.filter((s) => !s.group).map((s) => s.email.trim().toLowerCase())); + for (const s of src.server) { + const key = s.email.trim().toLowerCase(); + if (!key || shownKeys.has(key) || src.exclude.has(key)) continue; + shownKeys.add(key); + shown.push({ ...s }); + } + return shown; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index dc67853d..784dfcb0 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -11,7 +11,7 @@ import { List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, - Users, Tag, Type, Highlighter, Trash2, + Users, Tag, Type, Highlighter, Trash2, Search, } from '../components/icons'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; @@ -63,6 +63,7 @@ import { isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient, } from '../lib/recipients'; import { toUnicodeEmail } from '../lib/idn'; +import { mergeRecipientSuggestions } from '../lib/recipient-suggestions'; import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; @@ -250,17 +251,21 @@ function initialsOf(name: string, email: string): string { } function SuggestionList({ - suggestions, onPick, onPressIn, + suggestions, onPick, onPressIn, onSearchServer, searchingServer, }: { suggestions: RecipientSuggestion[]; onPick: (s: RecipientSuggestion) => void; onPressIn?: () => void; + /** Search the Sent folder on the server for more (webmail: "Search the server"). */ + onSearchServer?: () => void; + searchingServer?: boolean; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); return ( + {suggestions.map((s, i) => ( ))} + {onSearchServer && ( + { if (!searchingServer) onSearchServer(); }} + disabled={searchingServer} + accessibilityRole="button" + accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }} + style={({ pressed }) => [ + styles.suggestionRow, + suggestions.length > 0 && styles.suggestionSearchRowDivided, + pressed && styles.suggestionRowPressed, + ]} + > + + {searchingServer + ? + : } + + + {searchingServer + ? t('email_composer.autocomplete_searching', 'Searching...') + : t('email_composer.autocomplete_search_server', 'Search the server')} + + + )} + ); } @@ -740,6 +771,22 @@ export default function ComposeScreen({ route, navigation }: Props) { const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients); const contactsVersion = useContactsStore((s) => s.contacts); const recentVersion = useContactsStore((s) => s.recentRecipients); + const findContactByEmail = useContactsStore((s) => s.findContactByEmail); + const lookupDirectory = useContactsStore((s) => s.lookupDirectory); + const directoryVersion = useContactsStore((s) => s.directory); + const directoryStatus = useContactsStore((s) => s.directoryStatus); + const fetchDirectory = useContactsStore((s) => s.fetchDirectory); + const searchRecipients = useContactsStore((s) => s.searchRecipients); + // A known Sent folder makes the on-demand server search worth offering. + const canSearchServer = useContactsStore((s) => s.sentMailboxId != null); + const [serverHits, setServerHits] = React.useState<{ query: string; results: RecipientSuggestion[] } | null>(null); + const [searchingServer, setSearchingServer] = React.useState(false); + + // People in the company directory are suggested too (webmail loads the + // directory at sign-in); the store keeps one load per login. + React.useEffect(() => { + if (directoryStatus === 'idle' && !loginMovedFrom(ownerLoginRef.current)) void fetchDirectory(); + }, [directoryStatus, fetchDirectory]); React.useEffect(() => { if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id); @@ -759,12 +806,32 @@ export default function ComposeScreen({ route, navigation }: Props) { const suggestions = React.useMemo(() => { const q = suggestionQuery.trim(); if (q.length < 1) return []; - return getAutocomplete(q, 16) - .filter((s) => s.group || !alreadySelected.has(s.email.toLowerCase())) - .slice(0, 8); - // contactsVersion/recentVersion re-run the lookup when the store loads. + return mergeRecipientSuggestions({ + local: getAutocomplete(q, 16), + isContact: (s) => !!s.group || !!findContactByEmail(s.email), + directory: lookupDirectory(q, 16), + server: serverHits?.query === q ? serverHits.results : [], + exclude: alreadySelected, + limit: 8, + }); + // contactsVersion/recentVersion/directoryVersion re-run the lookup when the store loads. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [suggestionQuery, alreadySelected, getAutocomplete, contactsVersion, recentVersion]); + }, [suggestionQuery, alreadySelected, getAutocomplete, findContactByEmail, lookupDirectory, serverHits, contactsVersion, recentVersion, directoryVersion]); + + // On demand: search the Sent folder on the server for recipients matching + // what is typed, and add new ones below the suggestions (webmail + // handleServerSearch). Only for the login the message is written in. + const searchServer = async () => { + const q = suggestionQuery.trim(); + if (!q || searchingServer || loginMovedFrom(ownerLoginRef.current)) return; + setSearchingServer(true); + try { + const results = await searchRecipients(q); + if (!loginMovedFrom(ownerLoginRef.current)) setServerHits({ query: q, results }); + } finally { + setSearchingServer(false); + } + }; const setterFor = (field: Field) => field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients; @@ -2685,13 +2752,19 @@ export default function ComposeScreen({ route, navigation }: Props) { textContentType="none" /> - {activeField === field && suggestions.length > 0 && ( + {activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && ( { isPickingSuggestion.current = true; }} + onSearchServer={canSearchServer ? () => { + // No pick: a later blur turns the typed text into a chip again. + setTimeout(() => { isPickingSuggestion.current = false; }, 250); + void searchServer(); + } : undefined} + searchingServer={searchingServer} /> )} {field === 'to' && !ccVisible && ( @@ -3554,6 +3627,10 @@ function makeStyles(c: ThemePalette) { gap: spacing.sm, }, suggestionRowPressed: { backgroundColor: c.surfaceHover }, + suggestionScroll: { maxHeight: 320 }, + suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight }, + suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 }, suggestionAvatar: { width: 32, height: 32, borderRadius: 16,