@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 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.7.16
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 wakeList = normalizeWakeList(req.body?.wake);
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 wakeList = normalizeWakeList(wake);
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
- function saveSentCopy(authUser, password, config, raw) {
1884
- (async () => {
1885
- try {
1886
- const receiver = await getReceiver(authUser, password, config);
1887
- await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
1888
- } catch (err) {
1889
- console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
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 wakeListForPersist = normalizeWakeList(wake);
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 wakeList = normalizeWakeList(wake);
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.7.20",
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.7.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.1.0"
39
+ "@agenticmail/claudecode": "^0.2.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/cors": "^2.8.17",
@@ -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',
@@ -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 = 'Sent Items';
76
- state.folderNames.drafts = 'Drafts';
77
- state.folderNames.spam = 'Junk Mail';
78
- state.folderNames.trash = '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({
@@ -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;