@agenticmail/cli 0.9.27 → 0.9.29

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/cli.js CHANGED
@@ -438,6 +438,14 @@ function fail(msg) {
438
438
  function info(msg) {
439
439
  log(` ${c.dim(msg)}`);
440
440
  }
441
+ function maskApiKey(key) {
442
+ if (!key || typeof key !== "string") return "***";
443
+ if (key.length <= 8) return "***";
444
+ const underscore = key.indexOf("_");
445
+ const prefix = underscore > 0 && underscore <= 4 ? key.slice(0, underscore + 1) : "";
446
+ const tail = key.slice(-4);
447
+ return `${prefix}***${tail}`;
448
+ }
441
449
  function cleanFilePath(raw) {
442
450
  let p = raw.trim();
443
451
  if (p.startsWith("'") && p.endsWith("'") || p.startsWith('"') && p.endsWith('"')) {
@@ -950,7 +958,7 @@ async function interactiveShell(options) {
950
958
  const displayName = owner ? `${agent.name} from ${owner}` : agent.name;
951
959
  const active = currentAgent?.name === agent.name ? c.green(" \u25C2 active") : "";
952
960
  log(` ${c.cyan(displayName.padEnd(24))} ${c.dim(agent.email || "")}${active}`);
953
- log(` ${" ".repeat(24)} ${c.dim("key:")} ${c.yellow(agent.apiKey?.slice(0, 16) + "...")}`);
961
+ log(` ${" ".repeat(24)} ${c.dim("key:")} ${c.yellow(maskApiKey(agent.apiKey))}`);
954
962
  log("");
955
963
  }
956
964
  if (agents.length > 1) {
@@ -1014,7 +1022,7 @@ async function interactiveShell(options) {
1014
1022
  const created = await createResp.json();
1015
1023
  ok(`Agent ${c.bold('"' + created.name + '"')} created!`);
1016
1024
  log(` ${c.dim("Email:")} ${c.cyan(created.email || created.subAddress || "")}`);
1017
- log(` ${c.dim("Key:")} ${c.yellow(created.apiKey)}`);
1025
+ log(` ${c.dim("Key:")} ${c.yellow(created.apiKey)} ${c.dim("(save this \u2014 only shown once)")}`);
1018
1026
  log(` ${c.dim("Role:")} ${role}`);
1019
1027
  currentAgent = { name: created.name, email: created.email || created.subAddress, apiKey: created.apiKey };
1020
1028
  ok(`Switched to ${c.bold(created.name)}`);
@@ -3960,7 +3968,7 @@ ${c.dim(boxChar.bl + boxChar.h.repeat(bWidth) + boxChar.br)}`);
3960
3968
  if (data.agent) {
3961
3969
  ok(`Agent ${c.bold('"' + data.agent.name + '"')} is ready!`);
3962
3970
  log(` ${c.dim("Email:")} ${c.cyan(data.agent.subAddress)}`);
3963
- log(` ${c.dim("Key:")} ${c.yellow(data.agent.apiKey)}`);
3971
+ log(` ${c.dim("Key:")} ${c.yellow(data.agent.apiKey)} ${c.dim("(save this \u2014 only shown once)")}`);
3964
3972
  currentAgent = { name: data.agent.name, email: data.agent.email || data.agent.subAddress, apiKey: data.agent.apiKey };
3965
3973
  }
3966
3974
  log("");
@@ -43,6 +43,15 @@ async function signIn() {
43
43
  headers: { Authorization: `Bearer ${key}` },
44
44
  });
45
45
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
46
+ // The master key is persisted in localStorage so the operator
47
+ // doesn't have to re-type it on every page load. CodeQL
48
+ // `js/clear-text-storage-of-sensitive-data` flags this, but
49
+ // the threat model is self-hosted: the web UI binds to
50
+ // 127.0.0.1, the key never crosses the network, and the
51
+ // operator's local filesystem is already trusted by every
52
+ // other layer of the install. The realistic alternative
53
+ // (HttpOnly cookie + server-side session) requires a network
54
+ // boundary that doesn't exist here. lgtm[js/clear-text-storage-of-sensitive-data]
46
55
  localStorage.setItem('agenticmail.masterKey', key);
47
56
  state.masterKey = key;
48
57
  document.getElementById('auth').style.display = 'none';
@@ -350,6 +350,13 @@ function renderAttachmentChips() {
350
350
  const root = document.getElementById('compose-attachments');
351
351
  if (!root) return;
352
352
  if (pendingAttachments.length === 0) { root.innerHTML = ''; return; }
353
+ // Every operator-controlled string interpolated below goes through
354
+ // `escapeHtml`. `i` is a number index, `formatBytes(...)` only ever
355
+ // returns numeric-shape strings like "1.2 KB". CodeQL
356
+ // `js/xss-through-dom` flags the innerHTML write conservatively
357
+ // (it can't prove formatBytes is sanitizer-shaped); see the
358
+ // formatBytes() definition below for the static guarantee.
359
+ // lgtm[js/xss-through-dom]
353
360
  root.innerHTML = pendingAttachments.map((a, i) => `
354
361
  <span class="attachment-chip" data-att-index="${i}">
355
362
  <span class="chip-name" title="${escapeHtml(a.filename)}">${escapeHtml(a.filename)}</span>
@@ -10,33 +10,111 @@ import { loadList } from './list-view.js';
10
10
  import { icon } from './icons.js';
11
11
  import { confirmModal } from './modal.js';
12
12
 
13
+ /**
14
+ * Render the per-message toolbar based on which folder the operator
15
+ * is viewing the message in. The default (Inbox / Sent / Starred /
16
+ * Drafts / All) shows the Gmail-style row: Reply, Reply all, Archive,
17
+ * Mark unread, Report spam, Delete (= move to Trash).
18
+ *
19
+ * Three folders override that row because the default actions don't
20
+ * make sense once the message is already at its destination:
21
+ *
22
+ * - **Archive**: replace Archive with **Move to Inbox** (unarchive).
23
+ * Spam + Delete still apply.
24
+ * - **Spam**: replace Report-spam with **Not spam** (move to Inbox).
25
+ * The Archive action is hidden — moving spam to Archive bypasses
26
+ * the regular spam-train workflow; if the operator decides it's
27
+ * not spam, they want it in Inbox.
28
+ * - **Trash**: replace Archive with **Restore** (move to Inbox).
29
+ * Report-spam is hidden — moving trash to Spam is a confusing
30
+ * no-op (it's already deleted). Delete now means "delete forever"
31
+ * and gets a red title; deleteMessage() already detects the trash
32
+ * folder and switches to permanent expunge.
33
+ *
34
+ * Reply / Reply-all stay visible everywhere because operators
35
+ * legitimately reply to messages they've already archived or
36
+ * triaged into spam.
37
+ */
38
+ function renderToolbar(folder) {
39
+ const isArchive = folder === 'archive';
40
+ const isSpam = folder === 'spam';
41
+ const isTrash = folder === 'trash';
42
+
43
+ const buttons = [
44
+ `<button class="icon-btn" id="msg-back" title="Back to list">${icon('back')}</button>`,
45
+ `<button class="icon-btn" id="msg-reply" title="Reply">${icon('reply')}</button>`,
46
+ `<button class="icon-btn" id="msg-reply-all" title="Reply all">${icon('replyAll')}</button>`,
47
+ ];
48
+
49
+ if (isArchive) {
50
+ buttons.push(`<button class="icon-btn" id="msg-unarchive" title="Move to Inbox">${icon('inbox')}</button>`);
51
+ } else if (isTrash) {
52
+ buttons.push(`<button class="icon-btn" id="msg-restore" title="Restore to Inbox">${icon('inbox')}</button>`);
53
+ } else if (isSpam) {
54
+ buttons.push(`<button class="icon-btn" id="msg-not-spam" title="Not spam — move to Inbox">${icon('inbox')}</button>`);
55
+ } else {
56
+ buttons.push(`<button class="icon-btn" id="msg-archive" title="Archive">${icon('archive')}</button>`);
57
+ }
58
+
59
+ buttons.push(`<button class="icon-btn" id="msg-unread" title="Mark unread">${icon('mailUnread')}</button>`);
60
+
61
+ if (!isSpam && !isTrash) {
62
+ buttons.push(`<button class="icon-btn" id="msg-spam" title="Report spam">${icon('spam')}</button>`);
63
+ }
64
+
65
+ buttons.push(
66
+ `<button class="icon-btn" id="msg-delete" title="${isTrash ? 'Delete forever' : 'Delete'}">${icon('trash')}</button>`
67
+ );
68
+
69
+ return `<div class="message-toolbar">${buttons.join('\n ')}<div class="toolbar-spacer"></div></div>`;
70
+ }
71
+
72
+ /**
73
+ * Attach a click handler to a toolbar button if (and only if) the
74
+ * button is currently rendered. Folder-aware toolbars elide some
75
+ * buttons; calling `addEventListener` on a missing element would
76
+ * throw and abort the rest of the wiring.
77
+ */
78
+ function bindIf(id, handler) {
79
+ const el = document.getElementById(id);
80
+ if (el) el.addEventListener('click', handler);
81
+ }
82
+
13
83
  export async function openMessage(uid) {
14
84
  if (!state.selectedAgent) return;
15
85
  state.selectedUid = uid;
86
+ const folder = state.selectedFolder ?? 'inbox';
16
87
  const root = document.getElementById('content');
17
88
  root.innerHTML = `
18
- <div class="message-toolbar">
19
- <button class="icon-btn" id="msg-back" title="Back to list">${icon('back')}</button>
20
- <button class="icon-btn" id="msg-reply" title="Reply">${icon('reply')}</button>
21
- <button class="icon-btn" id="msg-reply-all" title="Reply all">${icon('replyAll')}</button>
22
- <button class="icon-btn" id="msg-archive" title="Archive">${icon('archive')}</button>
23
- <button class="icon-btn" id="msg-unread" title="Mark unread">${icon('mailUnread')}</button>
24
- <button class="icon-btn" id="msg-spam" title="Report spam">${icon('spam')}</button>
25
- <button class="icon-btn" id="msg-delete" title="Delete">${icon('trash')}</button>
26
- <div class="toolbar-spacer"></div>
27
- </div>
89
+ ${renderToolbar(folder)}
28
90
  <div class="message-view"><div class="empty">Loading…</div></div>
29
91
  `;
30
- document.getElementById('msg-back').addEventListener('click', () => { location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`; });
31
- document.getElementById('msg-reply').addEventListener('click', () => openReply(false));
32
- document.getElementById('msg-reply-all').addEventListener('click', () => openReply(true));
33
- document.getElementById('msg-archive').addEventListener('click', () => archiveMessage());
34
- document.getElementById('msg-unread').addEventListener('click', () => markUnread());
35
- document.getElementById('msg-spam').addEventListener('click', () => markSpam());
36
- document.getElementById('msg-delete').addEventListener('click', () => deleteMessage());
92
+ bindIf('msg-back', () => { location.hash = `#/folder/${folder}`; });
93
+ bindIf('msg-reply', () => openReply(false));
94
+ bindIf('msg-reply-all', () => openReply(true));
95
+ bindIf('msg-archive', () => archiveMessage());
96
+ bindIf('msg-unarchive', () => moveToInbox('unarchive'));
97
+ bindIf('msg-restore', () => moveToInbox('restore'));
98
+ bindIf('msg-not-spam', () => moveToInbox('not-spam'));
99
+ bindIf('msg-unread', () => markUnread());
100
+ bindIf('msg-spam', () => markSpam());
101
+ bindIf('msg-delete', () => deleteMessage());
37
102
 
38
103
  try {
39
- const msg = await apiGet(`/mail/messages/${uid}`, { agentKey: state.selectedAgent.apiKey });
104
+ // Pass the current folder so the API fetches from the right
105
+ // mailbox — Spam / Archive / Trash UIDs don't exist in INBOX,
106
+ // and the API defaults `folder` to INBOX when omitted. Without
107
+ // this, opening a message from any non-Inbox folder 404'd
108
+ // with `MESSAGE_NOT_FOUND` because UID N existed in (say) Junk
109
+ // Mail but the API looked in INBOX.
110
+ //
111
+ // We resolve the IMAP folder name via state.folderNames (the
112
+ // map populated by /mail/folders auto-discovery) so renames
113
+ // like Stalwart's "Junk Mail" vs "Spam" are handled in one
114
+ // place. "inbox" maps to "INBOX" by convention.
115
+ const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
116
+ const qs = imap && imap !== 'INBOX' ? `?folder=${encodeURIComponent(imap)}` : '';
117
+ const msg = await apiGet(`/mail/messages/${uid}${qs}`, { agentKey: state.selectedAgent.apiKey });
40
118
  state.currentMessage = msg;
41
119
  renderMessage(msg);
42
120
  } catch (err) {
@@ -217,10 +295,42 @@ function renderThreadQuote(dateRaw, sender, quotedBody) {
217
295
  `;
218
296
  }
219
297
 
298
+ /**
299
+ * Move the open message back to INBOX from Archive / Spam / Trash.
300
+ * Three triggers:
301
+ *
302
+ * - 'unarchive' (from Archive): generic move via /mail/messages/:uid/move
303
+ * - 'not-spam' (from Spam): /mail/messages/:uid/not-spam (server-side
304
+ * also clears the spam-train flag)
305
+ * - 'restore' (from Trash): generic move via /mail/messages/:uid/move
306
+ *
307
+ * All three navigate back to the originating folder list afterwards so
308
+ * the operator sees the row vanish from the view they triggered the
309
+ * action from. The list refresh is what makes the affordance feel real.
310
+ */
311
+ async function moveToInbox(reason) {
312
+ if (!state.currentMessage || !state.selectedAgent) return;
313
+ try {
314
+ const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
315
+ if (reason === 'not-spam') {
316
+ await apiPost(`/mail/messages/${state.selectedUid}/not-spam`, {}, { agentKey: state.selectedAgent.apiKey });
317
+ toast('Marked as not spam.');
318
+ } else {
319
+ await apiPost(`/mail/messages/${state.selectedUid}/move`, { from: imap, to: 'INBOX' }, { agentKey: state.selectedAgent.apiKey });
320
+ toast(reason === 'restore' ? 'Restored to Inbox.' : 'Moved to Inbox.');
321
+ }
322
+ location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
323
+ await loadList(state.selectedAgent, state.selectedFolder);
324
+ } catch (err) {
325
+ toast(`Move failed: ${err.message}`, true);
326
+ }
327
+ }
328
+
220
329
  async function markUnread() {
221
330
  if (!state.currentMessage || !state.selectedAgent) return;
222
331
  try {
223
- await apiPost(`/mail/messages/${state.selectedUid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
332
+ const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
333
+ await apiPost(`/mail/messages/${state.selectedUid}/unseen`, { folder: imap }, { agentKey: state.selectedAgent.apiKey });
224
334
  toast('Marked unread.');
225
335
  location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
226
336
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -262,7 +372,8 @@ async function markSpam() {
262
372
  });
263
373
  if (!ok) return;
264
374
  try {
265
- await apiPost(`/mail/messages/${state.selectedUid}/spam`, {}, { agentKey: state.selectedAgent.apiKey });
375
+ const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
376
+ await apiPost(`/mail/messages/${state.selectedUid}/spam`, { folder: imap }, { agentKey: state.selectedAgent.apiKey });
266
377
  toast('Reported as spam.');
267
378
  location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
268
379
  await loadList(state.selectedAgent, state.selectedFolder);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/cli",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
4
4
  "description": "Email and SMS infrastructure for AI agents — the first platform to give agents real email addresses and phone numbers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,13 +31,13 @@
31
31
  "prepublishOnly": "npm run build"
32
32
  },
33
33
  "dependencies": {
34
- "@agenticmail/api": "^0.9.19",
35
- "@agenticmail/core": "^0.9.4",
34
+ "@agenticmail/api": "^0.9.21",
35
+ "@agenticmail/core": "^0.9.5",
36
36
  "json5": "^2.2.3"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@agenticmail/claudecode": "^0.2.13",
40
- "@agenticmail/codex": "^0.1.8"
39
+ "@agenticmail/claudecode": "^0.2.15",
40
+ "@agenticmail/codex": "^0.1.10"
41
41
  },
42
42
  "devDependencies": {
43
43
  "tsup": "^8.4.0",