mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
`formatRecipient` writes a quote inside a display name as `\"`, but the parsers that read recipient strings back took every `"` as the end of the quoted name. A sender named `Support", ceo@corp.example, "x` came back from the draft stash (or the dirty check) as three recipients, one of them an address the user never chose. The splitter and its helpers now skip escaped characters inside quotes.
587 lines
23 KiB
TypeScript
587 lines
23 KiB
TypeScript
import { isValidEmail } from "@/lib/validation";
|
|
import { splitMailbox } from "@/lib/rfc5322-mailbox";
|
|
import { htmlToPlainText } from "@/lib/html-to-text";
|
|
import { emailHooks } from "@/lib/plugin-hooks";
|
|
import { Ellipsis, Lock, TriangleAlert } from "@/components/icons";
|
|
import type { Email } from "@/lib/jmap/types";
|
|
|
|
const HTML_ESCAPE_MAP = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
} as const;
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value.replace(/[&<>"']/g, (char) =>
|
|
HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Picks the plain-text and HTML bodies of an original message for seeding a
|
|
* reply/forward quote.
|
|
*
|
|
* Per RFC 8621 § 4.1.4 a message with only one body variant exposes that
|
|
* single part in BOTH `textBody` and `htmlBody`. So for an HTML-only message
|
|
* `textBody[0]` is the raw text/html source, and for a plain-text-only
|
|
* message `htmlBody[0]` is the text/plain part. Quoting either verbatim
|
|
* breaks the reply (#649): raw HTML tags end up in a plain-text quote, and
|
|
* plain text rendered as HTML collapses all newlines. Route by each part's
|
|
* actual MIME type instead: HTML listed under textBody is converted to
|
|
* readable text, and plain text listed under htmlBody is dropped so the
|
|
* composer's text path (escape + <br>) renders it.
|
|
*/
|
|
export function getQuoteBodies(
|
|
email: Pick<Email, "textBody" | "htmlBody" | "bodyValues" | "preview">
|
|
): { body: string; htmlBody?: string } {
|
|
const textPart = email.textBody?.[0];
|
|
const htmlPart = email.htmlBody?.[0];
|
|
const textValue = textPart ? email.bodyValues?.[textPart.partId]?.value : undefined;
|
|
const htmlValue = htmlPart ? email.bodyValues?.[htmlPart.partId]?.value : undefined;
|
|
|
|
const textPartIsHtml = textPart?.type?.toLowerCase() === "text/html";
|
|
// A missing type is treated as HTML, matching the viewer's rendering path.
|
|
const htmlPartIsHtml = !htmlPart?.type || htmlPart.type.toLowerCase() === "text/html";
|
|
|
|
const body = textValue
|
|
? (textPartIsHtml ? htmlToPlainText(textValue, { paragraphSpacing: true }) : textValue)
|
|
: (email.preview || "");
|
|
return {
|
|
body,
|
|
htmlBody: htmlPartIsHtml ? htmlValue || undefined : undefined,
|
|
};
|
|
}
|
|
|
|
export function plainTextToComposerBody(text: string): string {
|
|
if (!text) return "";
|
|
|
|
return text
|
|
.replace(/\r\n?/g, "\n")
|
|
.split(/\n{2,}/)
|
|
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
|
.join("");
|
|
}
|
|
|
|
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
|
|
// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so
|
|
// without this swap the editor would show a broken-image icon (issue #163).
|
|
export const INLINE_IMAGE_PLACEHOLDER =
|
|
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
|
|
/**
|
|
* Sniffs the real image MIME type from a blob's leading magic bytes, returning
|
|
* null when the bytes aren't a recognizable image.
|
|
*
|
|
* Some clients (notably Foxmail 7.2) embed inline images as
|
|
* `Content-Type: application/octet-stream` with no `Content-Disposition:
|
|
* inline` - the cid reference in the HTML is the only signal they're meant to
|
|
* render in-body (#543). The declared type can't be trusted in that case, so
|
|
* the composer looks at the bytes themselves to build a usable data URL and to
|
|
* re-attach the part with a correct `image/*` type when the reply is sent.
|
|
*/
|
|
export function sniffImageMime(bytes: Uint8Array): string | null {
|
|
if (!bytes || bytes.length < 4) return null;
|
|
// PNG: 89 50 4E 47
|
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
return "image/png";
|
|
}
|
|
// JPEG: FF D8 FF
|
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
return "image/jpeg";
|
|
}
|
|
// GIF: 47 49 46 38 ("GIF8")
|
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
|
return "image/gif";
|
|
}
|
|
// WebP: "RIFF" .... "WEBP"
|
|
if (
|
|
bytes.length >= 12 &&
|
|
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
|
) {
|
|
return "image/webp";
|
|
}
|
|
// BMP: 42 4D ("BM")
|
|
if (bytes[0] === 0x42 && bytes[1] === 0x4d) {
|
|
return "image/bmp";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Rewrites `<img src="cid:xxx">` references into `<img src="<placeholder>" data-cid="xxx">`
|
|
* so TipTap can render the editor (the original cid: URL would 404) while still
|
|
* carrying the cid through edits. The placeholder is swapped to the actual
|
|
* image data once the corresponding inline blob has been fetched.
|
|
*/
|
|
export function rewriteCidImagesForEditor(html: string): string {
|
|
if (!html || html.indexOf("cid:") === -1) return html;
|
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
|
let touched = false;
|
|
doc.querySelectorAll("img").forEach((img) => {
|
|
const src = img.getAttribute("src") || "";
|
|
if (!/^cid:/i.test(src)) return;
|
|
const cid = src.slice(4);
|
|
if (!cid) return;
|
|
if (!img.getAttribute("data-cid")) {
|
|
img.setAttribute("data-cid", cid);
|
|
}
|
|
img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER);
|
|
touched = true;
|
|
});
|
|
return touched ? doc.body.innerHTML : html;
|
|
}
|
|
|
|
/**
|
|
* Collects the cids of every `<img data-cid="...">` in a composer body, i.e.
|
|
* exactly the inline images the quoted original actually renders.
|
|
*
|
|
* Used to decide which cid attachments are worth fetching (and, on forward,
|
|
* which ones are already embedded in the body and so must not be listed as
|
|
* separate attachments too). Matching on the body rather than on the part's
|
|
* declared type/disposition is what makes Foxmail-style
|
|
* `application/octet-stream` inline images work.
|
|
*/
|
|
export function collectInlineImageCids(html: string): Set<string> {
|
|
const cids = new Set<string>();
|
|
if (!html || html.indexOf("data-cid") === -1) return cids;
|
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
|
doc.querySelectorAll("img[data-cid]").forEach((img) => {
|
|
const cid = img.getAttribute("data-cid");
|
|
if (cid) cids.add(cid);
|
|
});
|
|
return cids;
|
|
}
|
|
|
|
/**
|
|
* Reduce a composer body to just the user-authored text for the attachment
|
|
* reminder's keyword scan, dropping the quoted original of a reply/forward.
|
|
*
|
|
* Scanning the whole body triggered false positives whenever the quoted message
|
|
* mentioned an attachment - common, since the original often did carry one, and
|
|
* the default keyword list is broad and multilingual (#570). We strip:
|
|
* - HTML mode: the QuotedHtml island ([data-quoted-html]) and any <blockquote>
|
|
* (the wrapper used when the original had no HTML part), then convert to text.
|
|
* - Plain-text mode: lines prefixed with ">" (the reply quote).
|
|
* - Both modes: everything from the "Forwarded message" separator onward, which
|
|
* also removes the forwarded From/Date/Subject header lines and the bare
|
|
* forwarded original (which carries no blockquote/island wrapper).
|
|
*
|
|
* `forwardedSeparator` is the localized quote_header.forwarded_separator string;
|
|
* pass it so the forward cut works in the active locale.
|
|
*/
|
|
export function extractUserAuthoredText(
|
|
body: string,
|
|
options: { plainTextMode: boolean; forwardedSeparator?: string }
|
|
): string {
|
|
const { plainTextMode, forwardedSeparator } = options;
|
|
|
|
let text: string;
|
|
if (plainTextMode) {
|
|
text = body
|
|
.split("\n")
|
|
.filter((line) => !/^\s*>/.test(line))
|
|
.join("\n");
|
|
} else {
|
|
const doc = new DOMParser().parseFromString(`<body>${body}</body>`, "text/html");
|
|
doc
|
|
.querySelectorAll("[data-quoted-html], blockquote")
|
|
.forEach((el) => el.remove());
|
|
text = htmlToPlainText(doc.body.innerHTML, { paragraphSpacing: true });
|
|
}
|
|
|
|
// Cut everything from the forwarded-message separator onward. htmlToPlainText
|
|
// collapses the separator's internal whitespace, so match with a
|
|
// whitespace-flexible, regex-escaped pattern rather than an exact string.
|
|
const trimmedSeparator = forwardedSeparator?.trim();
|
|
if (trimmedSeparator) {
|
|
const pattern = trimmedSeparator
|
|
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\s+/g, "\\s+");
|
|
const match = text.match(new RegExp(pattern));
|
|
if (match && match.index !== undefined) {
|
|
text = text.slice(0, match.index);
|
|
}
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Used for hook to let plugins enrich recipient chips with colors and icons.
|
|
* The icon is a key into ICON_MAP, which maps to an icon component.
|
|
*/
|
|
export const ICON_MAP = {
|
|
'lock': Lock,
|
|
'triangle-alert': TriangleAlert,
|
|
'ellipsis': Ellipsis,
|
|
};
|
|
type IconName = keyof typeof ICON_MAP;
|
|
|
|
/**
|
|
* A composer recipient. Display name is optional; email is required - except
|
|
* for contact-group chips, which carry their already-resolved members and an
|
|
* empty email. Group chips are expanded into their members when the message
|
|
* is sent or saved as a draft (see {@link expandRecipients}).
|
|
*/
|
|
export type Recipient = {
|
|
name?: string;
|
|
email: string;
|
|
group?: { members: Array<{ name?: string; email: string }> };
|
|
extra?: {
|
|
color?: "success" | "destructive" | "warning"; // optional color for display purposes. May be populated by plugins via the onRecipientChipsChange hook.
|
|
icon?: IconName; // optional icon for display purposes. May be populated by plugins via the onRecipientChipsChange hook.
|
|
enriched?: boolean; // optional flag to indicate if the recipient has been enriched by plugins via the onRecipientChipsChange hook.
|
|
};
|
|
};
|
|
|
|
/** Enriches recipient chips with colors and icons. */
|
|
export async function enrichChipsWithColorsAndIcons(chips: Recipient[]): Promise<Recipient[]> {
|
|
return await emailHooks.onRecipientChipsChange.transform(chips);
|
|
};
|
|
|
|
/**
|
|
* True when the angle run that is open at `from` closes before the next one
|
|
* opens. `from` is the index of the character under test (a separator, or the
|
|
* opening `<` itself); the scan starts after it either way. A `>` inside a
|
|
* quoted display name does not close anything, and a `<` with no `>` of its own
|
|
* (or whose `>` sits past a later `<`) can never be an address delimiter, so
|
|
* separators after it must stay separators.
|
|
*/
|
|
function angleRunCloses(value: string, from: number): boolean {
|
|
let inQuotes = false;
|
|
for (let i = from + 1; i < value.length; i++) {
|
|
const ch = value[i];
|
|
if (inQuotes && ch === '\\') i++;
|
|
else if (ch === '"') inQuotes = !inQuotes;
|
|
else if (inQuotes) continue;
|
|
else if (ch === '>') return true;
|
|
else if (ch === '<') return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Splits a recipient string into individual entries on any character in
|
|
* `separators`, treating those characters as literal when they sit inside a
|
|
* quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
|
|
* (`<a,b@x>`). Trims each part and drops empties.
|
|
*
|
|
* Defaults to comma-only, the (de)serialization boundary used by the composer
|
|
* state and mailto handling. Pasted lists pass a wider set (see
|
|
* {@link splitPasteEntries}) because they also use `;` and line breaks.
|
|
*/
|
|
export function splitRecipients(value: string, separators = ','): string[] {
|
|
const result: string[] = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
let inAngle = false;
|
|
let inGroup = false;
|
|
for (let i = 0; i < value.length; i++) {
|
|
const ch = value[i];
|
|
if (inQuotes && ch === '\\' && i + 1 < value.length) {
|
|
// A quoted-pair: formatRecipient writes a `"` in a display name as
|
|
// `\"`. Taking it as the closing quote let a sender's name like
|
|
// `Support", ceo@corp.example, "x` split into an extra recipient.
|
|
current += ch + value[i + 1];
|
|
i++;
|
|
} else if (ch === '"') {
|
|
inQuotes = !inQuotes;
|
|
current += ch;
|
|
} else if (ch === '<' && !inQuotes) {
|
|
inAngle = true;
|
|
current += ch;
|
|
} else if (ch === '>' && !inQuotes) {
|
|
inAngle = false;
|
|
current += ch;
|
|
} else if (separators.includes(ch) && inAngle && !inQuotes && !inGroup && !angleRunCloses(value, i)) {
|
|
// An unclosed `<` would otherwise swallow every later separator, folding
|
|
// the whole list into one entry that is not an address at all. Inside a
|
|
// group the separators belong to the group, so `inGroup` still wins.
|
|
inAngle = false;
|
|
const trimmed = current.trim();
|
|
if (trimmed) result.push(trimmed);
|
|
current = '';
|
|
} else if (ch === ':' && !inQuotes && !inAngle) {
|
|
// RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group,
|
|
// separators inside it included, as a single entry. A colon inside a
|
|
// display name is always quoted (see NAME_NEEDS_QUOTING), so a bare
|
|
// colon reliably opens a group.
|
|
inGroup = true;
|
|
current += ch;
|
|
} else if (ch === ';' && inGroup && !inQuotes && !inAngle) {
|
|
inGroup = false;
|
|
current += ch;
|
|
} else if (separators.includes(ch) && !inQuotes && !inAngle && !inGroup) {
|
|
const trimmed = current.trim();
|
|
if (trimmed) result.push(trimmed);
|
|
current = '';
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
const trimmed = current.trim();
|
|
if (trimmed) result.push(trimmed);
|
|
return result;
|
|
}
|
|
|
|
// Display names containing any of these must be wrapped in a quoted-string so
|
|
// they survive comma-splitting at the serialization boundary and round-trip.
|
|
const NAME_NEEDS_QUOTING = /[,<>"@;:]/;
|
|
|
|
/**
|
|
* Formats a recipient as a string. Bare email when there's no distinct name;
|
|
* otherwise `Name <email>`, RFC 5322 quoting the name when it contains a comma
|
|
* or other special character.
|
|
*/
|
|
export function formatRecipient(name: string | undefined, email: string): string {
|
|
const trimmedName = name?.trim();
|
|
if (!trimmedName || trimmedName === email) return email;
|
|
const quoted = NAME_NEEDS_QUOTING.test(trimmedName)
|
|
? `"${trimmedName.replace(/(["\\])/g, '\\$1')}"`
|
|
: trimmedName;
|
|
return `${quoted} <${email}>`;
|
|
}
|
|
|
|
/** Strips a surrounding quoted-string (and its escapes) from a display name. */
|
|
function unquoteName(name: string): string {
|
|
const trimmed = name.trim();
|
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
/** Index of the first colon outside quotes/angle brackets, or -1. */
|
|
function findTopLevelColon(value: string): number {
|
|
let inQuotes = false;
|
|
let inAngle = false;
|
|
for (let i = 0; i < value.length; i++) {
|
|
const ch = value[i];
|
|
if (inQuotes && ch === '\\') i++;
|
|
else if (ch === '"') inQuotes = !inQuotes;
|
|
else if (ch === '<' && !inQuotes) inAngle = true;
|
|
else if (ch === '>' && !inQuotes) inAngle = false;
|
|
else if (ch === ':' && !inQuotes && !inAngle) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
|
|
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
|
|
* RFC 5322 group syntax (`Team: a@x, b@y;`) parses into a group chip - it is
|
|
* how contact groups round-trip through the composer's string boundaries.
|
|
*/
|
|
export function parseRecipient(s: string): Recipient {
|
|
const trimmed = s.trim();
|
|
if (trimmed.endsWith(';')) {
|
|
const colon = findTopLevelColon(trimmed);
|
|
if (colon !== -1) {
|
|
const members = splitRecipients(trimmed.slice(colon + 1, -1))
|
|
.map(parseRecipient)
|
|
.filter((m) => m.email && !m.group);
|
|
// Only accept the group form when it actually carries members - typed
|
|
// garbage like "Subject: hello;" stays a plain (invalid) recipient.
|
|
if (members.length > 0) {
|
|
return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } };
|
|
}
|
|
}
|
|
}
|
|
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
|
if (angleMatch) {
|
|
return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() };
|
|
}
|
|
return { email: trimmed };
|
|
}
|
|
|
|
/** Parses a serialized comma-separated recipient string into an array. */
|
|
export function parseRecipientList(value: string): Recipient[] {
|
|
return splitRecipients(value).map(parseRecipient);
|
|
}
|
|
|
|
/**
|
|
* Formats a single composer recipient, using RFC 5322 group syntax for
|
|
* contact-group chips so they survive the composer's string boundaries
|
|
* (draft data, dirty compare, the contacts-page hand-off).
|
|
*/
|
|
export function formatRecipientEntry(r: Recipient): string {
|
|
if (r.group) {
|
|
const name = r.name?.trim() || 'Group';
|
|
const quoted = NAME_NEEDS_QUOTING.test(name)
|
|
? `"${name.replace(/(["\\])/g, '\\$1')}"`
|
|
: name;
|
|
const members = r.group.members.map((m) => formatRecipient(m.name, m.email)).join(', ');
|
|
return `${quoted}: ${members};`;
|
|
}
|
|
return formatRecipient(r.name, r.email);
|
|
}
|
|
|
|
/** Serializes a recipient array into a comma-separated string. */
|
|
export function formatRecipientList(recipients: Recipient[]): string {
|
|
return recipients.map(formatRecipientEntry).join(', ');
|
|
}
|
|
|
|
/**
|
|
* Expands contact-group chips into their members for sending and
|
|
* draft-saving. Deduplicates case-insensitively by address across the whole
|
|
* list, keeping the first occurrence - an explicitly added individual wins
|
|
* over the same address arriving again via a group.
|
|
*/
|
|
export function expandRecipients(recipients: Recipient[]): Recipient[] {
|
|
const seen = new Set<string>();
|
|
const out: Recipient[] = [];
|
|
for (const r of recipients) {
|
|
for (const entry of r.group ? r.group.members : [r]) {
|
|
const key = entry.email.trim().toLowerCase();
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push({ name: entry.name, email: entry.email });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Top-level split of a pasted block into recipient entries on commas,
|
|
* semicolons and newlines (separators inside a quoted name or angle brackets
|
|
* stay literal). Broader than the comma-only default of {@link splitRecipients}
|
|
* because pasted lists also use `;` and line breaks as separators.
|
|
*/
|
|
function splitPasteEntries(value: string): string[] {
|
|
return splitRecipients(value, ',;\n\r');
|
|
}
|
|
|
|
/** True when a whitespace/semicolon token of `value` is an address in its own right. */
|
|
function carriesAddress(value: string): boolean {
|
|
return value.split(/[\s;]+/).some((t) => isValidEmail(t.trim().replace(/^<|>$/g, '')));
|
|
}
|
|
|
|
/**
|
|
* Splits pasted text into recipient candidates and partitions them: valid email
|
|
* addresses become `Recipient`s (deduped case-insensitively against
|
|
* `existingEmails` and within the paste), and everything else is returned as
|
|
* `invalid` for the caller to drop back into the input field.
|
|
*
|
|
* Handles both structured and bare lists, preserving display names:
|
|
* - `"Name <email>"` (the whole recipient quoted), `Name <email>`, and
|
|
* `"Doe, John" <email>` entries are kept intact with their display name.
|
|
* - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/
|
|
* semicolon/newline separated) split into one chip per address.
|
|
* - A token wrapped in angle brackets (`<a@x.com>`) is unwrapped before
|
|
* validating, so an `a <a@x.com>` fragment still yields the address.
|
|
* - `Name <email` entries that lost their closing bracket keep both the name
|
|
* and the address instead of falling apart into bare words.
|
|
*/
|
|
export function splitPastedRecipients(
|
|
text: string,
|
|
existingEmails: string[] = [],
|
|
): { valid: Recipient[]; invalid: string[] } {
|
|
const seen = new Set(existingEmails.map((e) => e.toLowerCase()));
|
|
const valid: Recipient[] = [];
|
|
const invalid: string[] = [];
|
|
|
|
// Adds a recipient if its address is valid and unseen. Returns true when the
|
|
// entry is fully handled (valid or a known duplicate) so the caller can stop.
|
|
const tryAdd = (r: Recipient): boolean => {
|
|
if (!isValidEmail(r.email)) return false;
|
|
const key = r.email.toLowerCase();
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email });
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// Split on commas, semicolons and newlines in a quote/angle-aware way so a
|
|
// `"Doe, John" <j@x.com>` or fully-quoted `"Name <email>"` entry stays a
|
|
// single recipient (separators inside the name or the address are literal).
|
|
for (const entry of splitPasteEntries(text)) {
|
|
// 1. Structured: `Name <email>`, a bare address, or the whole
|
|
// `Name <email>` wrapped in quotes (unwrap once and retry).
|
|
if (tryAdd(parseRecipient(entry))) continue;
|
|
const unwrapped = unquoteName(entry);
|
|
if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue;
|
|
|
|
// 2. `Name <email` with the closing bracket lost - splitMailbox tolerates
|
|
// the missing `>`, so the display name survives the paste. It keeps the
|
|
// last angle run only, so everything ahead of it would become display
|
|
// name: skip this step when that prefix carries an address of its own
|
|
// (`a@x.com <b@y.com`) rather than silently swallowing it.
|
|
const lastOpenAngle = entry.lastIndexOf('<');
|
|
if (
|
|
lastOpenAngle !== -1 &&
|
|
!carriesAddress(entry.slice(0, lastOpenAngle)) &&
|
|
tryAdd(splitMailbox(entry))
|
|
) continue;
|
|
|
|
// 3. Fallback: a bare-address run (`a@x.com b@y.com`) or a
|
|
// `John Doe <j@x.com>` fragment where only the <addr> is valid.
|
|
// Whitespace/semicolon-tokenize; leftover tokens stay behind.
|
|
for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) {
|
|
if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token);
|
|
}
|
|
}
|
|
|
|
return { valid, invalid };
|
|
}
|
|
|
|
/**
|
|
* Replaces the placeholder src on `<img data-cid="...">` elements with the
|
|
* resolved data URL once the inline blob has been fetched. Leaves images
|
|
* whose src has been edited away from the placeholder/cid alone.
|
|
*/
|
|
export function replaceInlineImagePlaceholders(
|
|
html: string,
|
|
cidToDataUrl: Map<string, string>
|
|
): string {
|
|
if (!html || cidToDataUrl.size === 0) return html;
|
|
if (html.indexOf("data-cid") === -1) return html;
|
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
|
let changed = false;
|
|
doc.querySelectorAll("img[data-cid]").forEach((img) => {
|
|
const cid = img.getAttribute("data-cid");
|
|
if (!cid) return;
|
|
const dataUrl = cidToDataUrl.get(cid);
|
|
if (!dataUrl) return;
|
|
const currentSrc = img.getAttribute("src") || "";
|
|
if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return;
|
|
img.setAttribute("src", dataUrl);
|
|
changed = true;
|
|
});
|
|
return changed ? doc.body.innerHTML : html;
|
|
}
|
|
|
|
export type PendingUploadLike = {
|
|
uploading?: boolean;
|
|
error?: boolean;
|
|
};
|
|
|
|
export type PendingUploadWaitResult = "completed" | "cancelled" | "failed";
|
|
|
|
/**
|
|
* Wait for in-flight attachment uploads to settle before sending.
|
|
*
|
|
* Polls `getAttachments` until nothing is `uploading`, checking
|
|
* `isCancelled` between polls (composer closed / draft discarded).
|
|
* Resolves:
|
|
* - "cancelled" - cancellation was signalled while waiting
|
|
* - "failed" - uploads settled but at least one attachment errored;
|
|
* the caller must NOT auto-send (the user may not be
|
|
* looking at the composer to notice the failed chip)
|
|
* - "completed" - all uploads finished cleanly, safe to proceed
|
|
*/
|
|
export async function waitForPendingUploads(
|
|
getAttachments: () => readonly PendingUploadLike[],
|
|
isCancelled: () => boolean,
|
|
pollMs = 150
|
|
): Promise<PendingUploadWaitResult> {
|
|
while (getAttachments().some((att) => att.uploading)) {
|
|
if (isCancelled()) return "cancelled";
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
}
|
|
return getAttachments().some((att) => att.error) ? "failed" : "completed";
|
|
}
|