@agenticmail/api 0.7.19 → 0.7.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 CHANGED
@@ -751,20 +751,75 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
751
751
  next(err);
752
752
  }
753
753
  });
754
+ const MAX_DRAFT_ATTACHMENTS_BYTES = 25 * 1024 * 1024;
755
+ function normaliseDraftAttachments(raw) {
756
+ if (!Array.isArray(raw) || raw.length === 0) return null;
757
+ let totalBytes = 0;
758
+ const cleaned = raw.map((a) => {
759
+ const content = typeof a?.content === "string" ? a.content : "";
760
+ totalBytes += Math.ceil(content.length * 0.75);
761
+ return {
762
+ filename: typeof a?.filename === "string" ? a.filename : "attachment",
763
+ contentType: typeof a?.contentType === "string" ? a.contentType : "application/octet-stream",
764
+ content,
765
+ encoding: "base64",
766
+ size: typeof a?.size === "number" ? a.size : Math.ceil(content.length * 0.75)
767
+ };
768
+ });
769
+ if (totalBytes > MAX_DRAFT_ATTACHMENTS_BYTES) {
770
+ throw Object.assign(new Error("attachments exceed 25 MB total"), { status: 413 });
771
+ }
772
+ return JSON.stringify(cleaned);
773
+ }
754
774
  router.get("/drafts", requireAgent, async (req, res, next) => {
755
775
  try {
756
776
  const rows = db.prepare("SELECT * FROM drafts WHERE agent_id = ? ORDER BY updated_at DESC").all(req.agent.id);
757
- res.json({ drafts: rows });
777
+ const stripped = rows.map((r) => {
778
+ let metaOnly;
779
+ if (r.attachments) {
780
+ try {
781
+ metaOnly = JSON.parse(r.attachments).map((a) => ({
782
+ filename: a.filename,
783
+ contentType: a.contentType,
784
+ size: a.size
785
+ }));
786
+ } catch {
787
+ metaOnly = void 0;
788
+ }
789
+ }
790
+ return { ...r, attachments: metaOnly };
791
+ });
792
+ res.json({ drafts: stripped });
793
+ } catch (err) {
794
+ next(err);
795
+ }
796
+ });
797
+ router.get("/drafts/:id", requireAgent, async (req, res, next) => {
798
+ try {
799
+ const row = db.prepare("SELECT * FROM drafts WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
800
+ if (!row) {
801
+ res.status(404).json({ error: "Draft not found" });
802
+ return;
803
+ }
804
+ if (row.attachments) {
805
+ try {
806
+ row.attachments = JSON.parse(row.attachments);
807
+ } catch {
808
+ row.attachments = [];
809
+ }
810
+ }
811
+ res.json(row);
758
812
  } catch (err) {
759
813
  next(err);
760
814
  }
761
815
  });
762
816
  router.post("/drafts", requireAgent, async (req, res, next) => {
763
817
  try {
764
- const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
818
+ const { to, subject, text, html, cc, bcc, inReplyTo, references, attachments } = req.body || {};
819
+ const attachmentsJson = normaliseDraftAttachments(attachments);
765
820
  const id = uuidv4();
766
- db.prepare(`INSERT INTO drafts (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, in_reply_to, refs)
767
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
821
+ db.prepare(`INSERT INTO drafts (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, in_reply_to, refs, attachments)
822
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
768
823
  id,
769
824
  req.agent.id,
770
825
  to || null,
@@ -774,19 +829,30 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
774
829
  cc || null,
775
830
  bcc || null,
776
831
  inReplyTo || null,
777
- references ? JSON.stringify(references) : null
832
+ references ? JSON.stringify(references) : null,
833
+ attachmentsJson
778
834
  );
779
835
  res.json({ ok: true, id });
780
836
  } catch (err) {
837
+ const status = err.status;
838
+ if (status) {
839
+ res.status(status).json({ error: err.message });
840
+ return;
841
+ }
781
842
  next(err);
782
843
  }
783
844
  });
784
845
  router.put("/drafts/:id", requireAgent, async (req, res, next) => {
785
846
  try {
786
- const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
787
- const result = db.prepare(`UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
788
- cc=?, bcc=?, in_reply_to=?, refs=?, updated_at=datetime('now')
789
- WHERE id=? AND agent_id=?`).run(
847
+ const { to, subject, text, html, cc, bcc, inReplyTo, references, attachments } = req.body || {};
848
+ const includeAttachments = Object.prototype.hasOwnProperty.call(req.body || {}, "attachments");
849
+ const attachmentsJson = includeAttachments ? normaliseDraftAttachments(attachments) : void 0;
850
+ const sql = includeAttachments ? `UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
851
+ cc=?, bcc=?, in_reply_to=?, refs=?, attachments=?, updated_at=datetime('now')
852
+ WHERE id=? AND agent_id=?` : `UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
853
+ cc=?, bcc=?, in_reply_to=?, refs=?, updated_at=datetime('now')
854
+ WHERE id=? AND agent_id=?`;
855
+ const params = [
790
856
  to || null,
791
857
  subject || null,
792
858
  text || null,
@@ -794,16 +860,22 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
794
860
  cc || null,
795
861
  bcc || null,
796
862
  inReplyTo || null,
797
- references ? JSON.stringify(references) : null,
798
- req.params.id,
799
- req.agent.id
800
- );
863
+ references ? JSON.stringify(references) : null
864
+ ];
865
+ if (includeAttachments) params.push(attachmentsJson);
866
+ params.push(req.params.id, req.agent.id);
867
+ const result = db.prepare(sql).run(...params);
801
868
  if (result.changes === 0) {
802
869
  res.status(404).json({ error: "Draft not found" });
803
870
  return;
804
871
  }
805
872
  res.json({ ok: true });
806
873
  } catch (err) {
874
+ const status = err.status;
875
+ if (status) {
876
+ res.status(status).json({ error: err.message });
877
+ return;
878
+ }
807
879
  next(err);
808
880
  }
809
881
  });
@@ -833,6 +905,19 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
833
905
  const agent = req.agent;
834
906
  const wakeList = normalizeWakeList(req.body?.wake);
835
907
  const customHeaders = wakeHeaders(wakeList);
908
+ let persistedAttachments;
909
+ if (draft.attachments) {
910
+ try {
911
+ const parsed = JSON.parse(draft.attachments);
912
+ persistedAttachments = parsed.map((a) => ({
913
+ filename: a.filename,
914
+ contentType: a.contentType,
915
+ content: a.content,
916
+ encoding: "base64"
917
+ }));
918
+ } catch {
919
+ }
920
+ }
836
921
  const mailOpts = {
837
922
  to: draft.to_addr,
838
923
  subject: draft.subject || "(no subject)",
@@ -842,6 +927,7 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
842
927
  bcc: draft.bcc || void 0,
843
928
  inReplyTo: draft.in_reply_to || void 0,
844
929
  references: draft.refs ? JSON.parse(draft.refs) : void 0,
930
+ ...persistedAttachments && persistedAttachments.length > 0 ? { attachments: persistedAttachments } : {},
845
931
  ...Object.keys(customHeaders).length > 0 ? { headers: customHeaders } : {}
846
932
  };
847
933
  if (gatewayManager) {
@@ -1794,15 +1880,21 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
1794
1880
  });
