) => {
const newInputText = e.target.value;
- const chipPart = chips.length > 0 ? chips.join(', ') + ', ' : '';
- const newValue = chipPart + newInputText;
- onChange(newValue);
- onAutocomplete(newValue, field);
+ onInputChange(newInputText);
+ onAutocomplete(newInputText, field);
};
const commitCurrentInput = () => {
if (inputText.trim()) {
- const newChips = [...chips, inputText.trim()];
- onChange(newChips.join(', ') + ', ');
+ onChipsChange([...chips, parseRecipient(inputText)]);
+ onInputChange('');
}
};
@@ -2950,45 +2946,39 @@ function RecipientChipInput({
return;
}
+ // Backspace on an empty input pulls the last chip back into the input for
+ // quick editing.
if (e.key === 'Backspace' && !inputText && chips.length > 0) {
const lastChip = chips[chips.length - 1];
- const remainingChips = chips.slice(0, -1);
- const chipPart = remainingChips.length > 0 ? remainingChips.join(', ') + ', ' : '';
- onChange(chipPart + lastChip);
+ onChipsChange(chips.slice(0, -1));
+ onInputChange(formatRecipient(lastChip.name, lastChip.email));
return;
}
};
const handleChipRemove = (index: number, e: React.MouseEvent) => {
e.stopPropagation();
- const remainingChips = chips.filter((_, i) => i !== index);
- if (remainingChips.length > 0) {
- onChange(remainingChips.join(', ') + ', ' + inputText);
- } else {
- onChange(inputText);
- }
+ onChipsChange(chips.filter((_, i) => i !== index));
};
- const handleContextMenu = (e: React.MouseEvent, index: number, chip: string) => {
- openContextMenu(e, { index, chip });
+ const handleContextMenu = (e: React.MouseEvent, index: number, recipient: Recipient) => {
+ openContextMenu(e, { index, recipient });
};
const handleEditEmail = () => {
if (!contextMenu.data) return;
- const { index, chip } = contextMenu.data;
- const parsed = parseChip(chip);
+ const { index, recipient } = contextMenu.data;
closeContextMenu();
- setEditValue(parsed.email);
- setEditingChip({ index, chip, editType: 'email' });
+ setEditValue(recipient.email);
+ setEditingChip({ index, editType: 'email' });
};
const handleEditName = () => {
if (!contextMenu.data) return;
- const { index, chip } = contextMenu.data;
- const parsed = parseChip(chip);
+ const { index, recipient } = contextMenu.data;
closeContextMenu();
- setEditValue(parsed.name || '');
- setEditingChip({ index, chip, editType: 'name' });
+ setEditValue(recipient.name || '');
+ setEditingChip({ index, editType: 'name' });
};
const handleBlur = (e: React.FocusEvent) => {
@@ -2996,10 +2986,7 @@ function RecipientChipInput({
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
- if (inputText.trim()) {
- const newChips = [...chips, inputText.trim()];
- onChange(newChips.join(', ') + ', ');
- }
+ commitCurrentInput();
onAutoBlur(e, field);
};
@@ -3021,9 +3008,9 @@ function RecipientChipInput({
setIsDragOver(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
- const { chip: draggedChip, fromField } = JSON.parse(raw) as { chip: string; fromField: 'to' | 'cc' | 'bcc' };
+ const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField === field) return;
- onMoveChip(draggedChip, fromField, field);
+ onMoveChip(recipient, fromField, field);
};
return (
@@ -3044,14 +3031,14 @@ function RecipientChipInput({
const chipDisplay = formatChipDisplay(chip);
return (
{
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ chip, fromField: field }));
+ e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
// Show the address while dragging, matching the email-list drag preview.
- const dragPreview = createChipDragPreview(parseChip(chip).email);
+ const dragPreview = createChipDragPreview(chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
@@ -3163,7 +3150,7 @@ function RecipientChipInput({
{contextMenu.data && (
<>
- {formatChipDisplay(contextMenu.data.chip)}
+ {formatChipDisplay(contextMenu.data.recipient)}
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 80753f58..5a781e85 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -100,6 +100,8 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
+ markThreadAsRead,
+ threadEmailCounts,
searchFilters,
setSearchFilters,
clearSearchFilters,
@@ -110,9 +112,9 @@ export function EmailList({
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
- const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
+ const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups);
- }, [emails, disableThreading, isScheduledView]);
+ }, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -250,10 +252,12 @@ export function EmailList({
if (!isExpanded && client) {
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
+ // Mark all unread emails in this thread as read
+ void markThreadAsRead(client, threadId);
} else {
toggleThreadExpansion(threadId);
}
- }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
+ }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx
index ecbca751..49dd46d1 100644
--- a/components/pro/pro-compose-tab-body.tsx
+++ b/components/pro/pro-compose-tab-body.tsx
@@ -26,6 +26,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
+ const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
@@ -92,7 +93,24 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
- await fetchEmails(client, selectedMailbox);
+ await refreshCurrentMailbox(client);
+ // Re-fetch the replied thread's cross-folder data so the expanded
+ // view shows the newly sent reply without collapsing.
+ if (data.sourceEmailId) {
+ const emailState = useEmailStore.getState();
+ const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId);
+ if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
+ const accountId = client.getAccountId();
+ const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
+ if (fullEmails.length > 0) {
+ useEmailStore.setState((state) => {
+ const c = new Map(state.threadEmailsCache);
+ c.set(repliedEmail.threadId!, fullEmails);
+ return { threadEmailsCache: c };
+ });
+ }
+ }
+ }
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);
diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts
index 9b5a407c..9fdcbde2 100644
--- a/lib/__tests__/email-composer-utils.test.ts
+++ b/lib/__tests__/email-composer-utils.test.ts
@@ -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 , bob@example.com, ", "Alice ");
- 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" , alice@x.com')).toEqual([
+ '"Doo, John" ',
+ "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 , c@x.com")).toEqual([
+ "Group ",
+ "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 ");
});
- it("appends a chip with a display name", () => {
- const result = addChipToFieldValue("alice@example.com, ", "Bob ");
- expect(result).toBe("alice@example.com, Bob , ");
+ it("quotes a name containing a comma", () => {
+ expect(formatRecipient("Doo, John", "john@doo.org")).toBe(
+ '"Doo, John" '
+ );
+ });
+
+ 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" ')).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" , alice@x.com');
+ expect(parseRecipientList(serialized)).toEqual(list);
+ });
+
+ it("parses an empty string to an empty array", () => {
+ expect(parseRecipientList("")).toEqual([]);
});
});
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 2288490d..cc20d52d 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -388,6 +388,16 @@ export class DemoJMAPClient implements IJMAPClient {
return { id: threadId, emailIds: emails.map(e => e.id) };
}
+ async getThreads(threadIds: string[]): Promise {
+ 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 {
return this.data.emails
.filter(e => e.threadId === threadId)
diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts
index ed1f2d13..4f869074 100644
--- a/lib/email-composer-utils.ts
+++ b/lib/email-composer-utils.ts
@@ -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 , 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" `) or angle brackets
+ * (``) 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 `, 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 `, `"Quoted, Name" `,
+ * 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(', ');
}
/**
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 6a55a8f5..0624861e 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -117,6 +117,7 @@ export interface IJMAPClient {
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise;
+ getThreads(threadIds: string[], accountId?: string): Promise;
getThreadEmails(threadId: string, accountId?: string): Promise;
// ── Compose / Send ────────────────────────────────────────────
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 501de5bb..7440d54c 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1904,6 +1904,24 @@ export class JMAPClient implements IJMAPClient {
}
}
+ async getThreads(threadIds: string[], accountId?: string): Promise {
+ 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 {
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 = {
[`mailboxIds/${draftMailboxId}`]: true,
'keywords/$draft': true,
+ 'keywords/$seen': true,
};
if (sentMailboxId) {
update[`mailboxIds/${sentMailboxId}`] = null;
diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts
index 65dfbae0..45ced51d 100644
--- a/lib/thread-utils.ts
+++ b/lib/thread-utils.ts
@@ -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,
+): 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,
});
}
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 53b25249..498e6173 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -60,6 +60,8 @@ interface EmailStore {
expandedThreadIds: Set;
threadEmailsCache: Map;
isLoadingThread: string | null;
+ // Full thread email counts (from Thread/get across all folders)
+ threadEmailCounts: Map;
// Keyword/tag filter
selectedKeyword: string | null;
@@ -199,6 +201,8 @@ interface EmailStore {
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
+ fetchThreadEmailCounts: (client: IJMAPClient) => Promise;
+ markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise;
// Mailbox management
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise;
@@ -519,6 +523,7 @@ export const useEmailStore = create((set, get) => ({
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
+ threadEmailCounts: new Map(),
// Keyword/tag filter
selectedKeyword: null,
@@ -565,6 +570,7 @@ export const useEmailStore = create((set, get) => ({
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
+ threadEmailCounts: new Map(),
isLoadingThread: null,
}),
fetchAccountMailboxes: async (client, accountId) => {
@@ -595,6 +601,7 @@ export const useEmailStore = create((set, get) => ({
selectedEmailIds: new Set(),
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
+ threadEmailCounts: new Map(),
}),
fetchTagCounts: async (client) => {
try {
@@ -617,6 +624,7 @@ export const useEmailStore = create((set, get) => ({
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
+ threadEmailCounts: new Map(),
isLoadingThread: null,
}),
setLoading: (loading) => set({ isLoading: loading }),
@@ -785,8 +793,14 @@ export const useEmailStore = create((set, get) => ({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
+ // Clear thread caches since the email list was fully replaced
+ threadEmailsCache: new Map(),
+ expandedThreadIds: new Set(),
+ isLoadingThread: null,
isLoading: false
});
+ // Fetch full thread counts in the background (non-blocking)
+ void get().fetchThreadEmailCounts(client);
} catch (error) {
console.error('Failed to fetch emails:', error);
set({
@@ -923,6 +937,10 @@ export const useEmailStore = create((set, get) => ({
totalEmails: result.total,
isLoadingMore: false
});
+ // Fetch full thread counts for newly loaded threads in the background
+ if (newEmails.length > 0) {
+ void get().fetchThreadEmailCounts(client);
+ }
} catch (error) {
console.error('Failed to load more emails:', error);
set({
@@ -1214,7 +1232,23 @@ export const useEmailStore = create((set, get) => ({
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
mailboxes: updatedMailboxes,
- processingReadStatus: newProcessingSet
+ processingReadStatus: newProcessingSet,
+ // Also update threadEmailsCache so expanded dropdowns reflect the change
+ threadEmailsCache: (() => {
+ let updated = false;
+ const newCache = new Map(state.threadEmailsCache);
+ for (const [tid, cachedEmails] of newCache) {
+ const idx = cachedEmails.findIndex(e => e.id === emailId);
+ if (idx !== -1) {
+ newCache.set(tid, cachedEmails.map((e, i) =>
+ i === idx ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
+ ));
+ updated = true;
+ break;
+ }
+ }
+ return updated ? newCache : state.threadEmailsCache;
+ })(),
};
});
} catch (error) {
@@ -2327,11 +2361,74 @@ export const useEmailStore = create((set, get) => ({
// hasMore should reflect whether there are still more emails beyond
// what we have loaded, using the fresh total from the server.
const hasMore = merged.length < (result.total || 0);
- set({
- emails: merged,
- hasMoreEmails: hasMore,
- totalEmails: result.total,
+
+ // Invalidate thread email caches for threads whose composition changed
+ // so expanded threads pick up new/removed emails.
+ const prevThreadIds = new Set(currentEmails.map(e => e.threadId));
+ const nextThreadIds = new Set(merged.map(e => e.threadId));
+ const changedThreadIds = new Set();
+ for (const tid of prevThreadIds) {
+ if (!nextThreadIds.has(tid)) changedThreadIds.add(tid);
+ }
+ for (const tid of nextThreadIds) {
+ if (!prevThreadIds.has(tid)) changedThreadIds.add(tid);
+ }
+ // Also check threads where the set of email IDs changed
+ const prevEmailsByThread = new Map>();
+ for (const e of currentEmails) {
+ if (!prevEmailsByThread.has(e.threadId)) prevEmailsByThread.set(e.threadId, new Set());
+ prevEmailsByThread.get(e.threadId)!.add(e.id);
+ }
+ const nextEmailsByThread = new Map>();
+ for (const e of merged) {
+ if (!nextEmailsByThread.has(e.threadId)) nextEmailsByThread.set(e.threadId, new Set());
+ nextEmailsByThread.get(e.threadId)!.add(e.id);
+ }
+ for (const [tid, nextIds] of nextEmailsByThread) {
+ const prevIds = prevEmailsByThread.get(tid);
+ if (!prevIds || prevIds.size !== nextIds.size) {
+ changedThreadIds.add(tid);
+ } else {
+ for (const id of nextIds) {
+ if (!prevIds.has(id)) { changedThreadIds.add(tid); break; }
+ }
+ }
+ }
+
+ set((state) => {
+ const newCache = new Map(state.threadEmailsCache);
+ for (const tid of changedThreadIds) {
+ newCache.delete(tid);
+ }
+ return {
+ emails: merged,
+ hasMoreEmails: hasMore,
+ totalEmails: result.total,
+ threadEmailsCache: newCache,
+ };
});
+
+ // Re-fetch cross-folder thread data for any currently expanded threads
+ // so they show the complete conversation (not just current-folder emails).
+ const expandedNow = get().expandedThreadIds;
+ if (expandedNow.size > 0) {
+ const effectiveClient2 = resolveActionClient(client);
+ const accountId = effectiveClient2.getAccountId();
+ for (const tid of expandedNow) {
+ void effectiveClient2.getThreadEmails(tid, accountId).then((fullEmails) => {
+ if (fullEmails.length > 0) {
+ set((state) => {
+ const c = new Map(state.threadEmailsCache);
+ c.set(tid, fullEmails);
+ return { threadEmailsCache: c };
+ });
+ }
+ });
+ }
+ }
+
+ // Fetch full thread counts in the background (non-blocking)
+ void get().fetchThreadEmailCounts(client);
}
} catch (error) {
console.error('Failed to refresh current mailbox:', error);
@@ -2401,6 +2498,90 @@ export const useEmailStore = create((set, get) => ({
}
},
+ markThreadAsRead: async (client, threadId) => {
+ const state = get();
+ const threadEmails = state.threadEmailsCache.get(threadId) ?? [];
+ const mainEmails = state.emails.filter(e => e.threadId === threadId);
+
+ // Combine unique emails from both sources
+ const allEmailMap = new Map();
+ for (const e of mainEmails) allEmailMap.set(e.id, e);
+ for (const e of threadEmails) allEmailMap.set(e.id, e);
+
+ const unreadIds = Array.from(allEmailMap.values())
+ .filter(e => !e.keywords?.$seen)
+ .map(e => e.id);
+
+ if (unreadIds.length === 0) return;
+
+ // Group by account for unified view support
+ const emailsById = new Map();
+ for (const e of allEmailMap.values()) emailsById.set(e.id, e);
+
+ // Group unread IDs by account client
+ const groups = new Map();
+ for (const id of unreadIds) {
+ const email = emailsById.get(id)!;
+ const { client: actionClient } = resolveEmailActionContext(email, client);
+ if (!groups.has(actionClient)) groups.set(actionClient, []);
+ groups.get(actionClient)!.push(id);
+ }
+
+ // Mark all as read on the server
+ try {
+ await Promise.all(
+ Array.from(groups.entries()).map(([actionClient, emailIds]) =>
+ actionClient.batchMarkAsRead(emailIds, true)
+ )
+ );
+ } catch (error) {
+ console.error('Failed to mark thread as read:', error);
+ return;
+ }
+
+ // Update local state
+ set((state) => {
+ const unreadSet = new Set(unreadIds);
+
+ const updatedEmails = state.emails.map(e =>
+ unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
+ );
+
+ // Update threadEmailsCache
+ const newCache = new Map(state.threadEmailsCache);
+ const cached = newCache.get(threadId);
+ if (cached) {
+ newCache.set(threadId, cached.map(e =>
+ unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
+ ));
+ }
+
+ // Update mailbox unread counters
+ const affectedEmails = state.emails.filter(e => unreadSet.has(e.id));
+ const updatedMailboxes = state.mailboxes.map(mailbox => {
+ let delta = 0;
+ for (const email of affectedEmails) {
+ if (email.mailboxIds?.[mailbox.id]) delta -= 1;
+ }
+ if (delta === 0) return mailbox;
+ return {
+ ...mailbox,
+ unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
+ unreadThreads: Math.max(0, mailbox.unreadThreads + delta),
+ };
+ });
+
+ return {
+ emails: updatedEmails,
+ threadEmailsCache: newCache,
+ mailboxes: updatedMailboxes,
+ selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id)
+ ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
+ : state.selectedEmail,
+ };
+ });
+ },
+
collapseAllThreads: () => {
set({
expandedThreadIds: new Set(),
@@ -2414,6 +2595,27 @@ export const useEmailStore = create((set, get) => ({
set({ threadEmailsCache: newCache });
},
+ fetchThreadEmailCounts: async (client) => {
+ const { emails } = get();
+ if (emails.length === 0) return;
+
+ const uniqueThreadIds = [...new Set(emails.map(e => e.threadId).filter(Boolean))];
+ if (uniqueThreadIds.length === 0) return;
+
+ try {
+ const effectiveClient = resolveActionClient(client);
+ const threads = await effectiveClient.getThreads(uniqueThreadIds);
+
+ const newCounts = new Map(get().threadEmailCounts);
+ for (const thread of threads) {
+ newCounts.set(thread.id, thread.emailIds?.length ?? 0);
+ }
+ set({ threadEmailCounts: newCounts });
+ } catch {
+ // Non-critical — fall back to inbox-only counts
+ }
+ },
+
// Mailbox management
createMailbox: async (client, name, parentId) => {
try {