feat: email a contact or group via the in-app composer
Adds a "Send email to group" action (To / Cc / Bcc) that opens the composer
pre-filled with the group's members in the chosen field, preserving each
member's display name. It is available both in the group context menu (between
"Edit Group" and "Delete") and in the group detail panel's header (shown when
the group has at least one member with an email). The single-contact "Send
email" button in the contact detail panel uses the same path.
Routing is internal, not via mailto:. Contacts is its own route and the composer
lives in the mail route, so the handoff stashes the recipients
(savePendingMailto) and does a client-side router.push("/"); the main route's
existing consumePendingMailto effect opens the composer in the current account.
This avoids the OS mailto handler (which could open a different mail app) and
the protocol round-trip's full-page reload, which dropped the in-memory
per-account JMAP clients of a multi-account session (a logout).
- contacts/page.tsx: openComposeInApp(recipients, field) shared helper;
handleComposeGroupFromSidebar (deduped "Name <email>" members, empty -> toast)
and handleComposeContact; wired to the sidebar, group detail, contact detail.
- contact-group-detail.tsx: onComposeGroup(field) prop + To/Cc/Bcc header control
(shown when the group has emailable members).
- contacts-sidebar.tsx: onComposeGroup(groupId, field) prop + "Send email to
group" submenu between Edit and Delete.
- contact-detail.tsx: onCompose() prop; the button is no longer a mailto: link.
- mailto.ts: recipient splitter is quote-aware (reuses the composer's
splitRecipients) so a comma in a display name survives — still useful for real
OS mailto: links.
- i18n: contacts.groups.send_email{,_to,_cc,_bcc} and no_member_emails across all
locales.
Display names round-trip via formatRecipient -> parseRecipientList.
This commit is contained in:
committed by
Linus Rath
parent
c9eae3b3a1
commit
ab3e0e717a
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
|
|||||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
|
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
|
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
@@ -461,6 +463,51 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open the in-app composer in the current session rather than routing through
|
||||||
|
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
|
||||||
|
// (which may open a different mail app), and the mailto protocol round-trip
|
||||||
|
// reloads the app - dropping the in-memory per-account JMAP clients of a
|
||||||
|
// multi-account session, which reads as a logout. Stashing the recipients and
|
||||||
|
// doing a client-side router.push keeps the session and the active account
|
||||||
|
// intact; the main route consumes the pending compose and opens the composer
|
||||||
|
// (see consumePendingMailto in page.tsx).
|
||||||
|
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
|
||||||
|
savePendingMailto({
|
||||||
|
to: field === "to" ? recipients : [],
|
||||||
|
cc: field === "cc" ? recipients : [],
|
||||||
|
bcc: field === "bcc" ? recipients : [],
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
router.push("/");
|
||||||
|
}, [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.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const recipients: 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));
|
||||||
|
}
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
toast.error(t("groups.no_member_emails"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openComposeInApp(recipients, field);
|
||||||
|
}, [getGroupMembers, t, openComposeInApp]);
|
||||||
|
|
||||||
|
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||||
|
const email = getContactPrimaryEmail(contact).trim();
|
||||||
|
if (!email) return;
|
||||||
|
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
|
||||||
|
}, [openComposeInApp]);
|
||||||
|
|
||||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("groups.delete_confirm_title"),
|
title: t("groups.delete_confirm_title"),
|
||||||
@@ -625,6 +672,7 @@ export default function ContactsPage() {
|
|||||||
onEdit={handleEditGroup}
|
onEdit={handleEditGroup}
|
||||||
onDelete={handleDeleteGroup}
|
onDelete={handleDeleteGroup}
|
||||||
onRemoveMember={handleRemoveGroupMember}
|
onRemoveMember={handleRemoveGroupMember}
|
||||||
|
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
@@ -702,6 +750,11 @@ export default function ContactsPage() {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onCompose={
|
||||||
|
selectedContact
|
||||||
|
? () => handleComposeContact(selectedContact)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onAddToGroup={
|
onAddToGroup={
|
||||||
selectedContact
|
selectedContact
|
||||||
? () => handleAddContactToGroup(selectedContact.id)
|
? () => handleAddContactToGroup(selectedContact.id)
|
||||||
@@ -807,6 +860,7 @@ export default function ContactsPage() {
|
|||||||
onImport={() => setShowImportDialog(true)}
|
onImport={() => setShowImportDialog(true)}
|
||||||
onEditGroup={handleEditGroupFromSidebar}
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
|
onComposeGroup={handleComposeGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ContactsSidebar } from '../contacts-sidebar';
|
||||||
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
|
||||||
|
vi.mock('@/stores/account-store', () => {
|
||||||
|
const state = { accounts: [], activeAccountId: null };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
return { useAccountStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = {
|
||||||
|
id: 'g1',
|
||||||
|
kind: 'group',
|
||||||
|
name: { full: 'Team' },
|
||||||
|
members: { '1': true },
|
||||||
|
} as unknown as ContactCard;
|
||||||
|
|
||||||
|
function renderSidebar(onComposeGroup = vi.fn()) {
|
||||||
|
render(
|
||||||
|
<ContactsSidebar
|
||||||
|
groups={[group]}
|
||||||
|
individuals={[]}
|
||||||
|
addressBooks={[]}
|
||||||
|
activeCategory="all"
|
||||||
|
onSelectCategory={vi.fn()}
|
||||||
|
onCreateGroup={vi.fn()}
|
||||||
|
onCreateContact={vi.fn()}
|
||||||
|
onEditGroup={vi.fn()}
|
||||||
|
onDeleteGroup={vi.fn()}
|
||||||
|
onComposeGroup={onComposeGroup}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return onComposeGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContactsSidebar — compose to group', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('shows a "Send email to group" submenu in the group context menu', () => {
|
||||||
|
renderSidebar();
|
||||||
|
fireEvent.contextMenu(screen.getByText('Team'));
|
||||||
|
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
|
||||||
|
// and the existing Edit/Delete entries still render
|
||||||
|
expect(screen.getByText('groups.edit')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('form.delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
|
||||||
|
const onComposeGroup = renderSidebar();
|
||||||
|
fireEvent.contextMenu(screen.getByText('Team'));
|
||||||
|
|
||||||
|
// Open the submenu (hover) then click "Cc".
|
||||||
|
const trigger = screen.getByText('groups.send_email').closest('.relative')!;
|
||||||
|
fireEvent.mouseOver(trigger);
|
||||||
|
fireEvent.mouseEnter(trigger);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('groups.send_email_cc'));
|
||||||
|
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,8 @@ interface ContactDetailProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onAddToGroup?: () => void;
|
onAddToGroup?: () => void;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
|
/** Compose an email to this contact in the app (no OS mailto handoff). */
|
||||||
|
onCompose?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -115,7 +117,7 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const smimeStore = useSmimeStore();
|
const smimeStore = useSmimeStore();
|
||||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||||
@@ -260,14 +262,16 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||||
{email && (
|
{email && onCompose && (
|
||||||
<a
|
<Button
|
||||||
href={`mailto:${email}`}
|
variant="outline"
|
||||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
size="sm"
|
||||||
|
onClick={onCompose}
|
||||||
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 mr-1" />
|
<Send className="w-4 h-4 mr-1" />
|
||||||
{t("detail.compose_email")}
|
{t("detail.compose_email")}
|
||||||
</a>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{phone && (
|
{phone && (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
|
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onRemoveMember: (memberId: string) => void;
|
onRemoveMember: (memberId: string) => void;
|
||||||
onSelectMember: (id: string) => void;
|
onSelectMember: (id: string) => void;
|
||||||
|
/** Compose an email to every member with the recipients placed in `field`. */
|
||||||
|
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
onSelectMember,
|
onSelectMember,
|
||||||
|
onComposeGroup,
|
||||||
isMobile,
|
isMobile,
|
||||||
className,
|
className,
|
||||||
}: ContactGroupDetailProps) {
|
}: ContactGroupDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const groupName = getContactDisplayName(group);
|
const groupName = getContactDisplayName(group);
|
||||||
|
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||||
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onComposeGroup && hasEmailMembers && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||||
|
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
|
||||||
|
<div className="inline-flex gap-1">
|
||||||
|
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||||
|
<Button
|
||||||
|
key={field}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onComposeGroup(field)}
|
||||||
|
className="touch-manipulation"
|
||||||
|
>
|
||||||
|
{t(`groups.send_email_${field}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
@@ -25,6 +25,7 @@ interface ContactsSidebarProps {
|
|||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onEditGroup?: (groupId: string) => void;
|
onEditGroup?: (groupId: string) => void;
|
||||||
onDeleteGroup?: (groupId: string) => void;
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
|
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
@@ -93,6 +94,7 @@ export function ContactsSidebar({
|
|||||||
onImport,
|
onImport,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
|
onComposeGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
onRenameAddressBook,
|
onRenameAddressBook,
|
||||||
@@ -684,6 +686,21 @@ export function ContactsSidebar({
|
|||||||
onEditGroup?.(groupContextMenu.data!.id);
|
onEditGroup?.(groupContextMenu.data!.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{onComposeGroup && (
|
||||||
|
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
|
||||||
|
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={field}
|
||||||
|
label={t(`groups.send_email_${field}`)}
|
||||||
|
onClick={() => {
|
||||||
|
const groupId = groupContextMenu.data!.id;
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onComposeGroup(groupId, field);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||||
|
import { formatRecipient, parseRecipientList } from "@/lib/email-composer-utils";
|
||||||
|
|
||||||
|
// Build a mailto: URL the way a recipient picker encodes one: each recipient
|
||||||
|
// percent-encoded and comma-joined, To in the path, Cc/Bcc in a query param.
|
||||||
|
// Used only to exercise parseMailto's quote-aware recipient splitting.
|
||||||
|
function encodeMailto(recipients: string[], field: "to" | "cc" | "bcc"): string {
|
||||||
|
const encoded = recipients.map((r) => encodeURIComponent(r)).join(",");
|
||||||
|
return field === "to" ? `mailto:${encoded}` : `mailto:?${field}=${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseMailto display-name handling", () => {
|
||||||
|
it("preserves display names through the mailto round-trip", () => {
|
||||||
|
const recipients = [
|
||||||
|
formatRecipient("Alice Smith", "alice@x.com"),
|
||||||
|
formatRecipient("Bob", "bob@y.com"),
|
||||||
|
];
|
||||||
|
const parsed = parseMailto(encodeMailto(recipients, "to"));
|
||||||
|
expect(parseRecipientList(parsed!.to.join(", "))).toEqual([
|
||||||
|
{ name: "Alice Smith", email: "alice@x.com" },
|
||||||
|
{ name: "Bob", email: "bob@y.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a display name containing a comma intact (quote-aware split)", () => {
|
||||||
|
const recipients = [
|
||||||
|
formatRecipient("Doe, John", "john@doe.org"), // -> "Doe, John" <john@doe.org>
|
||||||
|
"alice@x.com",
|
||||||
|
];
|
||||||
|
const parsed = parseMailto(encodeMailto(recipients, "cc"));
|
||||||
|
expect(parsed!.cc).toEqual(['"Doe, John" <john@doe.org>', "alice@x.com"]);
|
||||||
|
expect(parseRecipientList(parsed!.cc.join(", "))).toEqual([
|
||||||
|
{ name: "Doe, John", email: "john@doe.org" },
|
||||||
|
{ email: "alice@x.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { splitRecipients as splitRecipientString } from "@/lib/email-composer-utils";
|
||||||
|
|
||||||
export interface ParsedMailto {
|
export interface ParsedMailto {
|
||||||
to: string[];
|
to: string[];
|
||||||
cc: string[];
|
cc: string[];
|
||||||
@@ -25,10 +27,9 @@ function stripBodyControlChars(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitRecipients(value: string): string[] {
|
function splitRecipients(value: string): string[] {
|
||||||
return stripControlChars(value)
|
// Quote/angle-aware split so a `"Doe, John" <john@doo.org>` display name with
|
||||||
.split(",")
|
// an embedded comma stays a single recipient instead of being torn in two.
|
||||||
.map((recipient) => recipient.trim())
|
return splitRecipientString(stripControlChars(value));
|
||||||
.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryParam = {
|
type QueryParam = {
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Členové",
|
"members_label": "Členové",
|
||||||
"search_members": "Hledat kontakty k přidání...",
|
"search_members": "Hledat kontakty k přidání...",
|
||||||
"no_members": "Tato skupina nemá žádné členy",
|
"no_members": "Tato skupina nemá žádné členy",
|
||||||
"member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}"
|
"member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importovat kontakty",
|
"title": "Importovat kontakty",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Medlemmer",
|
"members_label": "Medlemmer",
|
||||||
"search_members": "Søg efter kontakter at tilføje...",
|
"search_members": "Søg efter kontakter at tilføje...",
|
||||||
"no_members": "Ingen medlemmer i denne gruppe",
|
"no_members": "Ingen medlemmer i denne gruppe",
|
||||||
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}"
|
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importér kontakter",
|
"title": "Importér kontakter",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Mitglieder",
|
"members_label": "Mitglieder",
|
||||||
"search_members": "Kontakte zum Hinzufügen suchen...",
|
"search_members": "Kontakte zum Hinzufügen suchen...",
|
||||||
"no_members": "Keine Mitglieder in dieser Gruppe",
|
"no_members": "Keine Mitglieder in dieser Gruppe",
|
||||||
"member_count": "{count, plural, =0 {Keine Mitglieder} one {1 Mitglied} other {# Mitglieder}}"
|
"member_count": "{count, plural, =0 {Keine Mitglieder} one {1 Mitglied} other {# Mitglieder}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Kontakte importieren",
|
"title": "Kontakte importieren",
|
||||||
|
|||||||
@@ -2360,7 +2360,12 @@
|
|||||||
"members_label": "Members",
|
"members_label": "Members",
|
||||||
"search_members": "Search contacts to add...",
|
"search_members": "Search contacts to add...",
|
||||||
"no_members": "No members in this group",
|
"no_members": "No members in this group",
|
||||||
"member_count": "{count, plural, =0 {No members} one {1 member} other {# members}}"
|
"member_count": "{count, plural, =0 {No members} one {1 member} other {# members}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Import Contacts",
|
"title": "Import Contacts",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Miembros",
|
"members_label": "Miembros",
|
||||||
"search_members": "Buscar contactos para agregar...",
|
"search_members": "Buscar contactos para agregar...",
|
||||||
"no_members": "No hay miembros en este grupo",
|
"no_members": "No hay miembros en este grupo",
|
||||||
"member_count": "{count, plural, =0 {Sin miembros} one {1 miembro} other {# miembros}}"
|
"member_count": "{count, plural, =0 {Sin miembros} one {1 miembro} other {# miembros}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importar contactos",
|
"title": "Importar contactos",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Membres",
|
"members_label": "Membres",
|
||||||
"search_members": "Rechercher des contacts à ajouter...",
|
"search_members": "Rechercher des contacts à ajouter...",
|
||||||
"no_members": "Aucun membre dans ce groupe",
|
"no_members": "Aucun membre dans ce groupe",
|
||||||
"member_count": "{count, plural, =0 {Aucun membre} one {1 membre} other {# membres}}"
|
"member_count": "{count, plural, =0 {Aucun membre} one {1 membre} other {# membres}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importer des contacts",
|
"title": "Importer des contacts",
|
||||||
|
|||||||
@@ -2360,7 +2360,12 @@
|
|||||||
"members_label": "Tagok",
|
"members_label": "Tagok",
|
||||||
"search_members": "Névjegyek keresése a hozzáadáshoz...",
|
"search_members": "Névjegyek keresése a hozzáadáshoz...",
|
||||||
"no_members": "Nincs tag ebben a csoportban",
|
"no_members": "Nincs tag ebben a csoportban",
|
||||||
"member_count": "{count, plural, =0 {Nincs tag} one {1 tag} other {# tag}}"
|
"member_count": "{count, plural, =0 {Nincs tag} one {1 tag} other {# tag}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Névjegyek importálása",
|
"title": "Névjegyek importálása",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Membri",
|
"members_label": "Membri",
|
||||||
"search_members": "Cerca contatti da aggiungere...",
|
"search_members": "Cerca contatti da aggiungere...",
|
||||||
"no_members": "Nessun membro in questo gruppo",
|
"no_members": "Nessun membro in questo gruppo",
|
||||||
"member_count": "{count, plural, =0 {Nessun membro} one {1 membro} other {# membri}}"
|
"member_count": "{count, plural, =0 {Nessun membro} one {1 membro} other {# membri}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importa contatti",
|
"title": "Importa contatti",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "メンバー",
|
"members_label": "メンバー",
|
||||||
"search_members": "追加する連絡先を検索...",
|
"search_members": "追加する連絡先を検索...",
|
||||||
"no_members": "このグループにメンバーがいません",
|
"no_members": "このグループにメンバーがいません",
|
||||||
"member_count": "{count, plural, =0 {メンバーなし} other {#人のメンバー}}"
|
"member_count": "{count, plural, =0 {メンバーなし} other {#人のメンバー}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "連絡先をインポート",
|
"title": "連絡先をインポート",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "멤버",
|
"members_label": "멤버",
|
||||||
"search_members": "추가할 연락처 검색...",
|
"search_members": "추가할 연락처 검색...",
|
||||||
"no_members": "이 그룹에는 멤버가 없어요",
|
"no_members": "이 그룹에는 멤버가 없어요",
|
||||||
"member_count": "멤버 {count}명"
|
"member_count": "멤버 {count}명",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "연락처 가져오기",
|
"title": "연락처 가져오기",
|
||||||
|
|||||||
@@ -2355,7 +2355,12 @@
|
|||||||
"members_label": "Dalībnieki",
|
"members_label": "Dalībnieki",
|
||||||
"search_members": "Meklēt kontaktus, ko pievienot...",
|
"search_members": "Meklēt kontaktus, ko pievienot...",
|
||||||
"no_members": "Šajā grupā nav dalībnieku",
|
"no_members": "Šajā grupā nav dalībnieku",
|
||||||
"member_count": "{count, plural, =0 {Nav dalībnieku} one {1 dalībnieks} other {# dalībnieki}}"
|
"member_count": "{count, plural, =0 {Nav dalībnieku} one {1 dalībnieks} other {# dalībnieki}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Kontaktu imports",
|
"title": "Kontaktu imports",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Leden",
|
"members_label": "Leden",
|
||||||
"search_members": "Contacten zoeken om toe te voegen...",
|
"search_members": "Contacten zoeken om toe te voegen...",
|
||||||
"no_members": "Geen leden in deze groep",
|
"no_members": "Geen leden in deze groep",
|
||||||
"member_count": "{count, plural, =0 {Geen leden} one {1 lid} other {# leden}}"
|
"member_count": "{count, plural, =0 {Geen leden} one {1 lid} other {# leden}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Contacten importeren",
|
"title": "Contacten importeren",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Członkowie",
|
"members_label": "Członkowie",
|
||||||
"search_members": "Szukaj kontaktów do dodania...",
|
"search_members": "Szukaj kontaktów do dodania...",
|
||||||
"no_members": "Brak członków w tej grupie",
|
"no_members": "Brak członków w tej grupie",
|
||||||
"member_count": "{count, plural, =0 {Brak członków} one {1 członek} other {# członków}}"
|
"member_count": "{count, plural, =0 {Brak członków} one {1 członek} other {# członków}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importuj kontakty",
|
"title": "Importuj kontakty",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Membros",
|
"members_label": "Membros",
|
||||||
"search_members": "Pesquisar contatos para adicionar...",
|
"search_members": "Pesquisar contatos para adicionar...",
|
||||||
"no_members": "Nenhum membro neste grupo",
|
"no_members": "Nenhum membro neste grupo",
|
||||||
"member_count": "{count, plural, =0 {Nenhum membro} one {1 membro} other {# membros}}"
|
"member_count": "{count, plural, =0 {Nenhum membro} one {1 membro} other {# membros}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importar contatos",
|
"title": "Importar contatos",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Участники",
|
"members_label": "Участники",
|
||||||
"search_members": "Поиск контактов для добавления...",
|
"search_members": "Поиск контактов для добавления...",
|
||||||
"no_members": "В этой группе нет участников",
|
"no_members": "В этой группе нет участников",
|
||||||
"member_count": "{count, plural, =0 {Нет участников} one {1 участник} other {# участников}}"
|
"member_count": "{count, plural, =0 {Нет участников} one {1 участник} other {# участников}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Импорт контактов",
|
"title": "Импорт контактов",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Üyeler",
|
"members_label": "Üyeler",
|
||||||
"search_members": "Eklemek için kişileri arayın...",
|
"search_members": "Eklemek için kişileri arayın...",
|
||||||
"no_members": "Bu grupta üye yok",
|
"no_members": "Bu grupta üye yok",
|
||||||
"member_count": "{count, plural, =0 {Üye yok} one {1 üye} other {# üye}}"
|
"member_count": "{count, plural, =0 {Üye yok} one {1 üye} other {# üye}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Kişileri İçe Aktar",
|
"title": "Kişileri İçe Aktar",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "Члени",
|
"members_label": "Члени",
|
||||||
"search_members": "Пошук контактів для додавання...",
|
"search_members": "Пошук контактів для додавання...",
|
||||||
"no_members": "У цій групі немає учасників",
|
"no_members": "У цій групі немає учасників",
|
||||||
"member_count": "{count, plural, =0 {Немає учасників} one {1 учасник} few {# учасники} many {# учасників} other {# учасників}}"
|
"member_count": "{count, plural, =0 {Немає учасників} one {1 учасник} few {# учасники} many {# учасників} other {# учасників}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Імпортувати контакти",
|
"title": "Імпортувати контакти",
|
||||||
|
|||||||
@@ -2359,7 +2359,12 @@
|
|||||||
"members_label": "成员",
|
"members_label": "成员",
|
||||||
"search_members": "搜索联系人以添加...",
|
"search_members": "搜索联系人以添加...",
|
||||||
"no_members": "该群组中没有成员",
|
"no_members": "该群组中没有成员",
|
||||||
"member_count": "{count, plural, =0 {无成员} one {1 位成员} other {# 位成员}}"
|
"member_count": "{count, plural, =0 {无成员} one {1 位成员} other {# 位成员}}",
|
||||||
|
"send_email": "Send email to group",
|
||||||
|
"send_email_to": "To",
|
||||||
|
"send_email_cc": "Cc",
|
||||||
|
"send_email_bcc": "Bcc",
|
||||||
|
"no_member_emails": "This group has no members with an email address."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "导入联系人",
|
"title": "导入联系人",
|
||||||
|
|||||||
Reference in New Issue
Block a user