1795
1881
  }
1796
1882
  }
1797
- function saveSentCopy(authUser, password, config, raw) {
1798
- (async () => {
1799
- try {
1800
- const receiver = await getReceiver(authUser, password, config);
1801
- await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
1802
- } catch (err) {
1803
- console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
1883
+ var sentFolderCache = /* @__PURE__ */ new Map();
1884
+ async function saveSentCopy(authUser, password, config, raw) {
1885
+ try {
1886
+ const receiver = await getReceiver(authUser, password, config);
1887
+ let folder = sentFolderCache.get(authUser);
1888
+ if (!folder) {
1889
+ const folders = await receiver.listFolders();
1890
+ const sentRe = /^sent\b|sent items|sent mail|sent messages|\[gmail\]\/sent/i;
1891
+ folder = folders.find((f) => f.specialUse === "\\Sent")?.path ?? folders.find((f) => sentRe.test(f.name) || sentRe.test(f.path))?.path ?? "Sent Items";
1892
+ sentFolderCache.set(authUser, folder);
1804
1893
  }
1805
- })();
1894
+ await receiver.appendMessage(raw, folder, ["\\Seen"]);
1895
+ } catch (err) {
1896
+ console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
1897
+ }
1806
1898
  }
1807
1899
  function createMailRoutes(accountManager2, config, db, gatewayManager) {
1808
1900
  const router = Router6();
@@ -2416,6 +2508,95 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
2416
2508
  next(err);
2417
2509
  }
2418
2510
  });
