Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -4,8 +4,11 @@ import {
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
INLINE_IMAGE_PLACEHOLDER,
|
||||
removeChipFromFieldValue,
|
||||
addChipToFieldValue,
|
||||
splitRecipients,
|
||||
formatRecipient,
|
||||
parseRecipient,
|
||||
parseRecipientList,
|
||||
formatRecipientList,
|
||||
} from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
@@ -115,59 +118,83 @@ describe("replaceInlineImagePlaceholders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeChipFromFieldValue", () => {
|
||||
it("removes the target chip and preserves others", () => {
|
||||
const result = removeChipFromFieldValue("alice@example.com, bob@example.com, ", "alice@example.com");
|
||||
expect(result).toBe("bob@example.com, ");
|
||||
describe("splitRecipients", () => {
|
||||
it("splits a plain comma-separated list", () => {
|
||||
expect(splitRecipients("alice@x.com, bob@x.com")).toEqual([
|
||||
"alice@x.com",
|
||||
"bob@x.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a chip with a display name", () => {
|
||||
const result = removeChipFromFieldValue("Alice <alice@example.com>, bob@example.com, ", "Alice <alice@example.com>");
|
||||
expect(result).toBe("bob@example.com, ");
|
||||
it("trims whitespace and drops empty segments", () => {
|
||||
expect(splitRecipients(" alice@x.com ,, bob@x.com ,")).toEqual([
|
||||
"alice@x.com",
|
||||
"bob@x.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles removing the only chip", () => {
|
||||
const result = removeChipFromFieldValue("alice@example.com, ", "alice@example.com");
|
||||
expect(result).toBe("");
|
||||
it("keeps a quoted display name containing a comma intact", () => {
|
||||
expect(splitRecipients('"Doo, John" <john@doo.org>, alice@x.com')).toEqual([
|
||||
'"Doo, John" <john@doo.org>',
|
||||
"alice@x.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the value unchanged when chip is not found", () => {
|
||||
const value = "alice@example.com, bob@example.com, ";
|
||||
expect(removeChipFromFieldValue(value, "carol@example.com")).toBe(value);
|
||||
it("does not split on a comma inside angle brackets", () => {
|
||||
expect(splitRecipients("Group <a,b@x.com>, c@x.com")).toEqual([
|
||||
"Group <a,b@x.com>",
|
||||
"c@x.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves in-progress input text after removing a chip", () => {
|
||||
const result = removeChipFromFieldValue("alice@example.com, bob@example.com, car", "alice@example.com");
|
||||
expect(result).toBe("bob@example.com, car");
|
||||
});
|
||||
|
||||
it("handles an empty field value", () => {
|
||||
expect(removeChipFromFieldValue("", "alice@example.com")).toBe("");
|
||||
});
|
||||
|
||||
it("removes only the first occurrence when chip appears multiple times", () => {
|
||||
const result = removeChipFromFieldValue("alice@example.com, alice@example.com, bob@example.com, ", "alice@example.com");
|
||||
expect(result).toBe("alice@example.com, bob@example.com, ");
|
||||
it("returns an empty array for an empty string", () => {
|
||||
expect(splitRecipients("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addChipToFieldValue", () => {
|
||||
it("appends a chip to a field with existing chips", () => {
|
||||
const result = addChipToFieldValue("alice@example.com, ", "bob@example.com");
|
||||
expect(result).toBe("alice@example.com, bob@example.com, ");
|
||||
describe("formatRecipient / parseRecipient", () => {
|
||||
it("returns a bare email when there is no name", () => {
|
||||
expect(formatRecipient(undefined, "a@x.com")).toBe("a@x.com");
|
||||
});
|
||||
|
||||
it("appends a chip to an empty field", () => {
|
||||
expect(addChipToFieldValue("", "alice@example.com")).toBe("alice@example.com, ");
|
||||
it("returns a bare email when the name equals the email", () => {
|
||||
expect(formatRecipient("a@x.com", "a@x.com")).toBe("a@x.com");
|
||||
});
|
||||
|
||||
it("preserves in-progress input text when appending", () => {
|
||||
const result = addChipToFieldValue("alice@example.com, bob", "carol@example.com");
|
||||
expect(result).toBe("alice@example.com, carol@example.com, bob");
|
||||
it("formats a simple name without quoting", () => {
|
||||
expect(formatRecipient("Alice", "a@x.com")).toBe("Alice <a@x.com>");
|
||||
});
|
||||
|
||||
it("appends a chip with a display name", () => {
|
||||
const result = addChipToFieldValue("alice@example.com, ", "Bob <bob@example.com>");
|
||||
expect(result).toBe("alice@example.com, Bob <bob@example.com>, ");
|
||||
it("quotes a name containing a comma", () => {
|
||||
expect(formatRecipient("Doo, John", "john@doo.org")).toBe(
|
||||
'"Doo, John" <john@doo.org>'
|
||||
);
|
||||
});
|
||||
|
||||
it("parses a bare email", () => {
|
||||
expect(parseRecipient("a@x.com")).toEqual({ email: "a@x.com" });
|
||||
});
|
||||
|
||||
it("parses and unquotes a quoted comma name", () => {
|
||||
expect(parseRecipient('"Doo, John" <john@doo.org>')).toEqual({
|
||||
name: "Doo, John",
|
||||
email: "john@doo.org",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRecipientList / formatRecipientList", () => {
|
||||
it("round-trips a comma-name recipient through serialize + parse", () => {
|
||||
const list = [
|
||||
{ name: "Doo, John", email: "john@doo.org" },
|
||||
{ email: "alice@x.com" },
|
||||
];
|
||||
const serialized = formatRecipientList(list);
|
||||
expect(serialized).toBe('"Doo, John" <john@doo.org>, alice@x.com');
|
||||
expect(parseRecipientList(serialized)).toEqual(list);
|
||||
});
|
||||
|
||||
it("parses an empty string to an empty array", () => {
|
||||
expect(parseRecipientList("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,6 +388,16 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return { id: threadId, emailIds: emails.map(e => e.id) };
|
||||
}
|
||||
|
||||
async getThreads(threadIds: string[]): Promise<Thread[]> {
|
||||
return threadIds
|
||||
.map(tid => {
|
||||
const emails = this.data.emails.filter(e => e.threadId === tid);
|
||||
if (emails.length === 0) return null;
|
||||
return { id: tid, emailIds: emails.map(e => e.id) };
|
||||
})
|
||||
.filter((t): t is Thread => t !== null);
|
||||
}
|
||||
|
||||
async getThreadEmails(threadId: string): Promise<Email[]> {
|
||||
return this.data.emails
|
||||
.filter(e => e.threadId === threadId)
|
||||
|
||||
+78
-23
@@ -52,37 +52,92 @@ export function rewriteCidImagesForEditor(html: string): string {
|
||||
return touched ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
/** A composer recipient. Display name is optional; email is required. */
|
||||
export type Recipient = { name?: string; email: string };
|
||||
|
||||
/**
|
||||
* Parses the chip array and trailing in-progress input text from a
|
||||
* comma-separated recipient field value (e.g. "Alice <a@x.com>, bob@x.com, b").
|
||||
* A trailing comma means "bob@x.com" is a committed chip and "b" is the live input.
|
||||
* Splits a comma-separated recipient string into individual entries. Commas
|
||||
* inside a quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
|
||||
* (`<a,b@x>`) are treated as literal, not separators. Only used at the
|
||||
* (de)serialization boundary — the live composer state is an array, so the UI
|
||||
* never round-trips through this. Trims each part and drops empties.
|
||||
*/
|
||||
function parseFieldValue(fieldValue: string): { chips: string[]; inputText: string } {
|
||||
const allParts = fieldValue.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const hasTrailingComma = fieldValue.trimEnd().endsWith(',');
|
||||
const chips = hasTrailingComma ? allParts : allParts.slice(0, -1);
|
||||
const inputText = hasTrailingComma ? '' : (allParts[allParts.length - 1] ?? '');
|
||||
return { chips, inputText };
|
||||
export function splitRecipients(value: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let inAngle = false;
|
||||
for (const ch of value) {
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += ch;
|
||||
} else if (ch === '<' && !inQuotes) {
|
||||
inAngle = true;
|
||||
current += ch;
|
||||
} else if (ch === '>' && !inQuotes) {
|
||||
inAngle = false;
|
||||
current += ch;
|
||||
} else if (ch === ',' && !inQuotes && !inAngle) {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) result.push(trimmed);
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) result.push(trimmed);
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildFieldValue(chips: string[], inputText: string): string {
|
||||
if (chips.length === 0) return inputText;
|
||||
return chips.join(', ') + ', ' + inputText;
|
||||
// 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}>`;
|
||||
}
|
||||
|
||||
/** Removes the first occurrence of `chip` from a recipient field value string. */
|
||||
export function removeChipFromFieldValue(fieldValue: string, chip: string): string {
|
||||
const { chips, inputText } = parseFieldValue(fieldValue);
|
||||
const idx = chips.indexOf(chip);
|
||||
if (idx === -1) return fieldValue;
|
||||
const remaining = chips.filter((_, i) => i !== idx);
|
||||
return buildFieldValue(remaining, inputText);
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Appends `chip` as a committed entry to a recipient field value string. */
|
||||
export function addChipToFieldValue(fieldValue: string, chip: string): string {
|
||||
const { chips, inputText } = parseFieldValue(fieldValue);
|
||||
return buildFieldValue([...chips, chip], inputText);
|
||||
/**
|
||||
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
|
||||
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
|
||||
*/
|
||||
export function parseRecipient(s: string): Recipient {
|
||||
const trimmed = s.trim();
|
||||
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);
|
||||
}
|
||||
|
||||
/** Serializes a recipient array into a comma-separated string. */
|
||||
export function formatRecipientList(recipients: Recipient[]): string {
|
||||
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface IJMAPClient {
|
||||
|
||||
// ── Threads ───────────────────────────────────────────────────
|
||||
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
|
||||
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
|
||||
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
|
||||
|
||||
// ── Compose / Send ────────────────────────────────────────────
|
||||
|
||||
+20
-1
@@ -1904,6 +1904,24 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getThreads(threadIds: string[], accountId?: string): Promise<Thread[]> {
|
||||
if (threadIds.length === 0) return [];
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
||||
return (response.methodResponses[0][1].list || []) as Thread[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get threads:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
@@ -2175,7 +2193,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
cc: cc?.length ? cc.map(email => ({ email })) : undefined,
|
||||
bcc: bcc?.length ? bcc.map(email => ({ email })) : undefined,
|
||||
subject,
|
||||
keywords: { "$draft": true },
|
||||
keywords: { "$seen": true, "$draft": true },
|
||||
mailboxIds: { [draftsMailbox.id]: true },
|
||||
bodyValues: htmlBody
|
||||
? { "text": { value: body }, "html": { value: htmlBody } }
|
||||
@@ -6259,6 +6277,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const update: Record<string, unknown> = {
|
||||
[`mailboxIds/${draftMailboxId}`]: true,
|
||||
'keywords/$draft': true,
|
||||
'keywords/$seen': true,
|
||||
};
|
||||
if (sentMailboxId) {
|
||||
update[`mailboxIds/${sentMailboxId}`] = null;
|
||||
|
||||
+10
-2
@@ -5,8 +5,16 @@ import type { Email, ThreadGroup } from "./jmap/types";
|
||||
* Single-email threads are still returned as ThreadGroups with emailCount=1.
|
||||
* When disableThreading is true, each email is placed into its own group using
|
||||
* its message ID as the key, so the list shows individual messages.
|
||||
*
|
||||
* @param threadEmailCounts - Optional map of threadId → total email count across
|
||||
* all folders (from Thread/get). When provided, emailCount reflects the full
|
||||
* thread size rather than just the emails in the current folder.
|
||||
*/
|
||||
export function groupEmailsByThread(emails: Email[], disableThreading = false): ThreadGroup[] {
|
||||
export function groupEmailsByThread(
|
||||
emails: Email[],
|
||||
disableThreading = false,
|
||||
threadEmailCounts?: Map<string, number>,
|
||||
): ThreadGroup[] {
|
||||
if (!emails || emails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -53,7 +61,7 @@ export function groupEmailsByThread(emails: Email[], disableThreading = false):
|
||||
hasAttachment,
|
||||
hasAnswered,
|
||||
hasForwarded,
|
||||
emailCount: sortedEmails.length,
|
||||
emailCount: threadEmailCounts?.get(threadId) ?? sortedEmails.length,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user