@agenticmail/api 0.9.10 → 0.9.12

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
@@ -1471,6 +1471,20 @@ function createEventRoutes(accountManager, config, db) {
1471
1471
  safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
1472
1472
 
1473
1473
  `);
1474
+ const broadcastNew = (e) => {
1475
+ safeWrite(`data: ${JSON.stringify(e)}
1476
+
1477
+ `);
1478
+ try {
1479
+ pushSystemEvent({
1480
+ type: "new_mail",
1481
+ agentId: agent.id,
1482
+ agentName: agent.name,
1483
+ event: e
1484
+ });
1485
+ } catch {
1486
+ }
1487
+ };
1474
1488
  watcher.on("new", async (event) => {
1475
1489
  if (db) touchActivity(db, agent.id);
1476
1490
  if (db && event.uid) {
@@ -1512,9 +1526,7 @@ function createEventRoutes(accountManager, config, db) {
1512
1526
  if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
1513
1527
  event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
1514
1528
  }
1515
- safeWrite(`data: ${JSON.stringify(event)}
1516
-
1517
- `);
1529
+ broadcastNew(event);
1518
1530
  return;
1519
1531
  }
1520
1532
  const spamResult = scoreEmail(parsed);
@@ -1540,9 +1552,7 @@ function createEventRoutes(accountManager, config, db) {
1540
1552
  }
1541
1553
  await receiver.moveMessage(event.uid, "INBOX", "Spam");
1542
1554
  event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
1543
- safeWrite(`data: ${JSON.stringify(event)}
1544
-
1545
- `);
1555
+ broadcastNew(event);
1546
1556
  return;
1547
1557
  }
1548
1558
  if (spamResult.isWarning) {
@@ -1566,18 +1576,7 @@ function createEventRoutes(accountManager, config, db) {
1566
1576
  console.error("[SSE] Spam/rule evaluation error:", err.message);
1567
1577
  }
1568
1578
  }
1569
- safeWrite(`data: ${JSON.stringify(event)}
1570
-
1571
- `);
1572
- try {
1573
- pushSystemEvent({
1574
- type: "new_mail",
1575
- agentId: agent.id,
1576
- agentName: agent.name,
1577
- event
1578
- });
1579
- } catch {
1580
- }
1579
+ broadcastNew(event);
1581
1580
  });
1582
1581
  watcher.on("expunge", (event) => {
1583
1582
  safeWrite(`data: ${JSON.stringify(event)}
@@ -2572,19 +2571,22 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
2572
2571
  router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
2573
2572
  try {
2574
2573
  const agent = req.agent;
2575
- const { uids: rawUids, from: fromFolder, to: toFolder } = req.body || {};
2576
- const uids = validateUids(rawUids);
2574
+ const body = req.body || {};
2575
+ const uids = validateUids(body.uids);
2577
2576
  if (!uids) {
2578
2577
  res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
2579
2578
  return;
2580
2579
  }
2580
+ const fromFolder = body.from ?? body.folder;
2581
+ const toFolder = body.to ?? body.toFolder;
2581
2582
  if (!toFolder) {
2582
- res.status(400).json({ error: "to (destination folder) is required" });
2583
+ res.status(400).json({ error: "destination folder is required (pass as `to` or `toFolder`)" });
2583
2584
  return;
2584
2585
  }
2585
2586
  const password = getAgentPassword(agent);
2586
2587
  const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2587
2588
  await receiver.batchMove(uids, fromFolder || "INBOX", toFolder);
2589
+ for (const uid of uids) invalidateParsedMessage(agent.id, uid);
2588
2590
  res.json({ ok: true, moved: uids.length });
2589
2591
  } catch (err) {
2590
2592
  next(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
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",
package/public/index.html CHANGED
@@ -42,8 +42,16 @@
42
42
  <!-- Real-time worker activity badges. Populated by SSE; one
43
43
  badge per active dispatcher worker showing what each
44
44
  agent is doing right now (reading, editing, running
45
- shell, etc.). Empty when no workers are active. -->
46
- <div id="activity-badges" class="activity-badges"></div>
45
+ shell, etc.). Empty when no workers are active.
46
+
47
+ The count pill is always rendered first by activity-badges.js
48
+ when there's at least one worker — gives users a stable
49
+ "3 active" affordance independent of how many badges fit
50
+ in the horizontal-scroll viewport. -->
51
+ <div id="activity-badges-shell" class="activity-badges-shell" hidden>
52
+ <span id="activity-badges-count" class="activity-badges-count" title="Active agents"></span>
53
+ <div id="activity-badges" class="activity-badges"></div>
54
+ </div>
47
55
  <button class="icon-btn" id="sound-toggle-btn" title="Notification sound"></button>
48
56
  <button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
49
57
  <button id="profile-btn" class="profile-trigger" title="Account">
@@ -15,6 +15,8 @@ import { onSystemEvent } from './system-stream.js';
15
15
  import { state, API_URL } from './state.js';
16
16
 
17
17
  const BADGE_CONTAINER_ID = 'activity-badges';
18
+ const SHELL_ID = 'activity-badges-shell';
19
+ const COUNT_ID = 'activity-badges-count';
18
20
 
19
21
  /**
20
22
  * Map of workerId → { agentName, kind, lastTool, turnCount,
@@ -59,8 +61,20 @@ function statusFor(lastTool) {
59
61
 
60
62
  function render() {
61
63
  const root = document.getElementById(BADGE_CONTAINER_ID);
64
+ const shell = document.getElementById(SHELL_ID);
65
+ const countEl = document.getElementById(COUNT_ID);
62
66
  if (!root) return;
63
67
  const list = Array.from(workers.values()).sort((a, b) => (a.startedAtMs ?? 0) - (b.startedAtMs ?? 0));
68
+ // Drive the shell + count pill so users see "3 active" even when only
69
+ // 2 badges fit in the scroll viewport. Empty state hides the whole
70
+ // shell so the topbar doesn't carry a dangling "0" pill.
71
+ if (shell) shell.hidden = list.length === 0;
72
+ if (countEl) {
73
+ countEl.textContent = String(list.length);
74
+ countEl.title = list.length === 1
75
+ ? '1 agent active'
76
+ : `${list.length} agents active`;
77
+ }
64
78
  if (list.length === 0) { root.innerHTML = ''; return; }
65
79
  root.innerHTML = list.map(w => {
66
80
  const initial = (w.agentName ?? '?').slice(0, 1).toUpperCase();
@@ -157,7 +157,18 @@ export async function loadList(agent, folder) {
157
157
  // side flag filter (Gmail convention); other folders need a real
158
158
  // mailbox name from the discovery cache.
159
159
  const isStarred = folder === 'starred';
160
- const imap = isStarred ? 'INBOX' : imapNameFor(folder);
160
+ let imap = isStarred ? 'INBOX' : imapNameFor(folder);
161
+ if (!imap) {
162
+ // The cache was populated at agent-switch time; some folders are
163
+ // created LATER on demand (the API auto-creates Archive on first
164
+ // archive, Spam on first report-as-spam). If we don't see the
165
+ // requested folder in the cache, force a fresh discovery once
166
+ // before declaring "no such folder" — covers the "moved to
167
+ // Archive but Archive tab is empty" report.
168
+ state.folderNames = {};
169
+ await ensureFolderCache(agent);
170
+ imap = imapNameFor(folder);
171
+ }
161
172
  if (!imap) {
162
173
  document.getElementById('list-rows').innerHTML =
163
174
  `<div class="empty"><div class="big">📭</div>No ${escapeHtml(folderTitle(folder))} folder on this server.</div>`;
package/public/styles.css CHANGED
@@ -170,14 +170,49 @@ a { color: var(--accent-strong); }
170
170
  events. Each badge shows the agent's initial + name + a short
171
171
  verb derived from the worker's `lastTool`. The green dot
172
172
  gently pulses so the row reads as "alive" rather than static. */
173
+ /* Shell wraps the always-visible count pill + the scrollable badge
174
+ row. Two children, side-by-side; the count never scrolls so the
175
+ user can see how many agents are active even when the scroll viewport
176
+ is full. */
177
+ .activity-badges-shell {
178
+ display: flex; align-items: center; gap: 6px;
179
+ margin-right: 4px;
180
+ min-width: 0; /* lets the inner scrollable strip shrink without
181
+ pushing siblings off the topbar */
182
+ }
183
+ .activity-badges-count {
184
+ display: inline-flex; align-items: center; justify-content: center;
185
+ height: 22px; min-width: 22px; padding: 0 7px;
186
+ background: var(--pink, #ff4d8c);
187
+ color: #fff;
188
+ font-size: 11px; font-weight: 700;
189
+ border-radius: 999px;
190
+ flex-shrink: 0;
191
+ user-select: none;
192
+ }
193
+ /* Horizontal-scrolling strip of badges. We deliberately allow
194
+ `overflow-x: auto` so the user can swipe / trackpad-scroll through
195
+ more than fit on screen, and use an edge fade mask so it's visually
196
+ clear there's more content past the right edge. Scrollbar stays
197
+ hidden (cleaner topbar) but the count pill + edge fade make
198
+ "there's more" discoverable. */
173
199
  .activity-badges {
174
200
  display: flex; align-items: center; gap: 6px;
175
201
  max-width: 480px;
176
202
  overflow-x: auto;
177
203
  scrollbar-width: none;
178
- margin-right: 4px;
204
+ -ms-overflow-style: none;
205
+ /* Mask creates a gentle fade-out on each end so badges visually
206
+ hint "I'm continuing past the viewport edge". Pure CSS, no JS. */
207
+ mask-image: linear-gradient(to right, transparent 0, black 12px, black calc(100% - 16px), transparent 100%);
208
+ -webkit-mask-image: linear-gradient(to right, transparent 0, black 12px, black calc(100% - 16px), transparent 100%);
209
+ /* Snap-scroll so swiping never leaves a badge half-clipped. */
210
+ scroll-snap-type: x proximity;
211
+ /* Ensure the inner strip can shrink inside its flex parent. */
212
+ min-width: 0;
179
213
  }
180
214
  .activity-badges::-webkit-scrollbar { display: none; }
215
+ .activity-badge { scroll-snap-align: start; }
181
216
  .activity-badge {
182
217
  display: inline-flex; align-items: center; gap: 6px;
183
218
  padding: 4px 10px 4px 6px;
@@ -213,10 +248,10 @@ a { color: var(--accent-strong); }
213
248
  .activity-badge .badge-status { color: var(--muted); }
214
249
  .activity-badge .badge-status::before { content: '· '; opacity: .6; }
215
250
 
216
- /* Mobile — hide badges on narrow viewports; they fold to the
217
- topbar's account avatar dropdown anyway. */
251
+ /* Mobile — hide the whole activity-badge shell (count pill + scrolling
252
+ strip) on narrow viewports; folds into the profile dropdown anyway. */
218
253
  @media (max-width: 800px) {
219
- .activity-badges { display: none; }
254
+ .activity-badges-shell { display: none !important; }
220
255
  }
221
256
 
222
257
  /* ─── Profile (top right) ─────────────────────────────────────────── */