2511
+ router.post("/mail/messages/:uid/archive", requireAgent, async (req, res, next) => {
2512
+ try {
2513
+ const agent = req.agent;
2514
+ const uid = parseInt(req.params.uid);
2515
+ if (isNaN(uid) || uid < 1) {
2516
+ res.status(400).json({ error: "Invalid UID" });
2517
+ return;
2518
+ }
2519
+ const sourceFolder = req.body?.folder || "INBOX";
2520
+ const password = getAgentPassword(agent);
2521
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2522
+ const folders = await receiver.listFolders();
2523
+ const archiveRe = /^archives?\b|^all archive\b/i;
2524
+ let archiveFolder = folders.find((f) => f.specialUse === "\\Archive")?.path ?? folders.find((f) => archiveRe.test(f.name) || archiveRe.test(f.path))?.path;
2525
+ if (!archiveFolder) {
2526
+ try {
2527
+ await receiver.createFolder("Archive");
2528
+ } catch {
2529
+ }
2530
+ archiveFolder = "Archive";
2531
+ }
2532
+ if (archiveFolder === sourceFolder) {
2533
+ res.status(400).json({ error: "Message already in archive" });
2534
+ return;
2535
+ }
2536
+ await receiver.moveMessage(uid, sourceFolder, archiveFolder);
2537
+ res.json({ ok: true, archive: archiveFolder });
2538
+ } catch (err) {
2539
+ next(err);
2540
+ }
2541
+ });
2542
+ router.post("/mail/batch/archive", requireAgent, async (req, res, next) => {
2543
+ try {
2544
+ const agent = req.agent;
2545
+ const { uids: rawUids, folder } = req.body || {};
2546
+ const uids = validateUids(rawUids);
2547
+ if (!uids) {
2548
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
2549
+ return;
2550
+ }
2551
+ const sourceFolder = folder || "INBOX";
2552
+ const password = getAgentPassword(agent);
2553
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2554
+ const folders = await receiver.listFolders();
2555
+ const archiveRe = /^archives?\b|^all archive\b/i;
2556
+ let archiveFolder = folders.find((f) => f.specialUse === "\\Archive")?.path ?? folders.find((f) => archiveRe.test(f.name) || archiveRe.test(f.path))?.path;
2557
+ if (!archiveFolder) {
2558
+ try {
2559
+ await receiver.createFolder("Archive");
2560
+ } catch {
2561
+ }
2562
+ archiveFolder = "Archive";
2563
+ }
2564
+ if (archiveFolder === sourceFolder) {
2565
+ res.status(400).json({ error: "Messages already in archive" });
2566
+ return;
2567
+ }
2568
+ await receiver.batchMove(uids, sourceFolder, archiveFolder);
2569
+ res.json({ ok: true, archived: uids.length, archive: archiveFolder });
2570
+ } catch (err) {
2571
+ next(err);
2572
+ }
2573
+ });
2574
+ router.post("/mail/batch/trash", requireAgent, async (req, res, next) => {
2575
+ try {
2576
+ const agent = req.agent;
2577
+ const { uids: rawUids, folder } = req.body || {};
2578
+ const uids = validateUids(rawUids);
2579
+ if (!uids) {
2580
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
2581
+ return;
2582
+ }
2583
+ const sourceFolder = folder || "INBOX";
2584
+ const password = getAgentPassword(agent);
2585
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2586
+ const folders = await receiver.listFolders();
2587
+ const trashRe = /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i;
2588
+ const trashFolder = folders.find((f) => f.specialUse === "\\Trash")?.path ?? folders.find((f) => trashRe.test(f.name) || trashRe.test(f.path))?.path;
2589
+ if (!trashFolder || trashFolder === sourceFolder) {
2590
+ await receiver.batchDelete(uids, sourceFolder);
2591
+ res.json({ ok: true, deleted: uids.length });
2592
+ return;
2593
+ }
2594
+ await receiver.batchMove(uids, sourceFolder, trashFolder);
2595
+ res.json({ ok: true, trashed: uids.length, trash: trashFolder });
2596
+ } catch (err) {
2597
+ next(err);
2598
+ }
2599
+ });
2419
2600
  router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
