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 { buildEmbeddedSignatureHtml, spliceSignature } from '../../lib/signature-utils';
import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files'; import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files';
import { servedLoginOf } from '../../lib/draft-login'; 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 { useAccountStore } from '../../stores/account-store';
import { useTemplatesStore } from '../../stores/templates-store'; import { useTemplatesStore } from '../../stores/templates-store';
@@ -315,6 +317,44 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
}, 60_000); }, 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)', () => { 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 () => { 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 // 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, List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type, Highlighter, Trash2, Users, Tag, Type, Highlighter, Trash2, Search,
} from '../components/icons'; } from '../components/icons';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
@@ -63,6 +63,7 @@ import {
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient, isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient,
} from '../lib/recipients'; } from '../lib/recipients';
import { toUnicodeEmail } from '../lib/idn'; import { toUnicodeEmail } from '../lib/idn';
import { mergeRecipientSuggestions } from '../lib/recipient-suggestions';
import { import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity'; } from '../lib/reply-identity';
@@ -250,17 +251,21 @@ function initialsOf(name: string, email: string): string {
} }
function SuggestionList({ function SuggestionList({
suggestions, onPick, onPressIn, suggestions, onPick, onPressIn, onSearchServer, searchingServer,
}: { }: {
suggestions: RecipientSuggestion[]; suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void; onPick: (s: RecipientSuggestion) => void;
onPressIn?: () => void; onPressIn?: () => void;
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
onSearchServer?: () => void;
searchingServer?: boolean;
}) { }) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
return ( return (
<View style={styles.suggestionBox}> <View style={styles.suggestionBox}>
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
{suggestions.map((s, i) => ( {suggestions.map((s, i) => (
<Pressable <Pressable
key={`${s.group?.id ?? s.email}-${i}`} key={`${s.group?.id ?? s.email}-${i}`}
@@ -287,6 +292,32 @@ function SuggestionList({
</View> </View>
</Pressable> </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> </View>
); );
} }
@@ -740,6 +771,22 @@ export default function ComposeScreen({ route, navigation }: Props) {
const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients); const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients);
const contactsVersion = useContactsStore((s) => s.contacts); const contactsVersion = useContactsStore((s) => s.contacts);
const recentVersion = useContactsStore((s) => s.recentRecipients); 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(() => { React.useEffect(() => {
if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id); if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id);
@@ -759,12 +806,32 @@ export default function ComposeScreen({ route, navigation }: Props) {
const suggestions = React.useMemo<RecipientSuggestion[]>(() => { const suggestions = React.useMemo<RecipientSuggestion[]>(() => {
const q = suggestionQuery.trim(); const q = suggestionQuery.trim();
if (q.length < 1) return []; if (q.length < 1) return [];
return getAutocomplete(q, 16) return mergeRecipientSuggestions({
.filter((s) => s.group || !alreadySelected.has(s.email.toLowerCase())) local: getAutocomplete(q, 16),
.slice(0, 8); isContact: (s) => !!s.group || !!findContactByEmail(s.email),
// contactsVersion/recentVersion re-run the lookup when the store loads. 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 // 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) => const setterFor = (field: Field) =>
field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients; field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients;
@@ -2685,13 +2752,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
textContentType="none" textContentType="none"
/> />
</View> </View>
{activeField === field && suggestions.length > 0 && ( {activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
<SuggestionList <SuggestionList
suggestions={suggestions} suggestions={suggestions}
onPick={pickSuggestion} onPick={pickSuggestion}
onPressIn={() => { onPressIn={() => {
isPickingSuggestion.current = true; 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 && ( {field === 'to' && !ccVisible && (
@@ -3554,6 +3627,10 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm, gap: spacing.sm,
}, },
suggestionRowPressed: { backgroundColor: c.surfaceHover }, 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: { suggestionAvatar: {
width: 32, height: 32, width: 32, height: 32,
borderRadius: 16, borderRadius: 16,