From c6bd5f645adba553a90e8b1af6e6beab4cb9ba44 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Thu, 9 Jul 2026 16:36:44 +0200 Subject: [PATCH] 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. --- app/(main)/[locale]/contacts/page.tsx | 24 +++-- components/email/email-composer.tsx | 102 +++++++++++++++------ lib/__tests__/email-composer-utils.test.ts | 57 +++++++++++- lib/email-composer-utils.ts | 94 ++++++++++++++++++- lib/plugin-types.ts | 5 + stores/contact-store.ts | 20 ++-- 6 files changed, 252 insertions(+), 50 deletions(-) diff --git a/app/(main)/[locale]/contacts/page.tsx b/app/(main)/[locale]/contacts/page.tsx index a2e81763..b84df961 100644 --- a/app/(main)/[locale]/contacts/page.tsx +++ b/app/(main)/[locale]/contacts/page.tsx @@ -20,7 +20,7 @@ import { exportContacts } from "@/components/contacts/contact-export"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { savePendingMailto } from "@/lib/protocol-handlers/session"; -import { formatRecipient } from "@/lib/email-composer-utils"; +import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -513,23 +513,31 @@ export default function ContactsPage() { }, [router]); const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => { - // Format each member as "Name " so the composer keeps the display - // name (round-trips via formatRecipient -> parseRecipientList). Dedupe by - // email, case-insensitively; members without an email are skipped. + // Hand the composer a single group chip (RFC 5322 group syntax survives + // the string hand-off) instead of one entry per member - the chip expands + // into the members when the message is sent. Dedupe by email, + // case-insensitively; members without an email are skipped. const seen = new Set(); - const recipients: string[] = []; + const members: Array<{ name?: string; email: string }> = []; for (const member of getGroupMembers(groupId)) { const email = getContactPrimaryEmail(member).trim(); const key = email.toLowerCase(); if (!email || seen.has(key)) continue; seen.add(key); - recipients.push(formatRecipient(getContactDisplayName(member), email)); + const name = getContactDisplayName(member); + members.push({ name: name && name !== email ? name : undefined, email }); } - if (recipients.length === 0) { + if (members.length === 0) { toast.error(t("groups.no_member_emails")); return; } - openComposeInApp(recipients, field); + const group = useContactStore.getState().contacts.find((c) => c.id === groupId); + const chip: Recipient = { + name: (group && getContactDisplayName(group)) || "Group", + email: "", + group: { members }, + }; + openComposeInApp([formatRecipientEntry(chip)], field); }, [getGroupMembers, t, openComposeInApp]); const handleComposeContact = useCallback((contact: ContactCard) => { diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 5d50e7ab..4e022d29 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -26,7 +26,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; -import { useContactStore } from "@/stores/contact-store"; +import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { generateSubAddress } from "@/lib/sub-addressing"; @@ -44,6 +44,7 @@ import { parseRecipient, parseRecipientList, formatRecipientList, + expandRecipients, splitPastedRecipients, waitForPendingUploads, extractUserAuthoredText, @@ -92,6 +93,10 @@ function createChipDragPreview(label: string): HTMLElement { return preview; } +// An autocomplete entry: a person, or a contact group (empty email) that +// inserts as a single chip and expands into its members on send. +type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } }; + export interface ComposerDraftData { to: string; cc: string; @@ -823,6 +828,7 @@ export function EmailComposer({ ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; const getAutocomplete = useContactStore((s) => s.getAutocomplete); + const getGroupMembers = useContactStore((s) => s.getGroupMembers); const searchRecipients = useContactStore((s) => s.searchRecipients); // Whether a Sent mailbox is known so the on-demand server search is worth // offering (falls back to hiding the "search the server" row otherwise). @@ -899,7 +905,7 @@ export function EmailComposer({ } }, [mode]); - const [autocompleteResults, setAutocompleteResults] = useState>([]); + const [autocompleteResults, setAutocompleteResults] = useState>([]); const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null); const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1); // Current trimmed query behind the open dropdown, plus the in-flight flag for @@ -933,7 +939,9 @@ export function EmailComposer({ const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => { if (fromField === toField) return; const setters = { to: setTo, cc: setCc, bcc: setBcc }; - const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? ''); + const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : ''; + const sameRecipient = (a: Recipient, b: Recipient) => + a.email === b.email && (a.name ?? '') === (b.name ?? '') && groupKey(a) === groupKey(b); setters[fromField](prev => { const idx = prev.findIndex(r => sameRecipient(r, recipient)); return idx === -1 ? prev : prev.filter((_, i) => i !== idx); @@ -961,9 +969,9 @@ export function EmailComposer({ autocompleteTimeoutRef.current = setTimeout(async () => { const localResults = getAutocomplete(query); // Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …). - const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email })); + const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email, group: r.group })); const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query }); - setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email }))); + setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email, group: s.group }))); // Keep the dropdown open even without local hits when a server search is // available, so the "search the server" row stays reachable (OWA-style). setActiveAutoField(merged.length > 0 || canSearchServer ? field : null); @@ -998,11 +1006,30 @@ export function EmailComposer({ } }, [autoQuery, composerClient, isSearchingServer, searchRecipients]); - const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => { + const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => { const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc; const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput; - setter(prev => [...prev, toRecipient(suggestion)]); + if (suggestion.group) { + // Insert the group as a single chip carrying a snapshot of its members + // (deduped, members without an address skipped). The chip is expanded + // into the members when the message is sent or saved as a draft. + const seen = new Set(); + const members: Array<{ name?: string; email: string }> = []; + for (const m of getGroupMembers(suggestion.group.id)) { + const email = getContactPrimaryEmail(m).trim(); + const key = email.toLowerCase(); + if (!email || seen.has(key)) continue; + seen.add(key); + const name = getContactDisplayName(m); + members.push({ name: name && name !== email ? name : undefined, email }); + } + if (members.length > 0) { + setter(prev => [...prev, { name: suggestion.name, email: '', group: { members } }]); + } + } else { + setter(prev => [...prev, toRecipient(suggestion)]); + } inputSetter(''); setAutocompleteResults([]); setActiveAutoField(null); @@ -1303,9 +1330,9 @@ export function EmailComposer({ const saveDraftOnce = async (): Promise => { if (!client || !composerClient) return null; - const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email)); - const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)); - const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)); + const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email)); + const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email)); + const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email)); if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) { return null; @@ -1474,7 +1501,9 @@ export function EmailComposer({ }; }, []); - const toAddresses = withInput(to, toInput); + // Groups expand here so validation and every outgoing payload see the + // actual member addresses. + const toAddresses = expandRecipients(withInput(to, toInput)); const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim(); const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading); const canSend = toAddresses.length > 0 && !!subject && hasContent; @@ -1603,8 +1632,8 @@ export function EmailComposer({ } } - const ccAddresses = withInput(cc, ccInput); - const bccAddresses = withInput(bcc, bccInput); + const ccAddresses = expandRecipients(withInput(cc, ccInput)); + const bccAddresses = expandRecipients(withInput(bcc, bccInput)); if (!canSend) { const errors: { to?: boolean; subject?: boolean; body?: boolean } = {}; @@ -2751,13 +2780,14 @@ export function EmailComposer({ const AutocompleteDropdown = React.forwardRef; + results: Array; selectedIndex: number; - onSelect: (suggestion: { name: string; email: string }) => void; + onSelect: (suggestion: SuggestionItem) => void; onSearchServer?: () => void; isSearchingServer?: boolean; }>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) { const t = useTranslations('email_composer'); + const tContacts = useTranslations('contacts'); return (
{results.map((r, i) => ( @@ -2776,9 +2806,19 @@ const AutocompleteDropdown = React.forwardRef - + {r.group ? ( + + + + ) : ( + + )} {r.name || r.email} - {r.name && ( + {r.group ? ( + + {tContacts('groups.member_count', { count: r.group.memberCount })} + + ) : r.name && ( <{r.email}> )} @@ -2844,10 +2884,10 @@ function RecipientChipInput({ onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void; onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void; activeAutoField: 'to' | 'cc' | 'bcc' | null; - autocompleteResults: Array<{ name: string; email: string }>; + autocompleteResults: Array; autoSelectedIndex: number; dropdownRef: React.RefObject; - onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void; + onInsertAutocomplete: (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => void; canSearchServer: boolean; onServerSearch: () => void; isSearchingServer: boolean; @@ -2880,7 +2920,9 @@ function RecipientChipInput({ // Format a recipient for display in a chip / context menu const formatChipDisplay = (r: Recipient): string => - r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email; + r.group + ? `${r.name || 'Group'} (${r.group.members.length})` + : r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email; // Handle saving an edited chip const handleSaveEdit = (newValue: string) => { @@ -2900,10 +2942,10 @@ function RecipientChipInput({ setEditingChip(null); return; } - newChip = { name: chip.name, email: trimmedNew }; + newChip = { ...chip, email: trimmedNew }; } else { // Update name, keep email. Empty name clears the display name. - newChip = { name: trimmedNew || undefined, email: chip.email }; + newChip = { ...chip, name: trimmedNew || undefined }; } const newChips = [...chips]; @@ -3066,7 +3108,7 @@ function RecipientChipInput({ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field })); // Show the address while dragging, matching the email-list drag preview. - const dragPreview = createChipDragPreview(chip.email); + const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email); e.dataTransfer.setDragImage(dragPreview, 0, 0); requestAnimationFrame(() => dragPreview.remove()); setDraggingIndex(i); @@ -3109,7 +3151,13 @@ function RecipientChipInput({ data-bwignore="true" /> ) : ( - {chipDisplay} + m.email).join(', ') : undefined} + > + {chip.group && } + {chipDisplay} + )}