2420
2601
  try {
2421
2602
  const agent = req.agent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.19",
3
+ "version": "0.7.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",
package/public/js/app.js CHANGED
@@ -55,9 +55,16 @@ function showAuthErr(msg) {
55
55
  }
56
56
  function signOut() {
57
57
  localStorage.removeItem('agenticmail.masterKey');
58
+ localStorage.removeItem('agenticmail.selectedAgentId');
58
59
  location.reload();
59
60
  }
60
61
 
62
+ // localStorage key for the inbox the user was last viewing.
63
+ // Persisted on every successful agent switch and consulted on
64
+ // bootstrap so a refresh / reopen lands on the same account
65
+ // instead of bouncing back to the bridge.
66
+ const STORAGE_LAST_AGENT = 'agenticmail.selectedAgentId';
67
+
61
68
  document.getElementById('auth-submit').addEventListener('click', signIn);
62
69
  document.getElementById('auth-key').addEventListener('keydown', e => {
63
70
  if (e.key === 'Enter') signIn();
@@ -77,7 +84,15 @@ async function bootstrap() {
77
84
  return a.name.localeCompare(b.name);
78
85
  });
79
86
  state.agents = all;
80
- const initial = state.agents.find(isBridgeAgent) ?? state.agents[0];
87
+ // Prefer the inbox the user was last viewing (persisted in
88
+ // localStorage on every selectAgent call). Falls back to the
89
+ // bridge if the stored id is gone (agent was deleted) or the
90
+ // user never switched. Fixes the "refresh always bounces me
91
+ // back to the host account" bug.
92
+ const lastId = localStorage.getItem(STORAGE_LAST_AGENT);
93
+ const initial = (lastId && state.agents.find(a => a.id === lastId))
94
+ ?? state.agents.find(isBridgeAgent)
95
+ ?? state.agents[0];
81
96
  if (initial) await selectAgent(initial);
82
97
  renderProfile();
83
98
  populateComposeFrom();
@@ -96,6 +111,11 @@ async function selectAgent(agent) {
96
111
  state.selectedAgent = agent;
97
112
  state.selectedUid = null;
98
113
  state.currentMessage = null;
114
+ // Persist the selection so a page refresh lands back on this
115
+ // inbox rather than bouncing to the bridge. Stored under a
116
+ // separate key from the master key so signing out clears it
117
+ // cleanly without affecting auth.
118
+ try { localStorage.setItem(STORAGE_LAST_AGENT, agent.id); } catch { /* private mode etc. */ }
99
119
  // Reset the per-agent folder cache so a fresh discovery runs
100
120
  // against the new agent's IMAP. Otherwise switching to an
101
121
  // account that uses different folder names (e.g. Gmail relay
@@ -102,8 +102,10 @@ export async function openDraft(draftId) {
102
102
  wireAutosave();
103
103
  wireAttachmentPicker();
104
104
  try {
105
- const data = await apiGet('/drafts', { agentKey: state.selectedAgent.apiKey });
106
- const draft = (data?.drafts ?? []).find(d => d.id === draftId);
105
+ // Use the single-draft endpoint, which returns attachment
106
+ // content in full (the list endpoint only sends metadata to
107
+ // keep the sidebar payload small).
108
+ const draft = await apiGet(`/drafts/${encodeURIComponent(draftId)}`, { agentKey: state.selectedAgent.apiKey });
107
109
  if (!draft) throw new Error('Draft not found');
108
110
  document.getElementById('compose-to').value = draft.to_addr ?? '';
109
111
  document.getElementById('compose-cc').value = draft.cc ?? '';
@@ -111,6 +113,19 @@ export async function openDraft(draftId) {
111
113
  document.getElementById('compose-body').value = draft.text_body ?? '';
112
114
  document.getElementById('compose-title').textContent =
113
115
  `Draft: ${draft.subject || '(no subject)'}`;
116
+ // Rehydrate attachment chips with the persisted blobs. Map
117
+ // the server-side `size` field back into the in-memory
118
+ // `sizeBytes` alias the rest of the compose code uses for
119
+ // the UI-side 20 MB cap.
120
+ pendingAttachments = Array.isArray(draft.attachments)
121
+ ? draft.attachments.map(a => ({
122
+ filename: a.filename,
123
+ contentType: a.contentType,
124
+ content: a.content,
125
+ encoding: 'base64',
126
+ sizeBytes: typeof a.size === 'number' ? a.size : 0,
127
+ }))
128
+ : [];
114
129
  renderAttachmentChips();
115
130
  setComposeStatus('Loaded from Drafts');
116
131
  setTimeout(() => document.getElementById('compose-body').focus(), 50);
@@ -174,12 +189,27 @@ function readComposeFields() {
174
189
  const subject = document.getElementById('compose-subject').value.trim();
175
190
  const text = document.getElementById('compose-body').value;
176
191
  const cc = document.getElementById('compose-cc').value.trim();
177
- if (!to && !subject && !text.trim() && !cc) return null;
192
+ if (!to && !subject && !text.trim() && !cc && pendingAttachments.length === 0) return null;
193
+ // The API expects `{ filename, contentType, content (base64),
194
+ // size }` per attachment. Drop the local-only sizeBytes alias
195
+ // and the redundant encoding field — the server defaults to
196
+ // base64 anyway.
197
+ const attachments = pendingAttachments.map(a => ({
198
+ filename: a.filename,
199
+ contentType: a.contentType,
200
+ content: a.content,
201
+ size: a.sizeBytes,
202
+ }));
178
203
  return {
179
204
  to: to || null,
180
205
  subject: subject || null,
181
206
  text: text || null,
182
207
  cc: cc || null,
208
+ // Always send `attachments` (even empty) so the server clears
209
+ // the stored blob when the user removes every chip. The PUT
210
+ // route uses `hasOwnProperty('attachments')` to distinguish
211
+ // "leave alone" from "set to empty".
212
+ attachments,
183
213
  };
184
214
  }
185
215
 
@@ -278,6 +308,11 @@ function wireAttachmentPicker() {
278
308
  }
279
309
  }
280
310
  renderAttachmentChips();
311
+ // Persist the new attachments to the draft so a "close and
312
+ // reopen" round-trip keeps them. Without this, attachments
313
+ // only ever lived in memory until the user typed in another
314
+ // field and triggered autosave organically.
315
+ scheduleAutosave();
281
316
  });
282
317
  }
283
318
 
@@ -310,6 +345,9 @@ function renderAttachmentChips() {
310
345
  el.addEventListener('click', () => {
311
346
  pendingAttachments.splice(Number(el.dataset.attRemove), 1);
312
347
  renderAttachmentChips();
348
+ // Same reason as the picker: removing a chip needs to
349
+ // persist or the draft round-trip will resurrect the file.
350
+ scheduleAutosave();
313
351
  });
314
352
  });
315
353
  }
@@ -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;