@agenticmail/api 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2714,30 +2714,75 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
2714
2714
  const folder = req.query.folder || "INBOX";
2715
2715
  const password = getAgentPassword(agent);
2716
2716
  const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2717
- const mailboxInfo = await receiver.getMailboxInfo(folder);
2718
- const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
2719
- const uids = envelopes.map((e) => e.uid);
2720
- const rawMap = uids.length > 0 ? await receiver.batchFetch(uids, folder) : /* @__PURE__ */ new Map();
2721
- const messages = [];
2722
- for (const env of envelopes) {
2723
- let preview = "";
2717
+ const PREVIEW_MAX_BYTES = 8192;
2718
+ const client = receiver.getImapClient();
2719
+ const lock = await client.getMailboxLock(folder);
2720
+ const envelopes = [];
2721
+ const rawMap = /* @__PURE__ */ new Map();
2722
+ let total = 0;
2723
+ try {
2724
+ const searchResult = await client.search({ all: true }, { uid: true });
2725
+ const allUids = Array.isArray(searchResult) ? searchResult : [];
2726
+ total = allUids.length;
2727
+ const sorted = allUids.slice().sort((a, b) => b - a);
2728
+ const pageUids = sorted.slice(offset, offset + limit);
2729
+ if (pageUids.length > 0) {
2730
+ for await (const msg of client.fetch(pageUids.join(","), {
2731
+ uid: true,
2732
+ envelope: true,
2733
+ flags: true,
2734
+ size: true
2735
+ })) {
2736
+ const env = msg.envelope;
2737
+ if (!env) continue;
2738
+ envelopes.push({
2739
+ uid: msg.uid,
2740
+ subject: env.subject ?? "",
2741
+ from: (env.from ?? []).map((a) => ({ name: a.name, address: a.address ?? "" })),
2742
+ to: (env.to ?? []).map((a) => ({ name: a.name, address: a.address ?? "" })),
2743
+ date: env.date ?? /* @__PURE__ */ new Date(),
2744
+ flags: msg.flags ? [...msg.flags] : [],
2745
+ size: msg.size ?? 0
2746
+ });
2747
+ }
2748
+ envelopes.sort((a, b) => b.uid - a.uid);
2749
+ for await (const msg of client.fetch(pageUids.join(","), {
2750
+ uid: true,
2751
+ source: { start: 0, maxLength: PREVIEW_MAX_BYTES }
2752
+ })) {
2753
+ if (msg.source) {
2754
+ rawMap.set(
2755
+ msg.uid,
2756
+ Buffer.isBuffer(msg.source) ? msg.source : Buffer.from(msg.source)
2757
+ );
2758
+ }
2759
+ }
2760
+ }
2761
+ } finally {
2762
+ lock.release();
2763
+ }
2764
+ const messages = await Promise.all(envelopes.map(async (env) => {
2724
2765
  const raw = rawMap.get(env.uid);
2766
+ let preview = "";
2725
2767
  if (raw) {
2726
- const parsed = await parseEmail2(raw);
2727
- preview = (parsed.text || "").slice(0, previewLen);
2768
+ try {
2769
+ const parsed = await parseEmail2(raw);
2770
+ preview = (parsed.text || "").slice(0, previewLen);
2771
+ } catch {
2772
+ }
2728
2773
  }
2729
- messages.push({
2774
+ return {
2730
2775
  uid: env.uid,
2731
2776
  subject: env.subject,
2732
2777
  from: env.from,
2733
2778
  to: env.to,
2734
2779
  date: env.date,
2735
- flags: [...env.flags],
2780
+ flags: env.flags,
2736
2781
  size: env.size,
2737
2782
  preview
2738
- });
2739
- }
2740
- res.json({ messages, count: messages.length, total: mailboxInfo.exists });
2783
+ };
2784
+ }));
2785
+ res.json({ messages, count: messages.length, total });
2741
2786
  } catch (err) {
2742
2787
  next(err);
2743
2788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "uuid": "^11.1.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@agenticmail/claudecode": "^0.2.2"
39
+ "@agenticmail/claudecode": "^0.2.4"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/cors": "^2.8.17",
package/public/js/app.js CHANGED
@@ -95,6 +95,19 @@ async function bootstrap() {
95
95
  const initial = (lastId && state.agents.find(a => a.id === lastId))
96
96
  ?? state.agents.find(isBridgeAgent)
97
97
  ?? state.agents[0];
98
+ // Seed the URL hash BEFORE selectAgent so selectAgent's loadList
99
+ // call lands on the right folder. We use history.replaceState
100
+ // (NOT `location.hash = ...`) so this does NOT fire a hashchange
101
+ // event — that would trigger a second route() → loadList() in
102
+ // parallel with selectAgent's, doubling the work on every
103
+ // bootstrap. Read the hash first so a deep-link refresh
104
+ // (e.g. /#/folder/sent) still wins.
105
+ const folderMatch = location.hash.match(/^#\/folder\/([a-z]+)$/);
106
+ if (folderMatch) {
107
+ state.selectedFolder = folderMatch[1];
108
+ } else if (!location.hash) {
109
+ history.replaceState(null, '', `${location.pathname}${location.search}#/folder/inbox`);
110
+ }
98
111
  if (initial) await selectAgent(initial);
99
112
  renderProfile();
100
113
  populateComposeFrom();
@@ -105,10 +118,14 @@ async function bootstrap() {
105
118
  // rendering. Idempotent — safe to call after bootstrap reruns.
106
119
  subscribeToActivity();
107
120
  maybeRequestNotificationPermission();
108
- // Initial route: if the URL already has a hash (e.g. a refresh
109
- // on /#/folder/sent), respect it; otherwise default to inbox.
110
- if (!location.hash) location.hash = '#/folder/inbox';
111
- else route();
121
+ // If the URL points at a message (not a folder), open it now —
122
+ // the folder list selectAgent already loaded stays in the
123
+ // background. Folder hashes need no extra work; selectAgent's
124
+ // loadList already handled them above.
125
+ const hash = location.hash;
126
+ const msgMatch = hash.match(/^#\/m\/(\d+)$/);
127
+ const draftMatch = hash.match(/^#\/d\/([a-zA-Z0-9-]+)$/);
128
+ if (msgMatch || draftMatch) route();
112
129
  } catch (err) {
113
130
  toast(`Failed to load agents: ${err.message}`, true);
114
131
  }
@@ -176,14 +193,18 @@ function route() {
176
193
  }
177
194
  const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
178
195
  const folder = folderMatch ? folderMatch[1] : 'inbox';
179
- if (state.selectedFolder !== folder) {
180
- state.selectedFolder = folder;
181
- // Reset pagination on every folder switcha fresh folder
182
- // starts at page 1. Preserved across silent SSE refreshes so
183
- // a new arrival doesn't yank the user back from page 3.
184
- state.pagination = { offset: 0, limit: 50, total: 0 };
185
- renderSidebar(onFolderSelect);
186
- }
196
+ // Only do work if the folder actually changed. Re-firing loadList
197
+ // for the SAME folder on every hashchange (e.g. closing a message
198
+ // detail back to the list) makes the UI feel sluggish the list
199
+ // is already rendered, and a second digest fetch just churns
200
+ // through 50 messages on the IMAP server for no visible change.
201
+ if (state.selectedFolder === folder) return;
202
+ state.selectedFolder = folder;
203
+ // Reset pagination on every folder switch — a fresh folder
204
+ // starts at page 1. Preserved across silent SSE refreshes so
205
+ // a new arrival doesn't yank the user back from page 3.
206
+ state.pagination = { offset: 0, limit: 50, total: 0 };
207
+ renderSidebar(onFolderSelect);
187
208
  if (state.selectedAgent) loadList(state.selectedAgent, folder);
188
209
  }
189
210
  window.addEventListener('hashchange', route);
@@ -359,7 +359,15 @@ export function renderList() {
359
359
  const prevBtn = document.getElementById('pager-prev');
360
360
  const nextBtn = document.getElementById('pager-next');
361
361
  if (prevBtn) prevBtn.disabled = offset <= 0;
362
- if (nextBtn) nextBtn.disabled = pageEnd >= total || state.messages.length < limit;
362
+ // Drive Next purely from the server-reported total. The previous
363
+ // `state.messages.length < limit` clause was meant as a "we hit the
364
+ // end" heuristic for the no-total fallback case, but it backfired
365
+ // on every folder that legitimately had fewer-than-`limit` items on
366
+ // a page (e.g. trailing partial page after deletions, or a folder
367
+ // whose IMAP STATUS returned a stale low count) — Next stayed
368
+ // permanently disabled. The new digest endpoint always returns an
369
+ // authoritative SEARCH-derived total, so a single check is enough.
370
+ if (nextBtn) nextBtn.disabled = pageEnd >= total;
363
371
 
364
372
  if (filtered.length === 0) {
365
373
  root.innerHTML = q