feat: suggest directory people and search the sent folder for recipients in the composer

This commit is contained in:
Linus Rath
2026-10-31 05:27:34 +01:00
parent 8ac1997149
commit a7a3757378
4 changed files with 281 additions and 8 deletions
@@ -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<ReturnType<ReturnType<typeof useContactsStore.getState>['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
@@ -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'\)/);
});
});
+64
View File
@@ -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<string>;
/** 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<string, RecipientSuggestion>();
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;
}
+85 -8
View File
@@ -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 (
<View style={styles.suggestionBox}>
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
{suggestions.map((s, i) => (
<Pressable
key={`${s.group?.id ?? s.email}-${i}`}
@@ -287,6 +292,32 @@ function SuggestionList({
</View>
</Pressable>
))}
{onSearchServer && (
<Pressable
onPressIn={onPressIn}
onPress={() => { if (!searchingServer) onSearchServer(); }}
disabled={searchingServer}
accessibilityRole="button"
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
style={({ pressed }) => [
styles.suggestionRow,
suggestions.length > 0 && styles.suggestionSearchRowDivided,
pressed && styles.suggestionRowPressed,
]}
>
<View style={styles.suggestionSearchIcon}>
{searchingServer
? <ActivityIndicator size="small" color={c.textSecondary} />
: <Search size={16} color={c.textSecondary} />}
</View>
<Text style={styles.suggestionSearchText} numberOfLines={1}>
{searchingServer
? t('email_composer.autocomplete_searching', 'Searching...')
: t('email_composer.autocomplete_search_server', 'Search the server')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
}
@@ -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<RecipientSuggestion[]>(() => {
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"
/>
</View>
{activeField === field && suggestions.length > 0 && (
{activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
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,