@agenticmail/cli 0.8.30 → 0.8.32

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
@@ -23,7 +23,14 @@ It bundles a setup wizard, API server launcher, and a full interactive shell wit
23
23
  >
24
24
  > Zero prompts, ~2 minutes, fully autonomous. Then tell the user to restart Claude Code. Full agent-facing runbook — including verification, common failures, and "what if Node < 22" — is at [`AGENTS.md`](https://github.com/agenticmail/agenticmail/blob/main/AGENTS.md) in the repo (the [agents.md](https://agents.md) convention).
25
25
 
26
- ## ✨ What's new in 0.8.27
26
+ ## ✨ What's new in 0.8.31
27
+
28
+ - **⏱ Compact-and-continue** — workers run across multiple SDK turns when one turn isn't enough. On context overflow the dispatcher synthesises a breadcrumb checkpoint from the captured log, builds a "resuming after context reset / do NOT redo" continuation prompt, and loops (4-iter cap so cost is bounded).
29
+ - **📐 Typed task contracts** — `call_agent` / `POST /tasks/assign` accept an `outputSchema` (JSON Schema, draft-7 subset). The wake prompt renders the schema into the worker's instructions and `submit_result` validates against it; mismatches return 400 with a flat `schemaErrors: [{ path, message }]` list so the worker can retry with a corrected shape.
30
+ - **🪝 Mail-hook polish** — Stop hook output rewritten as a clean inbox digest (preview, audience-neutral phrasing, no instruction-leakage). Hook bin path resolved with `import.meta.url` + filesystem probing so it works on both global npm installs and dev checkouts; the previous `command not found` and `MODULE_NOT_FOUND` errors are gone. Old installs auto-heal on the next `agenticmail claudecode` run.
31
+ - **🖱 Web UI fixes** — Delete + Move-to-Spam buttons in the message view; Compose auto-saves to Drafts every 2 s; `All Mail` folder hides itself on servers that don't have one; select-all checkbox wires through; AgenticMail logo PNG is now RGBA (transparent) instead of RGB with a baked-in white box.
32
+
33
+ ## ✨ Earlier — 0.8.27
27
34
 
28
35
  - **Web UI folder fix** — Sent / Drafts / Spam / Trash returned empty because hard-coded names didn't match Stalwart's IMAP names. Auto-discovery now matches `Sent Items`, `Junk Mail`, `Deleted Items`, `[Gmail]/…`, etc.
29
36
  - **Two-line preview** on every list row (switched to `/mail/digest`).
@@ -86,10 +86,14 @@
86
86
  <div class="compose-row"><label>Wake</label><input id="compose-wake" placeholder="(optional) names to wake — e.g. alice, bob" /></div>
87
87
  <div class="compose-row"><label>Subject</label><input id="compose-subject" /></div>
88
88
  <textarea id="compose-body" placeholder="Markdown supported: **bold**, *italic*, `code`, ```fenced```, ## headings, lists, tables…"></textarea>
89
+ <div id="compose-attachments" class="compose-attachments"></div>
90
+ <input id="compose-file-input" type="file" multiple style="display:none" />
89
91
  <p class="compose-hint">Tip: pass <code class="mono">wake</code> to limit which CC'd agents get a Claude turn. Add <code class="mono">[FINAL]</code> to the subject to close the thread.</p>
90
92
  </div>
91
93
  <div class="compose-foot">
92
94
  <button class="btn-send" id="compose-send">Send</button>
95
+ <button class="btn-attach icon-btn" id="compose-attach-btn" title="Attach files" data-icon="attachment"></button>
96
+ <span class="compose-status" id="compose-status"></span>
93
97
  <button class="btn-discard" id="compose-cancel">Discard</button>
94
98
  </div>
95
99
  </div>
@@ -23,3 +23,53 @@ export async function apiPost(path, body, opts = {}) {
23
23
  if (!r.ok) throw new Error(`${r.status} ${path}`);
24
24
  return await r.json();
25
25
  }
26
+
27
+ export async function apiPut(path, body, opts = {}) {
28
+ const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
29
+ method: 'PUT',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ Authorization: `Bearer ${opts.agentKey ?? state.masterKey}`,
33
+ },
34
+ body: JSON.stringify(body),
35
+ });
36
+ if (!r.ok) throw new Error(`${r.status} ${path}`);
37
+ return await r.json();
38
+ }
39
+
40
+ /**
41
+ * Fetch an attachment with auth and trigger a browser download.
42
+ *
43
+ * Browsers don't send custom headers on `<a href>` clicks, so a
44
+ * plain anchor pointing at the authed endpoint returns 401. We
45
+ * fetch the bytes via `fetch` + Authorization header, convert to a
46
+ * blob, build an object URL, and synthesise a click on a hidden
47
+ * anchor. The object URL is revoked after a short tick so memory
48
+ * isn't held forever.
49
+ */
50
+ export async function downloadAttachment(uid, index, filename, opts = {}) {
51
+ const r = await fetch(`${API_URL}/api/agenticmail/mail/messages/${uid}/attachments/${index}`, {
52
+ headers: { Authorization: `Bearer ${opts.agentKey ?? state.masterKey}` },
53
+ });
54
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
55
+ const blob = await r.blob();
56
+ const url = URL.createObjectURL(blob);
57
+ const a = document.createElement('a');
58
+ a.href = url;
59
+ a.download = filename || 'attachment';
60
+ document.body.appendChild(a);
61
+ a.click();
62
+ a.remove();
63
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
64
+ }
65
+
66
+ export async function apiDelete(path, opts = {}) {
67
+ const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
68
+ method: 'DELETE',
69
+ headers: { Authorization: `Bearer ${opts.agentKey ?? state.masterKey}` },
70
+ });
71
+ if (!r.ok) throw new Error(`${r.status} ${path}`);
72
+ // DELETE may return 204 No Content — guard against empty body.
73
+ const text = await r.text();
74
+ return text ? JSON.parse(text) : { ok: true };
75
+ }
@@ -10,7 +10,7 @@ import { apiGet } from './api.js';
10
10
  import { isBridgeAgent } from './avatar.js';
