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
+16 -8
View File
@@ -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 <email>" 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<string>();
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) => {