Feature: contact groups as single expandable recipient chips

Typing a contact group's name in a recipient field suggested the
individual members, and "send email to group" on the contacts page
filled the field with one chip per member - the group itself never
appeared anywhere.

The autocomplete now offers the group as a single entry (group icon
plus member count), and selecting it - like the contacts-page action -
inserts one chip named after the group that carries a snapshot of its
members. The chip expands into the deduplicated member addresses when
the message is sent or saved as a draft, mirroring how Outlook handles
distribution lists. Expansion happens where the outgoing address lists
are built, so validation and every plugin hook see real addresses.

Group chips survive the composer's string boundaries (draft data, dirty
compare, the contacts-page hand-off) as RFC 5322 group syntax
("Team: a@x, b@y;"). A bare colon reliably opens a group there because
display names containing a colon are always quoted. Typed text only
parses as a group when it carries at least one valid member, so stray
"Subject: hello" input stays a plain recipient.

RecipientSuggestion gains an optional group field; plugins that ignore
it keep working unchanged.
This commit is contained in:
dealerweb
2026-07-09 17:46:06 +02:00
committed by Linus Rath
parent fa31933922
commit c6bd5f645a
6 changed files with 252 additions and 50 deletions
+9 -11
View File
@@ -188,7 +188,7 @@ interface ContactStore {
setActiveTab: (tab: 'all' | 'groups') => void;
clearContacts: () => void;
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
getAutocomplete: (query: string) => Array<{ name: string; email: string; group?: { id: string; memberCount: number } }>;
getGroups: () => ContactCard[];
getIndividuals: () => ContactCard[];
@@ -527,20 +527,18 @@ export const useContactStore = create<ContactStore>()(
if (!query || query.length < 1) return [];
const lower = query.toLowerCase();
const results: Array<{ name: string; email: string }> = [];
const results: Array<{ name: string; email: string; group?: { id: string; memberCount: number } }> = [];
for (const contact of contacts) {
if (contact.kind === 'group') {
// Suggest the group itself as a single entry (Outlook-style);
// the composer turns it into one chip carrying the members.
const groupName = getContactDisplayName(contact);
if (groupName.toLowerCase().includes(lower)) {
const members = get().getGroupMembers(contact.id);
for (const member of members) {
const memberName = getContactDisplayName(member);
const memberEmails = member.emails ? Object.values(member.emails) : [];
for (const emailEntry of memberEmails) {
if (!emailEntry.address) continue;
results.push({ name: memberName, email: emailEntry.address });
}
if (groupName && groupName.toLowerCase().includes(lower)) {
const memberCount = get().getGroupMembers(contact.id)
.filter(m => getContactPrimaryEmail(m).trim()).length;
if (memberCount > 0) {
results.push({ name: groupName, email: '', group: { id: contact.id, memberCount } });
}
}
continue;