@agenticmail/api 0.7.20 → 0.9.0
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/README.md +8 -1
- package/dist/index.js +213 -12
- package/package.json +3 -3
- package/public/js/icons.js +2 -0
- package/public/js/list-view.js +154 -4
- package/public/js/message-view.js +20 -0
- package/public/js/sidebar.js +1 -0
- package/public/styles.css +28 -0
package/README.md
CHANGED
|
@@ -8,7 +8,14 @@ The API server for [AgenticMail](https://github.com/agenticmail/agenticmail) —
|
|
|
8
8
|
|
|
9
9
|
This package runs a web server that handles everything: sending email and SMS, reading inboxes, managing agents, phone number access, real-time notifications, inter-agent messaging, spam filtering, outbound security scanning, and gateway configuration. Every feature in AgenticMail is accessible through this API.
|
|
10
10
|
|
|
11
|
-
## ✨ What's new in 0.
|
|
11
|
+
## ✨ What's new in 0.9.0
|
|
12
|
+
|
|
13
|
+
- **🧠 Agent-thread memory + thread-id resolver endpoints.** Agents persist their own per-thread judgment so the dispatcher can pre-load it into the next wake's prompt:
|
|
14
|
+
- `GET / POST / DELETE /agents/me/memory/threads/:t` — agent-key scoped; each agent only ever touches its own memory file.
|
|
15
|
+
- `GET /agents/me/thread-id?uid=42&folder=INBOX` — resolves the stable subject-only thread id for a message UID, looking up the canonical root via the dispatcher's ThreadCache when available.
|
|
16
|
+
- **🎯 `wake` default flipped to "To: only".** `POST /mail/send`, `POST /drafts/:id/send`, `POST /templates/:id/send`, and the pending-outbound persistence path all derive the implicit allowlist from local recipients on the `To:` field when `wake` is omitted. CC'd local agents receive the mail without waking. New helper `deriveDefaultWakeList(to)` exported from `routes/mail.ts`. Opt back into the old behaviour with `wake: 'all'`.
|
|
17
|
+
|
|
18
|
+
## ✨ Earlier — 0.7.16
|
|
12
19
|
|
|
13
20
|
- **📐 Typed task contracts** — `POST /tasks/assign` and the long-poll `POST /tasks/rpc` accept an optional `outputSchema` field (JSON Schema, draft-7 subset). The schema is persisted on the task row via migration `014_task_output_schema.sql` and is rendered into the worker's wake prompt. `POST /tasks/:id/result` validates against the schema before accepting; mismatches return **400** with a flat `schemaErrors: [{ path, message }]` list. Validator lives at `src/lib/schema-validator.ts` (hand-rolled, no `ajv` dep) and supports `type`, `required`, `properties`, `items`, `enum`, `additionalProperties: false`, `minLength`/`maxLength`, `minimum`/`maximum`. Tasks without a schema keep the v0.8.x behaviour — fully back-compat.
|
|
14
21
|
- **⭐ Star endpoint** — `POST /mail/messages/:uid/star` with `{ starred: boolean, folder?: string }`. Maps to IMAP's `\Flagged` flag — same on-disk bit Gmail's star uses.
|
package/dist/index.js
CHANGED
|
@@ -903,7 +903,8 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
|
|
|
903
903
|
return;
|
|
904
904
|
}
|
|
905
905
|
const agent = req.agent;
|
|
906
|
-
const
|
|
906
|
+
const explicitWake = normalizeWakeList(req.body?.wake);
|
|
907
|
+
const wakeList = req.body?.wake === void 0 ? deriveDefaultWakeList(draft.to_addr) : explicitWake;
|
|
907
908
|
const customHeaders = wakeHeaders(wakeList);
|
|
908
909
|
let persistedAttachments;
|
|
909
910
|
if (draft.attachments) {
|
|
@@ -1189,7 +1190,8 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
|
|
|
1189
1190
|
return;
|
|
1190
1191
|
}
|
|
1191
1192
|
const applyVars = (text, vars2) => text.replace(/\{\{(\w+)\}\}/g, (m, key) => vars2[key] ?? m);
|
|
1192
|
-
const
|
|
1193
|
+
const explicitWake = normalizeWakeList(wake);
|
|
1194
|
+
const wakeList = wake === void 0 ? deriveDefaultWakeList(to) : explicitWake;
|
|
1193
1195
|
const customHeaders = wakeHeaders(wakeList);
|
|
1194
1196
|
const vars = variables && typeof variables === "object" ? variables : {};
|
|
1195
1197
|
const renderedSubject = applyVars(template.subject || "(no subject)", vars);
|
|
@@ -1799,13 +1801,29 @@ function normalizeMessageId(id) {
|
|
|
1799
1801
|
if (!id) return "";
|
|
1800
1802
|
return id.trim().replace(/^<+|>+$/g, "").toLowerCase();
|
|
1801
1803
|
}
|
|
1804
|
+
var WAKE_ALL_SENTINEL = "__wake_all__";
|
|
1802
1805
|
function normalizeWakeList(value) {
|
|
1803
1806
|
if (value === void 0 || value === null) return void 0;
|
|
1807
|
+
if (value === "all" || value === WAKE_ALL_SENTINEL) return void 0;
|
|
1804
1808
|
const strip = (s) => s.trim().replace(/@localhost$/i, "").toLowerCase();
|
|
1805
1809
|
if (Array.isArray(value)) return value.map((v) => strip(String(v))).filter(Boolean);
|
|
1806
1810
|
if (typeof value === "string") return value.split(",").map(strip).filter(Boolean);
|
|
1807
1811
|
return void 0;
|
|
1808
1812
|
}
|
|
1813
|
+
function deriveDefaultWakeList(toField) {
|
|
1814
|
+
if (!toField) return void 0;
|
|
1815
|
+
const arr = Array.isArray(toField) ? toField : String(toField).split(",");
|
|
1816
|
+
const localNames = [];
|
|
1817
|
+
for (const raw of arr) {
|
|
1818
|
+
const addr = String(raw).trim().toLowerCase();
|
|
1819
|
+
if (!addr.endsWith("@localhost")) continue;
|
|
1820
|
+
const m = addr.match(/<([^>]+)>/);
|
|
1821
|
+
const bare = m ? m[1].trim() : addr;
|
|
1822
|
+
const name = bare.replace(/@localhost$/i, "");
|
|
1823
|
+
if (name) localNames.push(name);
|
|
1824
|
+
}
|
|
1825
|
+
return localNames.length > 0 ? localNames : void 0;
|
|
1826
|
+
}
|
|
1809
1827
|
function wakeHeaders(wakeList) {
|
|
1810
1828
|
if (wakeList === void 0) return {};
|
|
1811
1829
|
return { "X-AgenticMail-Wake": wakeList.join(", ") };
|
|
@@ -1880,15 +1898,21 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
|
|
|
1880
1898
|
});
|
|
1881
1899
|
}
|
|
1882
1900
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1901
|
+
var sentFolderCache = /* @__PURE__ */ new Map();
|
|
1902
|
+
async function saveSentCopy(authUser, password, config, raw) {
|
|
1903
|
+
try {
|
|
1904
|
+
const receiver = await getReceiver(authUser, password, config);
|
|
1905
|
+
let folder = sentFolderCache.get(authUser);
|
|
1906
|
+
if (!folder) {
|
|
1907
|
+
const folders = await receiver.listFolders();
|
|
1908
|
+
const sentRe = /^sent\b|sent items|sent mail|sent messages|\[gmail\]\/sent/i;
|
|
1909
|
+
folder = folders.find((f) => f.specialUse === "\\Sent")?.path ?? folders.find((f) => sentRe.test(f.name) || sentRe.test(f.path))?.path ?? "Sent Items";
|
|
1910
|
+
sentFolderCache.set(authUser, folder);
|
|
1890
1911
|
}
|
|
1891
|
-
|
|
1912
|
+
await receiver.appendMessage(raw, folder, ["\\Seen"]);
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
|
|
1915
|
+
}
|
|
1892
1916
|
}
|
|
1893
1917
|
function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
1894
1918
|
const router = Router6();
|
|
@@ -1927,7 +1951,8 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
1927
1951
|
const pendingId = crypto.randomUUID();
|
|
1928
1952
|
const ownerName2 = agent.metadata?.ownerName;
|
|
1929
1953
|
const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
|
|
1930
|
-
const
|
|
1954
|
+
const explicitWakeForPersist = normalizeWakeList(wake);
|
|
1955
|
+
const wakeListForPersist = wake === void 0 ? deriveDefaultWakeList(to) : explicitWakeForPersist;
|
|
1931
1956
|
const mailOptions = {
|
|
1932
1957
|
to,
|
|
1933
1958
|
subject,
|
|
@@ -2010,7 +2035,8 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2010
2035
|
}
|
|
2011
2036
|
const ownerName = agent.metadata?.ownerName;
|
|
2012
2037
|
const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
2013
|
-
const
|
|
2038
|
+
const explicitWake = normalizeWakeList(wake);
|
|
2039
|
+
const wakeList = wake === void 0 ? deriveDefaultWakeList(to) : explicitWake;
|
|
2014
2040
|
const customHeaders = wakeHeaders(wakeList);
|
|
2015
2041
|
const mailOpts = {
|
|
2016
2042
|
to,
|
|
@@ -2502,6 +2528,95 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2502
2528
|
next(err);
|
|
2503
2529
|
}
|
|
2504
2530
|
});
|
|
2531
|
+
router.post("/mail/messages/:uid/archive", requireAgent, async (req, res, next) => {
|
|
2532
|
+
try {
|
|
2533
|
+
const agent = req.agent;
|
|
2534
|
+
const uid = parseInt(req.params.uid);
|
|
2535
|
+
if (isNaN(uid) || uid < 1) {
|
|
2536
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
const sourceFolder = req.body?.folder || "INBOX";
|
|
2540
|
+
const password = getAgentPassword(agent);
|
|
2541
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2542
|
+
const folders = await receiver.listFolders();
|
|
2543
|
+
const archiveRe = /^archives?\b|^all archive\b/i;
|
|
2544
|
+
let archiveFolder = folders.find((f) => f.specialUse === "\\Archive")?.path ?? folders.find((f) => archiveRe.test(f.name) || archiveRe.test(f.path))?.path;
|
|
2545
|
+
if (!archiveFolder) {
|
|
2546
|
+
try {
|
|
2547
|
+
await receiver.createFolder("Archive");
|
|
2548
|
+
} catch {
|
|
2549
|
+
}
|
|
2550
|
+
archiveFolder = "Archive";
|
|
2551
|
+
}
|
|
2552
|
+
if (archiveFolder === sourceFolder) {
|
|
2553
|
+
res.status(400).json({ error: "Message already in archive" });
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
await receiver.moveMessage(uid, sourceFolder, archiveFolder);
|
|
2557
|
+
res.json({ ok: true, archive: archiveFolder });
|
|
2558
|
+
} catch (err) {
|
|
2559
|
+
next(err);
|
|
2560
|
+
}
|
|
2561
|
+
});
|
|
2562
|
+
router.post("/mail/batch/archive", requireAgent, async (req, res, next) => {
|
|
2563
|
+
try {
|
|
2564
|
+
const agent = req.agent;
|
|
2565
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2566
|
+
const uids = validateUids(rawUids);
|
|
2567
|
+
if (!uids) {
|
|
2568
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
const sourceFolder = folder || "INBOX";
|
|
2572
|
+
const password = getAgentPassword(agent);
|
|
2573
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2574
|
+
const folders = await receiver.listFolders();
|
|
2575
|
+
const archiveRe = /^archives?\b|^all archive\b/i;
|
|
2576
|
+
let archiveFolder = folders.find((f) => f.specialUse === "\\Archive")?.path ?? folders.find((f) => archiveRe.test(f.name) || archiveRe.test(f.path))?.path;
|
|
2577
|
+
if (!archiveFolder) {
|
|
2578
|
+
try {
|
|
2579
|
+
await receiver.createFolder("Archive");
|
|
2580
|
+
} catch {
|
|
2581
|
+
}
|
|
2582
|
+
archiveFolder = "Archive";
|
|
2583
|
+
}
|
|
2584
|
+
if (archiveFolder === sourceFolder) {
|
|
2585
|
+
res.status(400).json({ error: "Messages already in archive" });
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
await receiver.batchMove(uids, sourceFolder, archiveFolder);
|
|
2589
|
+
res.json({ ok: true, archived: uids.length, archive: archiveFolder });
|
|
2590
|
+
} catch (err) {
|
|
2591
|
+
next(err);
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
router.post("/mail/batch/trash", requireAgent, async (req, res, next) => {
|
|
2595
|
+
try {
|
|
2596
|
+
const agent = req.agent;
|
|
2597
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2598
|
+
const uids = validateUids(rawUids);
|
|
2599
|
+
if (!uids) {
|
|
2600
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
const sourceFolder = folder || "INBOX";
|
|
2604
|
+
const password = getAgentPassword(agent);
|
|
2605
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2606
|
+
const folders = await receiver.listFolders();
|
|
2607
|
+
const trashRe = /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i;
|
|
2608
|
+
const trashFolder = folders.find((f) => f.specialUse === "\\Trash")?.path ?? folders.find((f) => trashRe.test(f.name) || trashRe.test(f.path))?.path;
|
|
2609
|
+
if (!trashFolder || trashFolder === sourceFolder) {
|
|
2610
|
+
await receiver.batchDelete(uids, sourceFolder);
|
|
2611
|
+
res.json({ ok: true, deleted: uids.length });
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
await receiver.batchMove(uids, sourceFolder, trashFolder);
|
|
2615
|
+
res.json({ ok: true, trashed: uids.length, trash: trashFolder });
|
|
2616
|
+
} catch (err) {
|
|
2617
|
+
next(err);
|
|
2618
|
+
}
|
|
2619
|
+
});
|
|
2505
2620
|
router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
|
|
2506
2621
|
try {
|
|
2507
2622
|
const agent = req.agent;
|
|
@@ -5101,6 +5216,91 @@ function createDispatcherActivityRoutes() {
|
|
|
5101
5216
|
return router;
|
|
5102
5217
|
}
|
|
5103
5218
|
|
|
5219
|
+
// src/routes/agent-memory.ts
|
|
5220
|
+
import { Router as Router14 } from "express";
|
|
5221
|
+
import {
|
|
5222
|
+
AgentMemoryStore,
|
|
5223
|
+
threadIdFor,
|
|
5224
|
+
ThreadCache
|
|
5225
|
+
} from "@agenticmail/core";
|
|
5226
|
+
function createAgentMemoryRoutes(config) {
|
|
5227
|
+
const router = Router14();
|
|
5228
|
+
const memoryStore = new AgentMemoryStore();
|
|
5229
|
+
const threadCache = new ThreadCache();
|
|
5230
|
+
router.get("/agents/me/memory/threads/:t", requireAgent, async (req, res, next) => {
|
|
5231
|
+
try {
|
|
5232
|
+
const t = String(req.params.t);
|
|
5233
|
+
const memory = memoryStore.read(req.agent.id, t);
|
|
5234
|
+
if (!memory) {
|
|
5235
|
+
res.status(404).json({ error: "No memory for this thread" });
|
|
5236
|
+
return;
|
|
5237
|
+
}
|
|
5238
|
+
res.json(memory);
|
|
5239
|
+
} catch (err) {
|
|
5240
|
+
next(err);
|
|
5241
|
+
}
|
|
5242
|
+
});
|
|
5243
|
+
router.post("/agents/me/memory/threads/:t", requireAgent, async (req, res, next) => {
|
|
5244
|
+
try {
|
|
5245
|
+
const t = String(req.params.t);
|
|
5246
|
+
const { summary, commitments, openQuestions, lastAction, lastUid } = req.body ?? {};
|
|
5247
|
+
if (!summary && !commitments && !openQuestions && !lastAction && lastUid === void 0) {
|
|
5248
|
+
res.status(400).json({ error: "At least one field is required (summary, commitments, openQuestions, lastAction, or lastUid)" });
|
|
5249
|
+
return;
|
|
5250
|
+
}
|
|
5251
|
+
memoryStore.write(req.agent.id, t, {
|
|
5252
|
+
summary: typeof summary === "string" ? summary : void 0,
|
|
5253
|
+
commitments: Array.isArray(commitments) ? commitments.map(String) : void 0,
|
|
5254
|
+
openQuestions: Array.isArray(openQuestions) ? openQuestions.map(String) : void 0,
|
|
5255
|
+
lastAction: typeof lastAction === "string" ? lastAction : void 0,
|
|
5256
|
+
lastUid: typeof lastUid === "number" ? lastUid : void 0
|
|
5257
|
+
});
|
|
5258
|
+
res.json({ ok: true, threadId: t });
|
|
5259
|
+
} catch (err) {
|
|
5260
|
+
next(err);
|
|
5261
|
+
}
|
|
5262
|
+
});
|
|
5263
|
+
router.delete("/agents/me/memory/threads/:t", requireAgent, async (req, res, next) => {
|
|
5264
|
+
try {
|
|
5265
|
+
memoryStore.delete(req.agent.id, String(req.params.t));
|
|
5266
|
+
res.json({ ok: true });
|
|
5267
|
+
} catch (err) {
|
|
5268
|
+
next(err);
|
|
5269
|
+
}
|
|
5270
|
+
});
|
|
5271
|
+
router.get("/agents/me/thread-id", requireAgent, async (req, res, next) => {
|
|
5272
|
+
try {
|
|
5273
|
+
const uid = parseInt(String(req.query.uid ?? ""), 10);
|
|
5274
|
+
if (isNaN(uid) || uid < 1) {
|
|
5275
|
+
res.status(400).json({ error: "uid query param is required" });
|
|
5276
|
+
return;
|
|
5277
|
+
}
|
|
5278
|
+
const folder = req.query.folder || "INBOX";
|
|
5279
|
+
const password = getAgentPassword(req.agent);
|
|
5280
|
+
const receiver = await getReceiver(req.agent.stalwartPrincipal, password, config);
|
|
5281
|
+
const envs = await receiver.listEnvelopes(folder, { limit: 1, offset: 0 });
|
|
5282
|
+
const envelope = envs.find((e) => e.uid === uid) ?? (await receiver.listEnvelopes(folder, { limit: 200, offset: 0 })).find((e) => e.uid === uid);
|
|
5283
|
+
if (!envelope) {
|
|
5284
|
+
res.status(404).json({ error: `No message with UID ${uid} in folder ${folder}` });
|
|
5285
|
+
return;
|
|
5286
|
+
}
|
|
5287
|
+
const subject = envelope.subject ?? "";
|
|
5288
|
+
const senderAddr = envelope.from?.[0]?.address ?? "";
|
|
5289
|
+
const provisional = threadIdFor({ subject, rootFromAddr: senderAddr });
|
|
5290
|
+
const existing = threadCache.read(provisional);
|
|
5291
|
+
if (existing) {
|
|
5292
|
+
const canonical = threadIdFor({ subject, rootFromAddr: existing.rootFromAddr });
|
|
5293
|
+
res.json({ threadId: canonical, rootFromAddr: existing.rootFromAddr, subject: existing.subject });
|
|
5294
|
+
return;
|
|
5295
|
+
}
|
|
5296
|
+
res.json({ threadId: provisional, rootFromAddr: senderAddr, subject });
|
|
5297
|
+
} catch (err) {
|
|
5298
|
+
next(err);
|
|
5299
|
+
}
|
|
5300
|
+
});
|
|
5301
|
+
return router;
|
|
5302
|
+
}
|
|
5303
|
+
|
|
5104
5304
|
// src/app.ts
|
|
5105
5305
|
var integrationRouteFactoryPromise = (async () => {
|
|
5106
5306
|
try {
|
|
@@ -5201,6 +5401,7 @@ function createApp(configOverrides) {
|
|
|
5201
5401
|
app2.use("/api/agenticmail", createStorageRoutes(db, accountManager2, config));
|
|
5202
5402
|
app2.use("/api/agenticmail", createSystemEventRoutes());
|
|
5203
5403
|
app2.use("/api/agenticmail", createDispatcherActivityRoutes());
|
|
5404
|
+
app2.use("/api/agenticmail", createAgentMemoryRoutes(config));
|
|
5204
5405
|
app2.use("/api/agenticmail", (_req, res) => {
|
|
5205
5406
|
res.status(404).json({ error: "Not found" });
|
|
5206
5407
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agenticmail/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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.
|
|
31
|
+
"@agenticmail/core": "^0.9.0",
|
|
32
32
|
"cors": "^2.8.5",
|
|
33
33
|
"dotenv": "^16.4.7",
|
|
34
34
|
"express": "^4.21.0",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"uuid": "^11.1.0"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@agenticmail/claudecode": "^0.
|
|
39
|
+
"@agenticmail/claudecode": "^0.2.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/cors": "^2.8.17",
|
package/public/js/icons.js
CHANGED
|
@@ -32,6 +32,8 @@ const PATHS = {
|
|
|
32
32
|
|
|
33
33
|
// ─── Sidebar folders ────────────────────────────────────────────
|
|
34
34
|
inbox: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H5V5h14v10z',
|
|
35
|
+
// Material-style archive: lidded box with horizontal slot.
|
|
36
|
+
archive: 'M20.54 5.23l-1.39-1.68A1.45 1.45 0 0 0 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23A2 2 0 0 0 3 6.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.5c0-.5-.18-.96-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.82-1h12l.93 1H5.12z',
|
|
35
37
|
sent: 'M2.01 21 23 12 2.01 3 2 10l15 2-15 2z',
|
|
36
38
|
drafts: 'M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2v-7l-8 5-8-5V5l8 5 8-5v2h2V5a2 2 0 0 0-2-2z',
|
|
37
39
|
allMail: 'M22 4h-2v9.38l-2.79-2.79L16 12l4 4 4-4-1.21-1.21L22 13.38V4zM4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8h-2v10H4V6h12V4H4z',
|
package/public/js/list-view.js
CHANGED
|
@@ -38,6 +38,11 @@ const FOLDER_MATCHERS = {
|
|
|
38
38
|
drafts: /^drafts?\b|\[gmail\]\/drafts/i,
|
|
39
39
|
spam: /^junk\b|junk mail|^spam\b|\[gmail\]\/spam/i,
|
|
40
40
|
trash: /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i,
|
|
41
|
+
// Archive is a Gmail/Outlook concept — most servers don't ship
|
|
42
|
+
// with one by default. We auto-create on demand (see the API's
|
|
43
|
+
// archive endpoint) so this matcher only needs to recognise
|
|
44
|
+
// existing folders.
|
|
45
|
+
archive: /^archives?\b|^all archive\b/i,
|
|
41
46
|
all: /^all mail\b|\[gmail\]\/all/i,
|
|
42
47
|
};
|
|
43
48
|
|
|
@@ -72,10 +77,11 @@ export async function ensureFolderCache(agent) {
|
|
|
72
77
|
} catch {
|
|
73
78
|
// Discovery failed — fall back to the most common defaults so
|
|
74
79
|
// at least Inbox + Sent work for vanilla Stalwart.
|
|
75
|
-
state.folderNames.sent
|
|
76
|
-
state.folderNames.drafts
|
|
77
|
-
state.folderNames.spam
|
|
78
|
-
state.folderNames.trash
|
|
80
|
+
state.folderNames.sent = 'Sent Items';
|
|
81
|
+
state.folderNames.drafts = 'Drafts';
|
|
82
|
+
state.folderNames.spam = 'Junk Mail';
|
|
83
|
+
state.folderNames.trash = 'Trash';
|
|
84
|
+
state.folderNames.archive = 'Archive';
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
|
|
@@ -91,6 +97,14 @@ export async function loadList(agent, folder) {
|
|
|
91
97
|
<input type="checkbox" id="list-select-all-input" />
|
|
92
98
|
</label>
|
|
93
99
|
<button class="icon-btn list-refresh" title="Refresh" id="list-refresh-btn">${icon('refresh', { size: 18 })}</button>
|
|
100
|
+
<div class="bulk-actions" id="bulk-actions" hidden>
|
|
101
|
+
<button class="icon-btn bulk-btn" id="bulk-archive" title="Archive selected">${icon('archive', { size: 18 })}</button>
|
|
102
|
+
<button class="icon-btn bulk-btn" id="bulk-delete" title="Delete selected">${icon('trash', { size: 18 })}</button>
|
|
103
|
+
<button class="icon-btn bulk-btn" id="bulk-spam" title="Report as spam">${icon('spam', { size: 18 })}</button>
|
|
104
|
+
<button class="icon-btn bulk-btn" id="bulk-mark-read" title="Mark as read">${icon('check', { size: 18 })}</button>
|
|
105
|
+
<button class="icon-btn bulk-btn" id="bulk-mark-unread" title="Mark as unread">${icon('mailUnread', { size: 18 })}</button>
|
|
106
|
+
<span class="bulk-count" id="bulk-count"></span>
|
|
107
|
+
</div>
|
|
94
108
|
<div class="list-toolbar-spacer"></div>
|
|
95
109
|
<span class="count-text" id="list-count"></span>
|
|
96
110
|
</div>
|
|
@@ -101,7 +115,17 @@ export async function loadList(agent, folder) {
|
|
|
101
115
|
const checked = e.target.checked;
|
|
102
116
|
document.querySelectorAll('#list-rows .row-check input[type=checkbox]')
|
|
103
117
|
.forEach(cb => { cb.checked = checked; });
|
|
118
|
+
updateBulkActions();
|
|
104
119
|
});
|
|
120
|
+
// Wire bulk-action handlers — each gathers the selected UIDs,
|
|
121
|
+
// calls the matching batch endpoint, and reloads the list. The
|
|
122
|
+
// toolbar visibility is driven by `updateBulkActions` which is
|
|
123
|
+
// called every time a checkbox flips.
|
|
124
|
+
document.getElementById('bulk-archive')?.addEventListener('click', () => runBulkAction(agent, folder, 'archive'));
|
|
125
|
+
document.getElementById('bulk-delete')?.addEventListener('click', () => runBulkAction(agent, folder, 'delete'));
|
|
126
|
+
document.getElementById('bulk-spam')?.addEventListener('click', () => runBulkAction(agent, folder, 'spam'));
|
|
127
|
+
document.getElementById('bulk-mark-read')?.addEventListener('click', () => runBulkAction(agent, folder, 'mark-read'));
|
|
128
|
+
document.getElementById('bulk-mark-unread')?.addEventListener('click', () => runBulkAction(agent, folder, 'mark-unread'));
|
|
105
129
|
|
|
106
130
|
// Drafts are a SQL-backed app primitive, not an IMAP mailbox.
|
|
107
131
|
// The autosave path writes to /drafts (sqlite) and the agent
|
|
@@ -204,6 +228,20 @@ export function renderList() {
|
|
|
204
228
|
if (state.selectedFolder === 'starred') {
|
|
205
229
|
filtered = filtered.filter(m => flagsHas(m.flags, '\\Flagged'));
|
|
206
230
|
}
|
|
231
|
+
// Defensive Sent-folder filter. The API serves the IMAP Sent
|
|
232
|
+
// mailbox directly, but some Stalwart configurations (or
|
|
233
|
+
// misconfigured saveSentCopy targets) can land messages whose
|
|
234
|
+
// sender ISN'T the active agent in Sent. Filter client-side
|
|
235
|
+
// so the user only ever sees messages they actually sent.
|
|
236
|
+
// This is a safety net — the server-side fix lives in
|
|
237
|
+
// saveSentCopy and the dispatcher's send path.
|
|
238
|
+
if (state.selectedFolder === 'sent' && state.selectedAgent?.email) {
|
|
239
|
+
const me = state.selectedAgent.email.toLowerCase();
|
|
240
|
+
filtered = filtered.filter(m => {
|
|
241
|
+
const fromAddr = (m.from?.[0]?.address ?? '').toLowerCase();
|
|
242
|
+
return fromAddr === me;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
207
245
|
|
|
208
246
|
const hlTerm = filters?.subject || filters?.from || filters?.text || '';
|
|
209
247
|
|
|
@@ -270,6 +308,12 @@ export function renderList() {
|
|
|
270
308
|
}).join('');
|
|
271
309
|
|
|
272
310
|
root.querySelectorAll('.list-row').forEach(el => {
|
|
311
|
+
// Checkbox change on individual rows — drives the bulk-action
|
|
312
|
+
// toolbar visibility. Attached separately from the row click
|
|
313
|
+
// handler so clicking the box doesn't propagate to "open
|
|
314
|
+
// message".
|
|
315
|
+
const cb = el.querySelector('.row-check input[type=checkbox]');
|
|
316
|
+
cb?.addEventListener('change', updateBulkActions);
|
|
273
317
|
el.addEventListener('click', (e) => {
|
|
274
318
|
// Star click — toggle via API and optimistically update the
|
|
275
319
|
// local flags so the icon flips without a reload.
|
|
@@ -298,6 +342,112 @@ export function renderList() {
|
|
|
298
342
|
});
|
|
299
343
|
}
|
|
300
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Read every checked row's UID. Empty array when nothing is
|
|
347
|
+
* selected. Used by the bulk-action handlers and toolbar
|
|
348
|
+
* visibility logic.
|
|
349
|
+
*/
|
|
350
|
+
function getSelectedUids() {
|
|
351
|
+
const uids = [];
|
|
352
|
+
document.querySelectorAll('#list-rows .list-row').forEach(row => {
|
|
353
|
+
const cb = row.querySelector('.row-check input[type=checkbox]');
|
|
354
|
+
if (cb?.checked) {
|
|
355
|
+
const uid = Number(row.dataset.uid);
|
|
356
|
+
if (Number.isFinite(uid)) uids.push(uid);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return uids;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Toggle the visibility of the bulk-action toolbar based on
|
|
364
|
+
* current selection. Also updates the count label so the user
|
|
365
|
+
* sees "3 selected" etc. Called on every checkbox change +
|
|
366
|
+
* after each successful bulk action.
|
|
367
|
+
*/
|
|
368
|
+
function updateBulkActions() {
|
|
369
|
+
const uids = getSelectedUids();
|
|
370
|
+
const bar = document.getElementById('bulk-actions');
|
|
371
|
+
const count = document.getElementById('bulk-count');
|
|
372
|
+
if (!bar || !count) return;
|
|
373
|
+
if (uids.length === 0) {
|
|
374
|
+
bar.hidden = true;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
bar.hidden = false;
|
|
378
|
+
count.textContent = `${uids.length} selected`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Execute a bulk action against every currently-selected row.
|
|
383
|
+
* Maps the action name to the matching batch endpoint, fires
|
|
384
|
+
* one request, then reloads the list so the rows disappear /
|
|
385
|
+
* change visibly. Confirm dialogs only on destructive actions
|
|
386
|
+
* (delete, spam) — archive + mark-read/unread are silent.
|
|
387
|
+
*/
|
|
388
|
+
async function runBulkAction(agent, folder, action) {
|
|
389
|
+
const uids = getSelectedUids();
|
|
390
|
+
if (uids.length === 0) return;
|
|
391
|
+
const imap = state.folderNames?.[folder] ?? 'INBOX';
|
|
392
|
+
let confirmTitle = '';
|
|
393
|
+
let confirmBody = '';
|
|
394
|
+
let confirmLabel = '';
|
|
395
|
+
let endpoint = '';
|
|
396
|
+
let body = { uids, folder: imap };
|
|
397
|
+
let danger = false;
|
|
398
|
+
switch (action) {
|
|
399
|
+
case 'archive':
|
|
400
|
+
endpoint = '/mail/batch/archive';
|
|
401
|
+
break;
|
|
402
|
+
case 'delete':
|
|
403
|
+
// From Trash, batch/trash falls through to permanent
|
|
404
|
+
// expunge; everywhere else it's a move-to-trash.
|
|
405
|
+
endpoint = '/mail/batch/trash';
|
|
406
|
+
danger = true;
|
|
407
|
+
confirmTitle = folder === 'trash' ? `Delete ${uids.length} message${uids.length === 1 ? '' : 's'} forever?` : `Move ${uids.length} message${uids.length === 1 ? '' : 's'} to Trash?`;
|
|
408
|
+
confirmBody = folder === 'trash' ? "This can't be undone." : 'You can recover them from Trash.';
|
|
409
|
+
confirmLabel = folder === 'trash' ? 'Delete forever' : 'Move to Trash';
|
|
410
|
+
break;
|
|
411
|
+
case 'spam':
|
|
412
|
+
// No batch/spam route yet — fall back to batch/move with
|
|
413
|
+
// the auto-discovered Spam folder.
|
|
414
|
+
endpoint = '/mail/batch/move';
|
|
415
|
+
body.toFolder = state.folderNames?.spam ?? 'Junk Mail';
|
|
416
|
+
danger = true;
|
|
417
|
+
confirmTitle = `Report ${uids.length} message${uids.length === 1 ? '' : 's'} as spam?`;
|
|
418
|
+
confirmBody = 'They will be moved to the Junk folder.';
|
|
419
|
+
confirmLabel = 'Report spam';
|
|
420
|
+
break;
|
|
421
|
+
case 'mark-read':
|
|
422
|
+
endpoint = '/mail/batch/seen';
|
|
423
|
+
break;
|
|
424
|
+
case 'mark-unread':
|
|
425
|
+
endpoint = '/mail/batch/unseen';
|
|
426
|
+
break;
|
|
427
|
+
default:
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (confirmTitle) {
|
|
431
|
+
const { confirmModal } = await import('./modal.js');
|
|
432
|
+
const ok = await confirmModal({ title: confirmTitle, body: confirmBody, confirm: confirmLabel, danger });
|
|
433
|
+
if (!ok) return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
await apiPost(endpoint, body, { agentKey: agent.apiKey });
|
|
437
|
+
toast(`${uids.length} message${uids.length === 1 ? '' : 's'} ${
|
|
438
|
+
action === 'archive' ? 'archived' :
|
|
439
|
+
action === 'delete' ? (folder === 'trash' ? 'deleted' : 'moved to Trash') :
|
|
440
|
+
action === 'spam' ? 'reported as spam' :
|
|
441
|
+
action === 'mark-read' ? 'marked as read' :
|
|
442
|
+
'marked as unread'
|
|
443
|
+
}.`);
|
|
444
|
+
// Reload so the rows that moved/changed visibly update.
|
|
445
|
+
await loadList(agent, folder);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
toast(`Bulk ${action} failed: ${err.message}`, true);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
301
451
|
/**
|
|
302
452
|
* Toggle the IMAP \Flagged flag on a message via the API. Updates
|
|
303
453
|
* the in-memory message object on success so renderList reflects
|
|
@@ -19,6 +19,7 @@ export async function openMessage(uid) {
|
|
|
19
19
|
<button class="icon-btn" id="msg-back" title="Back to list">${icon('back')}</button>
|
|
20
20
|
<button class="icon-btn" id="msg-reply" title="Reply">${icon('reply')}</button>
|
|
21
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>
|
|
22
23
|
<button class="icon-btn" id="msg-unread" title="Mark unread">${icon('mailUnread')}</button>
|
|
23
24
|
<button class="icon-btn" id="msg-spam" title="Report spam">${icon('spam')}</button>
|
|
24
25
|
<button class="icon-btn" id="msg-delete" title="Delete">${icon('trash')}</button>
|
|
@@ -29,6 +30,7 @@ export async function openMessage(uid) {
|
|
|
29
30
|
document.getElementById('msg-back').addEventListener('click', () => { location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`; });
|
|
30
31
|
document.getElementById('msg-reply').addEventListener('click', () => openReply(false));
|
|
31
32
|
document.getElementById('msg-reply-all').addEventListener('click', () => openReply(true));
|
|
33
|
+
document.getElementById('msg-archive').addEventListener('click', () => archiveMessage());
|
|
32
34
|
document.getElementById('msg-unread').addEventListener('click', () => markUnread());
|
|
33
35
|
document.getElementById('msg-spam').addEventListener('click', () => markSpam());
|
|
34
36
|
document.getElementById('msg-delete').addEventListener('click', () => deleteMessage());
|
|
@@ -219,6 +221,24 @@ async function markUnread() {
|
|
|
219
221
|
* route is POST /mail/messages/:uid/spam — it does the move +
|
|
220
222
|
* flags the message so future scans treat it as known spam.
|
|
221
223
|
*/
|
|
224
|
+
/**
|
|
225
|
+
* Archive the open message — move it to the Archive folder.
|
|
226
|
+
* No confirm dialog; archive is non-destructive (Gmail UX) so
|
|
227
|
+
* the user can always go to Archive and move things back.
|
|
228
|
+
*/
|
|
229
|
+
async function archiveMessage() {
|
|
230
|
+
if (!state.currentMessage || !state.selectedAgent) return;
|
|
231
|
+
try {
|
|
232
|
+
const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
|
|
233
|
+
await apiPost(`/mail/messages/${state.selectedUid}/archive`, { folder: imap }, { agentKey: state.selectedAgent.apiKey });
|
|
234
|
+
toast('Archived.');
|
|
235
|
+
location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
|
|
236
|
+
await loadList(state.selectedAgent, state.selectedFolder);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
toast(`Archive failed: ${err.message}`, true);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
222
242
|
async function markSpam() {
|
|
223
243
|
if (!state.currentMessage || !state.selectedAgent) return;
|
|
224
244
|
const ok = await confirmModal({
|
package/public/js/sidebar.js
CHANGED
|
@@ -19,6 +19,7 @@ export const FOLDERS = [
|
|
|
19
19
|
{ id: 'starred', label: 'Starred', icon: 'starOutline' },
|
|
20
20
|
{ id: 'sent', label: 'Sent', icon: 'sent' },
|
|
21
21
|
{ id: 'drafts', label: 'Drafts', icon: 'drafts' },
|
|
22
|
+
{ id: 'archive', label: 'Archive', icon: 'archive' },
|
|
22
23
|
{ id: 'all', label: 'All Mail', icon: 'allMail', requiresDiscovery: true },
|
|
23
24
|
{ id: 'spam', label: 'Spam', icon: 'spam' },
|
|
24
25
|
{ id: 'trash', label: 'Trash', icon: 'trash' },
|
package/public/styles.css
CHANGED
|
@@ -419,6 +419,27 @@ a { color: var(--accent-strong); }
|
|
|
419
419
|
font-size: 12px; color: var(--muted);
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/* Bulk-action toolbar — appears between select-all and refresh
|
|
423
|
+
when one or more rows are checked. Replaces the visual idle
|
|
424
|
+
state of the row (toolbar) with action buttons + a "N selected"
|
|
425
|
+
indicator on the right. */
|
|
426
|
+
.bulk-actions {
|
|
427
|
+
display: flex; align-items: center; gap: 4px;
|
|
428
|
+
margin-left: 8px;
|
|
429
|
+
padding-left: 12px;
|
|
430
|
+
border-left: 1px solid var(--line);
|
|
431
|
+
}
|
|
432
|
+
.bulk-actions[hidden] { display: none; }
|
|
433
|
+
.bulk-actions .bulk-btn {
|
|
434
|
+
width: 36px; height: 36px;
|
|
435
|
+
color: var(--ink-soft);
|
|
436
|
+
}
|
|
437
|
+
.bulk-actions .bulk-btn:hover { background: var(--bg-hover); color: var(--ink); }
|
|
438
|
+
.bulk-count {
|
|
439
|
+
font-size: 12px; font-weight: 500; color: var(--accent-strong);
|
|
440
|
+
margin-left: 8px;
|
|
441
|
+
}
|
|
442
|
+
|
|
422
443
|
/* Gmail-style compact rows.
|
|
423
444
|
Single line per message; subject + preview share one truncated
|
|
424
445
|
cell so longer previews tail off with ellipsis instead of
|
|
@@ -576,6 +597,13 @@ mark.search-hl {
|
|
|
576
597
|
max-width: 840px;
|
|
577
598
|
margin: 0 auto;
|
|
578
599
|
width: 100%;
|
|
600
|
+
/* Clear visual end-of-message marker. The reply / quoted-thread
|
|
601
|
+
chrome above made the body's bottom ambiguous; a hairline rule
|
|
602
|
+
gives the reader a definite stop and separates from the
|
|
603
|
+
attachments / next message in stack views. */
|
|
604
|
+
border-bottom: 1px solid var(--line);
|
|
605
|
+
padding-bottom: 28px;
|
|
606
|
+
margin-bottom: 8px;
|
|
579
607
|
}
|
|
580
608
|
.message-body h1, .message-body h2, .message-body h3 {
|
|
581
609
|
color: var(--pink); margin: 1.2em 0 .4em;
|