@agenticmail/api 0.9.20 → 0.9.22

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
@@ -97,7 +97,7 @@ function requireAgent(req, res, next) {
97
97
  // src/middleware/error-handler.ts
98
98
  function errorHandler(err, req, res, _next) {
99
99
  const message = err instanceof Error ? err.message : String(err);
100
- console.error(`[ERROR] ${req.method} ${req.path}:`, err instanceof Error ? err.stack || message : message);
100
+ console.error("[ERROR] %s %s:", req.method, req.path, err instanceof Error ? err.stack || message : message);
101
101
  if (res.headersSent) return;
102
102
  if (err instanceof SyntaxError && err.status === 400) {
103
103
  res.status(400).json({ error: "Invalid JSON in request body" });
@@ -401,7 +401,11 @@ function createAccountRoutes(accountManager, db, config) {
401
401
  router.get("/accounts/directory", requireAuth, async (_req, res, next) => {
402
402
  try {
403
403
  const agents = await accountManager.list();
404
- const directory = agents.map((a) => ({ name: a.name, email: a.email, role: a.role }));
404
+ const directory = agents.map((a) => {
405
+ const meta = a.metadata ?? {};
406
+ const host2 = typeof meta.host === "string" ? meta.host : null;
407
+ return { name: a.name, email: a.email, role: a.role, host: host2 };
408
+ });
405
409
  res.json({ agents: directory });
406
410
  } catch (err) {
407
411
  next(err);
@@ -414,7 +418,9 @@ function createAccountRoutes(accountManager, db, config) {
414
418
  res.status(404).json({ error: "Agent not found" });
415
419
  return;
416
420
  }
417
- res.json({ name: agent.name, email: agent.email, role: agent.role });
421
+ const meta = agent.metadata ?? {};
422
+ const host2 = typeof meta.host === "string" ? meta.host : null;
423
+ res.json({ name: agent.name, email: agent.email, role: agent.role, host: host2 });
418
424
  } catch (err) {
419
425
  next(err);
420
426
  }
@@ -700,7 +706,7 @@ function parseScheduleTime(input) {
700
706
  if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
701
707
  return d;
702
708
  }
703
- const humanMatch = trimmed.match(
709
+ const humanMatch = trimmed.length > 200 ? null : trimmed.match(
704
710
  /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\s*(.+)?$/
705
711
  );
706
712
  if (humanMatch) {
@@ -1930,7 +1936,7 @@ function deriveDefaultWakeList(toField) {
1930
1936
  const arr = Array.isArray(toField) ? toField : String(toField).split(",");
1931
1937
  const localNames = [];
1932
1938
  for (const raw of arr) {
1933
- const trimmed = String(raw).trim().toLowerCase();
1939
+ const trimmed = String(raw).slice(0, 500).trim().toLowerCase();
1934
1940
  const m = trimmed.match(/<([^>]+)>/);
1935
1941
  const bare = (m ? m[1] : trimmed).trim();
1936
1942
  if (!bare.endsWith("@localhost")) continue;
@@ -1958,7 +1964,8 @@ async function notifyLocalRecipientsOfNewMail(accountManager, toField, ccField,
1958
1964
  if (!v) return [];
1959
1965
  const items = Array.isArray(v) ? v : [v];
1960
1966
  const out = /* @__PURE__ */ new Set();
1961
- for (const entry of items) {
1967
+ for (const rawEntry of items) {
1968
+ const entry = typeof rawEntry === "string" && rawEntry.length > 1e4 ? rawEntry.slice(0, 1e4) : rawEntry;
1962
1969
  let match;
1963
1970
  addrRe.lastIndex = 0;
1964
1971
  while ((match = addrRe.exec(entry)) !== null) {
@@ -4332,7 +4339,7 @@ function buildColumnDDL(col, dialect) {
4332
4339
  if (col.default !== void 0) {
4333
4340
  let val;
4334
4341
  if (typeof col.default === "string") {
4335
- const trimmed = col.default.trim();
4342
+ const trimmed = col.default.slice(0, 500).trim();
4336
4343
  const isSqlExpr = /\(.*\)/.test(trimmed) || /^CURRENT_(?:TIMESTAMP|DATE|TIME)$/i.test(trimmed);
4337
4344
  val = isSqlExpr ? `(${trimmed})` : `'${col.default.replace(/'/g, "''")}'`;
4338
4345
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.20",
3
+ "version": "0.9.22",
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",
@@ -28,7 +28,7 @@
28
28
  "prepublishOnly": "npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "@agenticmail/core": "^0.9.4",
31
+ "@agenticmail/core": "^0.9.5",
32
32
  "cors": "^2.8.5",
33
33
  "dotenv": "^16.4.7",
34
34
  "express": "^4.21.0",
package/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
- <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
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 {