11
11
  import { renderProfile, toggleProfileMenu, closeProfileMenu } from './profile.js';
12
12
  import { renderSidebar } from './sidebar.js';
13
- import { loadList, renderList, clearSearch } from './list-view.js';
13
+ import { loadList, renderList, clearSearch, ensureFolderCache } from './list-view.js';
14
14
  import { openMessage } from './message-view.js';
15
15
  import { populateComposeFrom, openCompose, closeCompose, sendCompose } from './compose.js';
16
16
  import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
@@ -96,6 +96,15 @@ async function selectAgent(agent) {
96
96
  state.selectedAgent = agent;
97
97
  state.selectedUid = null;
98
98
  state.currentMessage = null;
99
+ // Reset the per-agent folder cache so a fresh discovery runs
100
+ // against the new agent's IMAP. Otherwise switching to an
101
+ // account that uses different folder names (e.g. Gmail relay
102
+ // vs vanilla Stalwart) keeps the previous cache.
103
+ state.folderNames = {};
104
+ // Discover folders BEFORE the first sidebar render so the
105
+ // `requiresDiscovery` hide-rule (All Mail on non-Gmail servers)
106
+ // has the cache to consult. Falls back to defaults on failure.
107
+ await ensureFolderCache(agent);
99
108
  renderSidebar(onFolderSelect);
100
109
  renderProfile();
101
110
  await loadList(agent, state.selectedFolder);
@@ -1,10 +1,30 @@
1
1
  // Gmail-style bottom-right compose popup. Handles both new-message
2
2
  // and reply flows. `wake` is the AgenticMail selective-wake hint.
3
+ //
4
+ // Draft autosave: every keystroke on the to / cc / subject / body
5
+ // fields schedules a 2s-debounced save to `/drafts`. First save
6
+ // POSTs and stores the returned id; subsequent saves PUT to that
7
+ // id. On Send, the draft (if any) is deleted after the send
8
+ // succeeds — otherwise it stays around so the user can find it
9
+ // in the Drafts folder.
3
10
  import { state } from './state.js';
4
11
  import { escapeHtml, toast } from './utils.js';
5
- import { apiPost } from './api.js';
12
+ import { apiPost, apiPut, apiDelete } from './api.js';
6
13
  import { loadList } from './list-view.js';
7
14
 
15
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
16
+ let autosaveTimer = null;
17
+ let autosaveInFlight = false;
18
+
19
+ /**
20
+ * In-memory attachment buffer for the current compose. Each entry
21
+ * is `{ filename, contentType, content (base64), encoding }` —
22
+ * the same shape the API's `/mail/send` accepts. We don't persist
23
+ * attachments to the draft store (the drafts table doesn't have
24
+ * a binary column); a draft round-trip loses them by design.
25
+ */
26
+ let pendingAttachments = [];
27
+
8
28
  export function populateComposeFrom() {
9
29
  const sel = document.getElementById('compose-from');
10
30
  sel.innerHTML = state.agents
@@ -14,11 +34,17 @@ export function populateComposeFrom() {
14
34
 
15
35
  export function openCompose() {
16
36
  state.composeReplyContext = null;
37
+ state.composeDraftId = null;
38
+ pendingAttachments = [];
17
39
  document.getElementById('compose-title').textContent = 'New message';
18
40
  if (state.selectedAgent) document.getElementById('compose-from').value = state.selectedAgent.id;
19
41
  ['compose-to', 'compose-cc', 'compose-wake', 'compose-subject', 'compose-body']
20
42
  .forEach(id => { document.getElementById(id).value = ''; });
43
+ renderAttachmentChips();
44
+ setComposeStatus('');
21
45
  showModal();
46
+ wireAutosave();
47
+ wireAttachmentPicker();
22
48
  setTimeout(() => document.getElementById('compose-to').focus(), 50);
23
49
  }
24
50
 
@@ -26,6 +52,7 @@ export function openReply(replyAll) {
26
52
  if (!state.currentMessage) return;
27
53
  const msg = state.currentMessage;
28
54
  state.composeReplyContext = { uid: msg.uid, agent: state.selectedAgent, replyAll };
55
+ state.composeDraftId = null;
29
56
  document.getElementById('compose-title').textContent =
30
57
  `Reply${replyAll ? ' all' : ''}: ${msg.subject ?? '(no subject)'}`;
31
58
  document.getElementById('compose-from').value = state.selectedAgent.id;
@@ -45,18 +72,188 @@ export function openReply(replyAll) {
45
72
  const quoted = (msg.text ?? '').split('\n').map(l => `> ${l}`).join('\n');
46
73
  const stub = `\n\nOn ${msg.date}, ${fromAddr} wrote:\n${quoted}`;
47
74
  document.getElementById('compose-body').value = stub;
75
+ pendingAttachments = [];
76
+ renderAttachmentChips();
77
+ setComposeStatus('');
48
78
  showModal();
79
+ wireAutosave();
80
+ wireAttachmentPicker();
49
81
  setTimeout(() => document.getElementById('compose-body').focus(), 50);
50
82
  }
51
83
 
52
84
  export function closeCompose() {
53
85
  document.getElementById('compose-bg').style.display = 'none';
86
+ // Flush a final save synchronously-ish on close so a quick
87
+ // "type → close" doesn't lose work. We only fire if there's a
88
+ // pending debounce — if the user already saved or never typed,
89
+ // skip the network call.
90
+ if (autosaveTimer) {
91
+ clearTimeout(autosaveTimer);
92
+ autosaveTimer = null;
93
+ void runAutosave();
94
+ }
54
95
  }
55
96
 
56
97
  function showModal() {
57
98
  document.getElementById('compose-bg').style.display = 'flex';
58
99
  }
59
100
 
101
+ /**
102
+ * Build the field set the drafts API expects from current modal
103
+ * state. Returns null when the draft is empty (no point persisting
104
+ * a blank shell).
105
+ */
106
+ function readComposeFields() {
107
+ const to = document.getElementById('compose-to').value.trim();
108
+ const subject = document.getElementById('compose-subject').value.trim();
109
+ const text = document.getElementById('compose-body').value;
110
+ const cc = document.getElementById('compose-cc').value.trim();
111
+ if (!to && !subject && !text.trim() && !cc) return null;
112
+ return {
113
+ to: to || null,
114
+ subject: subject || null,
115
+ text: text || null,
116
+ cc: cc || null,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Wire the autosave debounce to every input/textarea in the modal.
122
+ * Re-wires on every open() so removed/replaced DOM nodes don't
123
+ * accumulate listeners.
124
+ */
125
+ function wireAutosave() {
126
+ ['compose-to', 'compose-cc', 'compose-subject', 'compose-body'].forEach(id => {
127
+ const el = document.getElementById(id);
128
+ if (!el) return;
129
+ // Marker prevents double-binding.
130
+ if (el._autosaveBound) return;
131
+ el._autosaveBound = true;
132
+ el.addEventListener('input', scheduleAutosave);
133
+ });
134
+ }
135
+
136
+ function scheduleAutosave() {
137
+ setComposeStatus('Saving…');
138
+ if (autosaveTimer) clearTimeout(autosaveTimer);
139
+ autosaveTimer = setTimeout(runAutosave, AUTOSAVE_DEBOUNCE_MS);
140
+ }
141
+
142
+ async function runAutosave() {
143
+ autosaveTimer = null;
144
+ if (autosaveInFlight) {
145
+ // Coalesce: re-schedule one more pass after the current
146
+ // request lands so we don't lose the latest keystroke.
147
+ autosaveTimer = setTimeout(runAutosave, AUTOSAVE_DEBOUNCE_MS);
148
+ return;
149
+ }
150
+ const fields = readComposeFields();
151
+ if (!fields) { setComposeStatus(''); return; }
152
+ const agentId = document.getElementById('compose-from').value;
153
+ const agent = state.agents.find(a => a.id === agentId) ?? state.selectedAgent;
154
+ if (!agent) return;
155
+ autosaveInFlight = true;
156
+ try {
157
+ if (state.composeDraftId) {
158
+ await apiPut(`/drafts/${state.composeDraftId}`, fields, { agentKey: agent.apiKey });
159
+ } else {
160
+ const r = await apiPost('/drafts', fields, { agentKey: agent.apiKey });
161
+ state.composeDraftId = r?.id ?? null;
162
+ }
163
+ setComposeStatus('Saved to Drafts');
164
+ } catch (err) {
165
+ setComposeStatus(`Save failed: ${err.message}`);
166
+ } finally {
167
+ autosaveInFlight = false;
168
+ }
169
+ }
170
+
171
+ function setComposeStatus(text) {
172
+ const el = document.getElementById('compose-status');
173
+ if (el) el.textContent = text;
174
+ }
175
+
176
+ /**
177
+ * Wire the paperclip button + hidden file input. Reads files as
178
+ * base64 (FileReader → ArrayBuffer → btoa) and appends them to
179
+ * `pendingAttachments`. We cap total payload at 20 MB because
180
+ * Stalwart's default SMTP message-size limit is in that range —
181
+ * larger and the send would silently fail on the wire.
182
+ */
183
+ const ATTACHMENT_TOTAL_CAP_BYTES = 20 * 1024 * 1024;
184
+
185
+ function wireAttachmentPicker() {
186
+ const btn = document.getElementById('compose-attach-btn');
187
+ const input = document.getElementById('compose-file-input');
188
+ if (!btn || !input) return;
189
+ if (btn._attachBound) return;
190
+ btn._attachBound = true;
191
+ btn.addEventListener('click', () => input.click());
192
+ input.addEventListener('change', async () => {
193
+ const files = Array.from(input.files ?? []);
194
+ input.value = ''; // allow re-picking the same file later
195
+ for (const f of files) {
196
+ const currentBytes = pendingAttachments.reduce((s, a) => s + a.sizeBytes, 0);
197
+ if (currentBytes + f.size > ATTACHMENT_TOTAL_CAP_BYTES) {
198
+ toast(`Skipped ${f.name}: total attachments would exceed 20 MB.`, true);
199
+ continue;
200
+ }
201
+ try {
202
+ const content = await fileToBase64(f);
203
+ pendingAttachments.push({
204
+ filename: f.name,
205
+ contentType: f.type || 'application/octet-stream',
206
+ content,
207
+ encoding: 'base64',
208
+ sizeBytes: f.size,
209
+ });
210
+ } catch (err) {
211
+ toast(`Couldn't read ${f.name}: ${err.message}`, true);
212
+ }
213
+ }
214
+ renderAttachmentChips();
215
+ });
216
+ }
217
+
218
+ function fileToBase64(file) {
219
+ return new Promise((resolve, reject) => {
220
+ const reader = new FileReader();
221
+ reader.onload = () => {
222
+ // result is `data:<mime>;base64,<payload>` — strip the prefix.
223
+ const r = String(reader.result ?? '');
224
+ const i = r.indexOf(',');
225
+ resolve(i >= 0 ? r.slice(i + 1) : r);
226
+ };
227
+ reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
228
+ reader.readAsDataURL(file);
229
+ });
230
+ }
231
+
232
+ function renderAttachmentChips() {
233
+ const root = document.getElementById('compose-attachments');
234
+ if (!root) return;
235
+ if (pendingAttachments.length === 0) { root.innerHTML = ''; return; }
236
+ root.innerHTML = pendingAttachments.map((a, i) => `
237
+ <span class="attachment-chip" data-att-index="${i}">
238
+ <span class="chip-name" title="${escapeHtml(a.filename)}">${escapeHtml(a.filename)}</span>
239
+ <span class="chip-size">${formatBytes(a.sizeBytes)}</span>
240
+ <button class="chip-remove" data-att-remove="${i}" title="Remove">×</button>
241
+ </span>
242
+ `).join('');
243
+ root.querySelectorAll('[data-att-remove]').forEach(el => {
244
+ el.addEventListener('click', () => {
245
+ pendingAttachments.splice(Number(el.dataset.attRemove), 1);
246
+ renderAttachmentChips();
247
+ });
248
+ });
249
+ }
250
+
251
+ function formatBytes(bytes) {
252
+ if (bytes < 1024) return `${bytes} B`;
253
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
254
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
255
+ }
256
+
60
257
  export async function sendCompose() {
61
258
  const agentId = document.getElementById('compose-from').value;
62
259
  const agent = state.agents.find(a => a.id === agentId);
@@ -70,8 +267,26 @@ export async function sendCompose() {
70
267
  const body = { to, subject, text };
71
268
  if (cc) body.cc = cc;
72
269
  if (wakeRaw) body.wake = wakeRaw.split(',').map(s => s.trim()).filter(Boolean);
270
+ if (pendingAttachments.length > 0) {
271
+ // Strip the local-only `sizeBytes` field — the API expects
272
+ // only filename/contentType/content/encoding. Keeping the
273
+ // extra field works (it's ignored) but is noise on the wire.
274
+ body.attachments = pendingAttachments.map(a => ({
275
+ filename: a.filename,
276
+ contentType: a.contentType,
277
+ content: a.content,
278
+ encoding: a.encoding,
279
+ }));
280
+ }
73
281
  try {
74
282
  await apiPost('/mail/send', body, { agentKey: agent.apiKey });
283
+ // Clean up the autosaved draft (if any) — the message is in
284
+ // the real Sent folder now, no need to keep a Drafts entry.
285
+ if (state.composeDraftId) {
286
+ try { await apiDelete(`/drafts/${state.composeDraftId}`, { agentKey: agent.apiKey }); } catch { /* ignore */ }
287
+ state.composeDraftId = null;
288
+ }
289
+ pendingAttachments = [];
75
290
  closeCompose();
76
291
  toast('Sent.');
77
292
  if (state.selectedAgent?.id === agent.id) await loadList(agent, state.selectedFolder);
@@ -97,6 +97,15 @@ export async function loadList(agent, folder) {
97
97
  <div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
98
98
  `;
99
99
  document.getElementById('list-refresh-btn')?.addEventListener('click', () => loadList(agent, folder));
100
+ // Select-all toggles every visible row checkbox. We don't currently
101
+ // expose a bulk-action toolbar (delete/archive/move) yet, so this
102
+ // is purely a visual selection state for now — but wiring it
103
+ // means it works the moment a bulk-action surface lands.
104
+ document.getElementById('list-select-all-input')?.addEventListener('change', (e) => {
105
+ const checked = e.target.checked;
106
+ document.querySelectorAll('#list-rows .row-check input[type=checkbox]')
107
+ .forEach(cb => { cb.checked = checked; });
108
+ });
100
109
  await ensureFolderCache(agent);
101
110
 
102
111
  // Resolve the real IMAP folder. Starred reuses INBOX + a client-
@@ -4,10 +4,11 @@ import { escapeHtml, stripHtml, toast } from './utils.js';
4
4
  import { formatDateFull } from './time.js';
5
5
  import { renderMarkdown } from './markdown.js';
6
6
  import { avatarHtml } from './avatar.js';
7
- import { apiGet, apiPost } from './api.js';
7
+ import { apiGet, apiPost, apiDelete, downloadAttachment } from './api.js';
8
8
  import { openReply } from './compose.js';
9
9
  import { loadList } from './list-view.js';
10
10
  import { icon } from './icons.js';
11
+ import { confirmModal } from './modal.js';
11
12
 
12
13
  export async function openMessage(uid) {
13
14
  if (!state.selectedAgent) return;
@@ -19,6 +20,8 @@ export async function openMessage(uid) {
19
20
  <button class="icon-btn" id="msg-reply" title="Reply">${icon('reply')}</button>
20
21
  <button class="icon-btn" id="msg-reply-all" title="Reply all">${icon('replyAll')}</button>
21
22
  <button class="icon-btn" id="msg-unread" title="Mark unread">${icon('mailUnread')}</button>
23
+ <button class="icon-btn" id="msg-spam" title="Report spam">${icon('spam')}</button>
24
+ <button class="icon-btn" id="msg-delete" title="Delete">${icon('trash')}</button>
22
25
  <div class="toolbar-spacer"></div>
23
26
  </div>
24
27
  <div class="message-view"><div class="empty">Loading…</div></div>
@@ -27,6 +30,8 @@ export async function openMessage(uid) {
27
30
  document.getElementById('msg-reply').addEventListener('click', () => openReply(false));
28
31
  document.getElementById('msg-reply-all').addEventListener('click', () => openReply(true));
29
32
  document.getElementById('msg-unread').addEventListener('click', () => markUnread());
33
+ document.getElementById('msg-spam').addEventListener('click', () => markSpam());
34
+ document.getElementById('msg-delete').addEventListener('click', () => deleteMessage());
30
35
 
31
36
  try {
32
37
  const msg = await apiGet(`/mail/messages/${uid}`, { agentKey: state.selectedAgent.apiKey });
@@ -49,8 +54,12 @@ function renderMessage(msg) {
49
54
  const bodyText = msg.text ?? stripHtml(msg.html ?? '');
50
55
 
51
56
  const attachmentsHtml = (msg.attachments ?? []).length > 0
52
- ? `<div class="message-attachments">${msg.attachments.map(a =>
53
- `<span class="message-attachment"><span class="att-icon">${icon('attachment', { size: 18 })}</span>${escapeHtml(a.filename ?? '(unnamed)')}${a.size ? ` · ${Math.round(a.size/1024)}KB` : ''}</span>`
57
+ ? `<div class="message-attachments">${msg.attachments.map((a, i) =>
58
+ `<button class="message-attachment" data-att-index="${i}" data-att-filename="${escapeHtml(a.filename ?? 'attachment')}" title="Click to download">
59
+ <span class="att-icon">${icon('attachment', { size: 18 })}</span>
60
+ <span class="att-name">${escapeHtml(a.filename ?? '(unnamed)')}</span>
61
+ ${a.size ? `<span class="att-size">${formatBytes(a.size)}</span>` : ''}
62
+ </button>`
54
63
  ).join('')}</div>`
55
64
  : '';
56
65
 
@@ -69,15 +78,134 @@ function renderMessage(msg) {
69
78
  <div class="message-date">${escapeHtml(formatDateFull(msg.date))}</div>
70
79
  </div>
71
80
  </div>
72
- <div class="message-body">${renderMarkdown(bodyText)}</div>
81
+ <div class="message-body">${renderBodyWithThreading(bodyText)}</div>
73
82
  ${attachmentsHtml}
74
83
  `;
84
+
85
+ // Wire attachment download clicks. Browsers don't pass our auth
86
+ // header on a plain <a href>, so we fetch+blob+synthesise the
87
+ // click in api.js → downloadAttachment.
88
+ view.querySelectorAll('.message-attachment').forEach((el) => {
89
+ el.addEventListener('click', async () => {
90
+ const idx = Number(el.dataset.attIndex);
91
+ const filename = el.dataset.attFilename;
92
+ el.classList.add('downloading');
93
+ try {
94
+ await downloadAttachment(state.selectedUid, idx, filename, { agentKey: state.selectedAgent.apiKey });
95
+ } catch (err) {
96
+ toast(`Download failed: ${err.message}`, true);
97
+ } finally {
98
+ el.classList.remove('downloading');
99
+ }
100
+ });
101
+ });
102
+ }
103
+
104
+ /** Pretty-print byte counts (KB / MB) for attachment size display. */
105
+ function formatBytes(bytes) {
106
+ if (bytes < 1024) return `${bytes} B`;
107
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
108
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
109
+ }
110
+
111
+ /**
112
+ * Render a message body with proper email-thread chrome around
113
+ * quoted replies.
114
+ *
115
+ * Most clients (including ours, via `openReply` in compose.js)
116
+ * prefix a quoted-reply block with the canonical header line:
117
+ *
118
+ * On 2026-05-13T22:50:24.000Z, claudecode@localhost wrote:
119
+ * > original body line 1
120
+ * > original body line 2
121
+ *
122
+ * Rendered with our plain markdown that becomes raw text + a
123
+ * blockquote of `>` lines — visible but ugly: ISO timestamp, no
124
+ * avatar, no nice formatting. This function detects the pattern,
125
+ * extracts (date, sender, quoted body), and renders each quoted
126
+ * chunk as a styled "thread-quote" card with the right chrome:
127
+ * sender avatar, name, friendly date, and the quoted body
128
+ * recursively threaded (for replies-to-replies).
129
+ *
130
+ * Non-matching prose flows through untouched via `renderMarkdown`.
131
+ */
132
+ function renderBodyWithThreading(src) {
133
+ if (!src) return '<div class="empty">(no body)</div>';
134
+ const lines = src.split('\n');
135
+ const out = [];
136
+ let prose = [];
137
+ let i = 0;
138
+
139
+ const flushProse = () => {
140
+ if (prose.length === 0) return;
141
+ out.push(renderMarkdown(prose.join('\n')));
142
+ prose = [];
143
+ };
144
+
145
+ // Header pattern: `On <date>, <addr> wrote:` with optional
146
+ // angle-bracket form `<addr@host>`. Date is anything up to the
147
+ // comma; addr is anything not whitespace + an @.
148
+ const headerRe = /^On (.+?), <?([^\s<>]+@[^\s<>]+)>? wrote:\s*$/;
149
+
150
+ while (i < lines.length) {
151
+ const m = lines[i].match(headerRe);
152
+ if (!m) {
153
+ prose.push(lines[i]);
154
+ i++;
155
+ continue;
156
+ }
157
+ flushProse();
158
+ const dateRaw = m[1];
159
+ const sender = m[2];
160
+ i++;
161
+ // Collect contiguous `>` lines (with possible blank-line gaps
162
+ // inside the quote, which most clients tolerate). Stop at the
163
+ // first non-quote, non-blank line.
164
+ const quoted = [];
165
+ while (i < lines.length) {
166
+ const l = lines[i];
167
+ if (l.startsWith('>')) { quoted.push(l.replace(/^>\s?/, '')); i++; continue; }
168
+ if (l.trim() === '') {
169
+ // Peek ahead — if the next non-blank is another `>`, the
170
+ // blank line is part of the quote; otherwise we're done.
171
+ let j = i + 1;
172
+ while (j < lines.length && lines[j].trim() === '') j++;
173
+ if (j < lines.length && lines[j].startsWith('>')) { quoted.push(''); i++; continue; }
174
+ }
175
+ break;
176
+ }
177
+ out.push(renderThreadQuote(dateRaw, sender, quoted.join('\n')));
178
+ }
179
+ flushProse();
180
+ return out.join('');
181
+ }
182
+
183
+ function renderThreadQuote(dateRaw, sender, quotedBody) {
184
+ // Try to format the ISO date through the same helper the
185
+ // message header uses; fall back to the raw string on parse fail.
186
+ const friendlyDate = (() => {
187
+ const d = new Date(dateRaw);
188
+ if (!Number.isNaN(d.getTime())) return formatDateFull(d.toISOString());
189
+ return dateRaw;
190
+ })();
191
+ const sub = renderBodyWithThreading(quotedBody); // recurse for nested threads
192
+ return `
193
+ <div class="thread-quote">
194
+ <div class="thread-quote-head">
195
+ ${avatarHtml({ name: sender }, 'avatar-sm')}
196
+ <span class="thread-quote-from">${escapeHtml(sender)}</span>
197
+ <span class="thread-quote-dot">·</span>
198
+ <span class="thread-quote-date">${escapeHtml(friendlyDate)}</span>
199
+ </div>
200
+ <div class="thread-quote-body">${sub}</div>
201
+ </div>
202
+ `;
75
203
  }
76
204
 
77
205
  async function markUnread() {
78
206
  if (!state.currentMessage || !state.selectedAgent) return;
79
207
  try {
80
- await apiPost(`/mail/messages/${state.currentMessage.uid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
208
+ await apiPost(`/mail/messages/${state.selectedUid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
81
209
  toast('Marked unread.');
82
210
  location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
83
211
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -85,3 +213,53 @@ async function markUnread() {
85
213
  toast(`Failed: ${err.message}`, true);
86
214
  }
87
215
  }
216
+
217
+ /**
218
+ * Move the open message to the Junk Mail folder (IMAP). The API
219
+ * route is POST /mail/messages/:uid/spam — it does the move +
220
+ * flags the message so future scans treat it as known spam.
221
+ */
222
+ async function markSpam() {
223
+ if (!state.currentMessage || !state.selectedAgent) return;
224
+ const ok = await confirmModal({
225
+ title: 'Report this message as spam?',
226
+ body: 'It will be moved to the Junk folder and used to train the spam filter.',
227
+ confirm: 'Report spam',
228
+ danger: true,
229
+ });
230
+ if (!ok) return;
231
+ try {
232
+ await apiPost(`/mail/messages/${state.selectedUid}/spam`, {}, { agentKey: state.selectedAgent.apiKey });
233
+ toast('Reported as spam.');
234
+ location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
235
+ await loadList(state.selectedAgent, state.selectedFolder);
236
+ } catch (err) {
237
+ toast(`Spam failed: ${err.message}`, true);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Delete the open message. DELETE /mail/messages/:uid moves it
243
+ * to the IMAP \Deleted state (Stalwart auto-expunges on server
244
+ * config, otherwise it stays in Trash). Confirm before firing
245
+ * since this is destructive.
246
+ */
247
+ async function deleteMessage() {
248
+ if (!state.currentMessage || !state.selectedAgent) return;
249
+ const subject = state.currentMessage.subject ?? '(no subject)';
250
+ const ok = await confirmModal({
251
+ title: 'Delete this message?',
252
+ body: `"${subject}" will be moved to Trash. This can't be undone from the web UI.`,
253
+ confirm: 'Delete',
254
+ danger: true,
255
+ });
256
+ if (!ok) return;
257
+ try {
258
+ await apiDelete(`/mail/messages/${state.selectedUid}`, { agentKey: state.selectedAgent.apiKey });
259
+ toast('Deleted.');
260
+ location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
261
+ await loadList(state.selectedAgent, state.selectedFolder);
262
+ } catch (err) {
263
+ toast(`Delete failed: ${err.message}`, true);
264
+ }
265
+ }
@@ -0,0 +1,107 @@
1
+ // Generic centered confirmation modal — a proper replacement for
2
+ // the browser's `window.confirm()`. We use it for every destructive
3
+ // action in the UI (delete, report spam, etc.) so the experience
4
+ // matches the rest of the app rather than dropping the user into
5
+ // the OS-styled alert UI.
6
+ //
7
+ // Usage:
8
+ // const ok = await confirmModal({
9
+ // title: 'Delete this message?',
10
+ // body: "This can't be undone.",
11
+ // confirm: 'Delete',
12
+ // danger: true,
13
+ // });
14
+ // if (!ok) return;
15
+ //
16
+ // Implementation notes:
17
+ // - Promise-based so callers can `await` the result the same
18
+ // way they would `confirm()`. Resolves true on confirm, false
19
+ // on cancel / Escape / backdrop click.
20
+ // - Built lazily on first call and reused thereafter. Closing
21
+ // the modal detaches its keydown listener so we don't pile
22
+ // up handlers across the session.
23
+ // - Focus is moved to the confirm button on open so Enter
24
+ // confirms by default. Escape always cancels.
25
+
26
+ import { escapeHtml } from './utils.js';
27
+
28
+ let modalRoot = null;
29
+ let activeResolve = null;
30
+ let activeKeydownHandler = null;
31
+
32
+ function ensureModal() {
33
+ if (modalRoot) return modalRoot;
34
+ modalRoot = document.createElement('div');
35
+ modalRoot.className = 'confirm-modal-bg';
36
+ modalRoot.style.display = 'none';
37
+ modalRoot.innerHTML = `
38
+ <div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
39
+ <h2 class="confirm-modal-title" id="confirm-modal-title"></h2>
40
+ <p class="confirm-modal-body"></p>
41
+ <div class="confirm-modal-actions">
42
+ <button class="btn-confirm-cancel" data-action="cancel">Cancel</button>
43
+ <button class="btn-confirm-ok" data-action="confirm"></button>
44
+ </div>
45
+ </div>
46
+ `;
47
+ document.body.appendChild(modalRoot);
48
+ // Backdrop click → cancel (only when the click landed on the
49
+ // backdrop itself, not the inner card).
50
+ modalRoot.addEventListener('click', (e) => {
51
+ if (e.target === modalRoot) resolveModal(false);
52
+ });
53
+ modalRoot.querySelector('[data-action=cancel]').addEventListener('click', () => resolveModal(false));
54
+ modalRoot.querySelector('[data-action=confirm]').addEventListener('click', () => resolveModal(true));
55
+ return modalRoot;
56
+ }
57
+
58
+ function resolveModal(value) {
59
+ if (!activeResolve) return;
60
+ const resolve = activeResolve;
61
+ activeResolve = null;
62
+ if (modalRoot) modalRoot.style.display = 'none';
63
+ if (activeKeydownHandler) {
64
+ document.removeEventListener('keydown', activeKeydownHandler);
65
+ activeKeydownHandler = null;
66
+ }
67
+ resolve(value);
68
+ }
69
+
70
+ export function confirmModal({
71
+ title = 'Are you sure?',
72
+ body = '',
73
+ confirm = 'OK',
74
+ cancel = 'Cancel',
75
+ danger = false,
76
+ } = {}) {
77
+ // If a previous modal is somehow still open, cancel it before
78
+ // opening the new one. Guarantees a single instance.
79
+ if (activeResolve) resolveModal(false);
80
+
81
+ const root = ensureModal();
82
+ root.querySelector('.confirm-modal-title').textContent = title;
83
+ const bodyEl = root.querySelector('.confirm-modal-body');
84
+ bodyEl.innerHTML = body ? escapeHtml(body) : '';
85
+ bodyEl.style.display = body ? 'block' : 'none';
86
+ const okBtn = root.querySelector('[data-action=confirm]');
87
+ okBtn.textContent = confirm;
88
+ okBtn.classList.toggle('btn-confirm-danger', !!danger);
89
+ root.querySelector('[data-action=cancel]').textContent = cancel;
90
+ root.style.display = 'flex';
91
+ // Focus the confirm button so Enter resolves true by default.
92
+ setTimeout(() => okBtn.focus(), 0);
93
+
94
+ activeKeydownHandler = (e) => {
95
+ if (e.key === 'Escape') { e.preventDefault(); resolveModal(false); }
96
+ else if (e.key === 'Enter' && document.activeElement?.dataset?.action === 'confirm') {
97
+ // Default Enter only fires when the OK button has focus —
98
+ // prevents a textarea-Enter elsewhere from accidentally
99
+ // confirming a hidden modal.
100
+ e.preventDefault();
101
+ resolveModal(true);
102
+ }
103
+ };
104
+ document.addEventListener('keydown', activeKeydownHandler);
105
+
106
+ return new Promise((resolve) => { activeResolve = resolve; });
107
+ }
@@ -8,12 +8,18 @@
8
8
  import { state } from './state.js';
9
9
  import { icon } from './icons.js';
10
10
 
11
+ // `All Mail` is a Gmail-only concept (a virtual folder that
12
+ // aggregates every message regardless of mailbox). Stalwart and most
13
+ // other IMAP servers don't expose anything equivalent, so we ship
14
+ // the link but hide it at render time when the discovery cache
15
+ // didn't find a real folder name — see `renderSidebar`. The
16
+ // flag below is what the renderer keys off.
11
17
  export const FOLDERS = [
12
18
  { id: 'inbox', label: 'Inbox', icon: 'inbox' },
13
19
  { id: 'starred', label: 'Starred', icon: 'starOutline' },
14
20
  { id: 'sent', label: 'Sent', icon: 'sent' },
15
21
  { id: 'drafts', label: 'Drafts', icon: 'drafts' },
16
- { id: 'all', label: 'All Mail', icon: 'allMail' },
22
+ { id: 'all', label: 'All Mail', icon: 'allMail', requiresDiscovery: true },
17
23
  { id: 'spam', label: 'Spam', icon: 'spam' },
18
24
  { id: 'trash', label: 'Trash', icon: 'trash' },
19
25
  ];
@@ -23,7 +29,13 @@ export function renderSidebar(onSelect) {
23
29
  if (!root) return;
24
30
  const active = state.selectedFolder ?? 'inbox';
25
31
  const unread = state.unread?.[state.selectedAgent?.id] ?? 0;
26
- root.innerHTML = FOLDERS.map(f => {
32
+ // Hide folders that need discovery but didn't get a real IMAP
33
+ // name from the per-agent folder cache. Saves the user from
34
+ // clicking "All Mail" and getting an empty-state error on
35
+ // servers that don't have an equivalent (Stalwart, most non-
36
+ // Gmail providers).
37
+ const visible = FOLDERS.filter(f => !f.requiresDiscovery || state.folderNames?.[f.id]);
38
+ root.innerHTML = visible.map(f => {
27
39
  const isActive = f.id === active;
28
40
  const showCount = f.id === 'inbox' && unread > 0;
29
41
  return `
@@ -579,6 +579,42 @@ mark.search-hl {
579
579
  margin: .8em 0; padding: .2em 0 .2em 12px;
580
580
  color: var(--muted); white-space: pre-wrap;
581
581
  }
582
+
583
+ /* ─── In-body thread quote (replies inline in the body) ───────── */
584
+ /* When a message body contains an "On <date>, <addr> wrote:" line
585
+ followed by `>`-quoted text, we render that section as a
586
+ styled card with proper avatar + name + friendly date instead
587
+ of leaving the raw ISO timestamp visible. Nested replies recurse
588
+ so a 3-deep thread gets 3 nested cards. */
589
+ .thread-quote {
590
+ margin: 16px 0;
591
+ border-left: 3px solid var(--pink-rule);
592
+ padding: 0 0 0 16px;
593
+ }
594
+ .thread-quote .thread-quote {
595
+ border-left-color: #c084fc;
596
+ margin: 12px 0;
597
+ }
598
+ .thread-quote .thread-quote .thread-quote { border-left-color: #f59e0b; }
599
+ .thread-quote-head {
600
+ display: flex; align-items: center; gap: 8px;
601
+ margin: 0 0 8px;
602
+ font-size: 13px;
603
+ color: var(--muted);
604
+ }
605
+ .thread-quote-head .avatar-sm {
606
+ width: 22px; height: 22px; font-size: 10px;
607
+ }
608
+ .thread-quote-from { font-weight: 500; color: var(--ink-soft); }
609
+ .thread-quote-dot { opacity: .5; }
610
+ .thread-quote-date { color: var(--muted); }
611
+ .thread-quote-body {
612
+ color: var(--ink-soft);
613
+ }
614
+ .thread-quote-body p,
615
+ .thread-quote-body div { color: var(--ink-soft); }
616
+ .thread-quote-body code { background: var(--code-bg); color: var(--code-fg); }
617
+
582
618
  .message-body blockquote blockquote { border-left-color: #c084fc; }
583
619
  .message-body blockquote blockquote blockquote { border-left-color: #f59e0b; }
584
620
  .message-body table { border-collapse: collapse; margin: .5em 0; }
@@ -597,11 +633,62 @@ mark.search-hl {
597
633
  width: 100%;
598
634
  }
599
635
  .message-attachment {
636
+ /* Clickable chip — fetches the file via the authed download
637
+ helper and triggers a browser download. */
600
638
  display: inline-flex; align-items: center; gap: 8px;
601
639
  padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px;
602
- font-size: 13px; color: var(--ink-soft);
640
+ background: var(--bg-soft); color: var(--ink-soft);
641
+ font-size: 13px; cursor: pointer;
642
+ font: inherit;
643
+ transition: background .12s, border-color .12s, transform .06s;
644
+ }
645
+ .message-attachment:hover {
646
+ background: var(--bg-hover);
647
+ border-color: var(--accent-strong);
648
+ color: var(--ink);
649
+ }
650
+ .message-attachment:active { transform: translateY(1px); }
651
+ .message-attachment.downloading { opacity: .6; cursor: progress; }
652
+ .message-attachment .att-icon { display: inline-flex; }
653
+ .message-attachment .att-size {
654
+ color: var(--muted); font-size: 12px;
655
+ padding-left: 4px;
656
+ border-left: 1px solid var(--line);
657
+ }
658
+
659
+ /* Compose attachment chips (in the open compose modal) */
660
+ .compose-attachments {
661
+ display: flex; flex-wrap: wrap; gap: 6px;
662
+ padding: 8px 0 0;
663
+ }
664
+ .compose-attachments:empty { display: none; }
665
+ .attachment-chip {
666
+ display: inline-flex; align-items: center; gap: 8px;
667
+ padding: 4px 4px 4px 10px;
668
+ background: var(--bg-soft); border: 1px solid var(--line);
669
+ border-radius: 16px;
670
+ font-size: 12px; color: var(--ink-soft);
671
+ max-width: 240px;
672
+ }
673
+ .attachment-chip .chip-name {
674
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
675
+ max-width: 160px;
603
676
  }
604
- .message-attachment .att-icon { font-size: 18px; }
677
+ .attachment-chip .chip-size { color: var(--muted); }
678
+ .attachment-chip .chip-remove {
679
+ width: 20px; height: 20px; border-radius: 50%;
680
+ display: inline-flex; align-items: center; justify-content: center;
681
+ font-size: 14px; line-height: 1; color: var(--muted);
682
+ background: transparent; cursor: pointer;
683
+ }
684
+ .attachment-chip .chip-remove:hover { background: var(--bg-hover); color: var(--ink); }
685
+
686
+ /* Paperclip attach button in compose footer. */
687
+ .btn-attach {
688
+ width: 36px; height: 36px;
689
+ color: var(--muted);
690
+ }
691
+ .btn-attach:hover { background: var(--bg-hover); color: var(--ink); }
605
692
 
606
693
  /* ─── Auth gate ─────────────────────────────────────────────────────── */
607
694
  .auth-gate {
@@ -691,8 +778,13 @@ mark.search-hl {
691
778
  font-weight: 500; font-size: 14px;
692
779
  }
693
780
  .btn-send:hover { background: var(--accent-strong); }
781
+ .compose-status {
782
+ font-size: 12px; color: var(--muted);
783
+ margin-left: 12px;
784
+ flex: 1; min-width: 0;
785
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
786
+ }
694
787
  .btn-discard {
695
- margin-left: auto;
696
788
  color: var(--muted);
697
789
  padding: 8px 12px;
698
790
  }
@@ -701,6 +793,60 @@ mark.search-hl {
701
793
  font-size: 11px; color: var(--muted); padding: 0 8px;
702
794
  }
703
795
 
796
+ /* ─── Confirm modal (used by destructive actions: delete, spam) ──── */
797
+ /* Centered dialog backed by a dimmed full-screen backdrop. Replaces
798
+ browser-native window.confirm() so destructive actions match the
799
+ rest of the app's chrome rather than dropping into OS-styled
800
+ alerts. Focus lands on the confirm button on open; Esc cancels;
801
+ Enter confirms only when the button has focus. */
802
+ .confirm-modal-bg {
803
+ position: fixed; inset: 0; z-index: 80;
804
+ background: rgba(0,0,0,0.45);
805
+ display: flex; align-items: center; justify-content: center;
806
+ padding: 24px;
807
+ animation: confirm-fade-in .12s ease-out;
808
+ }
809
+ @keyframes confirm-fade-in { from { opacity: 0; } to { opacity: 1; } }
810
+ .confirm-modal {
811
+ width: 100%; max-width: 420px;
812
+ background: var(--bg); color: var(--ink);
813
+ border-radius: 14px;
814
+ padding: 24px 24px 18px;
815
+ box-shadow: 0 12px 40px rgba(0,0,0,0.25), 0 2px 8px rgba(0,0,0,0.1);
816
+ }
817
+ .confirm-modal-title {
818
+ margin: 0 0 8px;
819
+ font: 500 18px/1.35 'Google Sans', sans-serif;
820
+ color: var(--ink);
821
+ }
822
+ .confirm-modal-body {
823
+ margin: 0 0 20px;
824
+ font-size: 14px; line-height: 1.5;
825
+ color: var(--ink-soft);
826
+ }
827
+ .confirm-modal-actions {
828
+ display: flex; gap: 8px; justify-content: flex-end;
829
+ }
830
+ .btn-confirm-cancel,
831
+ .btn-confirm-ok {
832
+ font: 500 14px/1 'Google Sans', sans-serif;
833
+ padding: 10px 18px; border-radius: 8px;
834
+ cursor: pointer; transition: background .12s;
835
+ }
836
+ .btn-confirm-cancel {
837
+ background: transparent; color: var(--accent-strong);
838
+ }
839
+ .btn-confirm-cancel:hover { background: var(--bg-hover); }
840
+ .btn-confirm-ok {
841
+ background: var(--pink); color: white;
842
+ }
843
+ .btn-confirm-ok:hover { background: var(--accent-strong); }
844
+ .btn-confirm-ok.btn-confirm-danger {
845
+ /* Destructive variant — red instead of brand pink. */
846
+ background: #d93025;
847
+ }
848
+ .btn-confirm-ok.btn-confirm-danger:hover { background: #b3261e; }
849
+
704
850
  /* ─── Toast ────────────────────────────────────────────────────────── */
705
851
  .toast {
706
852
  position: fixed; bottom: 24px; left: 24px; z-index: 60;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/cli",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
4
4
  "description": "Email and SMS infrastructure for AI agents — the first platform to give agents real email addresses and phone numbers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@agenticmail/api": "^0.7.15",
32
+ "@agenticmail/api": "^0.7.17",
33
33
  "@agenticmail/core": "^0.7.0",
34
34
  "json5": "^2.2.3"
35
35
  },