@agenticmail/cli 0.9.28 → 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 +11 -3
- package/dist/public/js/app.js +9 -0
- package/dist/public/js/compose.js +7 -0
- package/dist/public/js/message-view.js +113 -17
- package/package.json +5 -5
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
|
|
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("");
|
package/dist/public/js/app.js
CHANGED
|
@@ -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,30 +10,95 @@ 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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
104
|
// Pass the current folder so the API fetches from the right
|
|
@@ -230,6 +295,37 @@ function renderThreadQuote(dateRaw, sender, quotedBody) {
|
|
|
230
295
|
`;
|
|
231
296
|
}
|
|
232
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
|
+
|
|
233
329
|
async function markUnread() {
|
|
234
330
|
if (!state.currentMessage || !state.selectedAgent) return;
|
|
235
331
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agenticmail/cli",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
35
|
-
"@agenticmail/core": "^0.9.
|
|
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.
|
|
40
|
-
"@agenticmail/codex": "^0.1.
|
|
39
|
+
"@agenticmail/claudecode": "^0.2.15",
|
|
40
|
+
"@agenticmail/codex": "^0.1.10"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"tsup": "^8.4.0",
|