@agenticmail/api 0.9.19 → 0.9.21
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 +8 -6
- package/package.json +2 -2
- package/public/js/app.js +9 -0
- package/public/js/compose.js +7 -0
- package/public/js/message-view.js +131 -20
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(
|
|
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" });
|
|
@@ -700,7 +700,7 @@ function parseScheduleTime(input) {
|
|
|
700
700
|
if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
|
|
701
701
|
return d;
|
|
702
702
|
}
|
|
703
|
-
const humanMatch = trimmed.match(
|
|
703
|
+
const humanMatch = trimmed.length > 200 ? null : trimmed.match(
|
|
704
704
|
/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\s*(.+)?$/
|
|
705
705
|
);
|
|
706
706
|
if (humanMatch) {
|
|
@@ -1930,7 +1930,7 @@ function deriveDefaultWakeList(toField) {
|
|
|
1930
1930
|
const arr = Array.isArray(toField) ? toField : String(toField).split(",");
|
|
1931
1931
|
const localNames = [];
|
|
1932
1932
|
for (const raw of arr) {
|
|
1933
|
-
const trimmed = String(raw).trim().toLowerCase();
|
|
1933
|
+
const trimmed = String(raw).slice(0, 500).trim().toLowerCase();
|
|
1934
1934
|
const m = trimmed.match(/<([^>]+)>/);
|
|
1935
1935
|
const bare = (m ? m[1] : trimmed).trim();
|
|
1936
1936
|
if (!bare.endsWith("@localhost")) continue;
|
|
@@ -1958,7 +1958,8 @@ async function notifyLocalRecipientsOfNewMail(accountManager, toField, ccField,
|
|
|
1958
1958
|
if (!v) return [];
|
|
1959
1959
|
const items = Array.isArray(v) ? v : [v];
|
|
1960
1960
|
const out = /* @__PURE__ */ new Set();
|
|
1961
|
-
for (const
|
|
1961
|
+
for (const rawEntry of items) {
|
|
1962
|
+
const entry = typeof rawEntry === "string" && rawEntry.length > 1e4 ? rawEntry.slice(0, 1e4) : rawEntry;
|
|
1962
1963
|
let match;
|
|
1963
1964
|
addrRe.lastIndex = 0;
|
|
1964
1965
|
while ((match = addrRe.exec(entry)) !== null) {
|
|
@@ -2468,9 +2469,10 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
|
2468
2469
|
res.status(400).json({ error: "Invalid UID" });
|
|
2469
2470
|
return;
|
|
2470
2471
|
}
|
|
2472
|
+
const folder = req.body?.folder || "INBOX";
|
|
2471
2473
|
const password = getAgentPassword(agent);
|
|
2472
2474
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2473
|
-
await receiver.markUnseen(uid);
|
|
2475
|
+
await receiver.markUnseen(uid, folder);
|
|
2474
2476
|
invalidateParsedMessage(agent.id, uid);
|
|
2475
2477
|
res.json({ ok: true });
|
|
2476
2478
|
} catch (err) {
|
|
@@ -4331,7 +4333,7 @@ function buildColumnDDL(col, dialect) {
|
|
|
4331
4333
|
if (col.default !== void 0) {
|
|
4332
4334
|
let val;
|
|
4333
4335
|
if (typeof col.default === "string") {
|
|
4334
|
-
const trimmed = col.default.trim();
|
|
4336
|
+
const trimmed = col.default.slice(0, 500).trim();
|
|
4335
4337
|
const isSqlExpr = /\(.*\)/.test(trimmed) || /^CURRENT_(?:TIMESTAMP|DATE|TIME)$/i.test(trimmed);
|
|
4336
4338
|
val = isSqlExpr ? `(${trimmed})` : `'${col.default.replace(/'/g, "''")}'`;
|
|
4337
4339
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agenticmail/api",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.21",
|
|
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.
|
|
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';
|
package/public/js/compose.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|