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.
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
import { splitRecipients as splitRecipientString } from "@/lib/email-composer-utils";
|
|
|
|
export interface ParsedMailto {
|
|
to: string[];
|
|
cc: string[];
|
|
bcc: string[];
|
|
subject: string;
|
|
body: string;
|
|
}
|
|
|
|
const MAX_RECIPIENTS = 200;
|
|
const MAX_SUBJECT_LENGTH = 998;
|
|
const MAX_BODY_LENGTH = 64 * 1024;
|
|
// eslint-disable-next-line no-control-regex
|
|
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
|
// eslint-disable-next-line no-control-regex
|
|
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
|
|
function stripControlChars(value: string): string {
|
|
return value.replace(CONTROL_CHARS, "");
|
|
}
|
|
|
|
function stripBodyControlChars(value: string): string {
|
|
return value
|
|
.replace(/\r\n?/g, "\n")
|
|
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
|
|
}
|
|
|
|
function splitRecipients(value: string): string[] {
|
|
// Quote/angle-aware split so a `"Doe, John" <john@doo.org>` display name with
|
|
// an embedded comma stays a single recipient instead of being torn in two.
|
|
return splitRecipientString(stripControlChars(value));
|
|
}
|
|
|
|
type QueryParam = {
|
|
key: string;
|
|
value: string;
|
|
};
|
|
|
|
function getQueryValue(searchParams: QueryParam[], key: string): string {
|
|
const values: string[] = [];
|
|
const lowerKey = key.toLowerCase();
|
|
|
|
for (const { key: paramKey, value } of searchParams) {
|
|
if (paramKey.toLowerCase() === lowerKey) {
|
|
values.push(value);
|
|
}
|
|
}
|
|
|
|
return values.join(",");
|
|
}
|
|
|
|
function decodePathname(pathname: string): string | null {
|
|
try {
|
|
return decodeURIComponent(pathname || "");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function decodeQueryPart(value: string): string | null {
|
|
try {
|
|
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
|
|
// encoding, a literal '+' is part of the value and must not become space.
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseQuery(query: string): QueryParam[] | null {
|
|
if (!query) return [];
|
|
|
|
const params: QueryParam[] = [];
|
|
for (const part of query.split("&")) {
|
|
if (!part) continue;
|
|
const separatorIndex = part.indexOf("=");
|
|
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
|
|
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
|
|
const key = decodeQueryPart(rawKey);
|
|
const value = decodeQueryPart(rawValue);
|
|
if (key === null || value === null) return null;
|
|
params.push({ key, value });
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
export function parseMailto(raw: string): ParsedMailto | null {
|
|
if (!raw.toLowerCase().startsWith("mailto:")) return null;
|
|
|
|
const addressAndQuery = raw.slice("mailto:".length);
|
|
const queryIndex = addressAndQuery.indexOf("?");
|
|
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
|
|
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
|
|
|
|
const decodedPathname = decodePathname(rawPathname);
|
|
if (decodedPathname === null) return null;
|
|
const searchParams = parseQuery(rawQuery);
|
|
if (searchParams === null) return null;
|
|
|
|
const to = [
|
|
...splitRecipients(decodedPathname),
|
|
...splitRecipients(getQueryValue(searchParams, "to")),
|
|
].slice(0, MAX_RECIPIENTS);
|
|
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
|
|
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
|
|
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
|
|
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
|
|
|
|
return {
|
|
to,
|
|
cc,
|
|
bcc,
|
|
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
|
|
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
|
};
|
|
}
|