@agenticmail/api 0.7.15 → 0.7.17

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.9
11
+ ## ✨ What's new in 0.7.16
12
+
13
+ - **📐 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
+ - **⭐ 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.
15
+ - **📥 Long-running worker visibility** — `POST /dispatcher/worker-heartbeat`, `GET /dispatcher/worker-log/:id`. Combined with the dropped 30-min `active` TTL, workers can run for hours; staleness is a flag, not an eviction trigger.
16
+ - **🌐 Web UI** under `packages/api/public/` — Gmail two-column layout, official Claude + AgenticMail logos, hash router (`#/folder/<id>`, `#/m/<uid>`), folder auto-discovery (works on Stalwart / Gmail / Outlook / macOS Mail conventions), 2-line preview rows, mobile-responsive sidebar, draft autosave, vector icon library.
17
+
18
+ ## ✨ Earlier — 0.7.9
12
19
 
13
20
  - **🌐 Gmail-style web UI, fully redesigned** — `packages/api/public/` ships a proper two-column Gmail layout (sidebar with Compose + folders / content pane) served by `express.static` at the API root. Every emoji replaced with an inline 24×24 vector icon library (`public/js/icons.js`). HTML shell + dedicated `styles.css` + 14 modular ES module JS files under `public/js/`. Hash router (`#/inbox`, `#/m/<uid>`), search with `from:` / `subject:` operators, real-time SSE updates, browser notifications. Run via `agenticmail web` from the CLI.
14
21
  - **Wake allowlist on `POST /mail/send`** — accept a `wake` parameter (array of agent names or comma-separated string). The API normalises it, sets an `X-AgenticMail-Wake` header on the outgoing SMTP envelope, AND surfaces it as `wakeAllowlist` on the SSE event so the dispatcher can decide which CC'd recipients to actually give a Claude turn.
package/dist/index.js CHANGED
@@ -3147,12 +3147,120 @@ function createGatewayRoutes(gatewayManager) {
3147
3147
  import { Router as Router10 } from "express";
3148
3148
  import { v4 as uuidv43 } from "uuid";
3149
3149
  import { MailSender as MailSender4 } from "@agenticmail/core";
3150
+
3151
+ // src/lib/schema-validator.ts
3152
+ function validate(value, schema, path = "") {
3153
+ const errors = [];
3154
+ if (!schema || typeof schema !== "object") return errors;
3155
+ if (schema.type !== void 0) {
3156
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
3157
+ if (!types.some((t) => matchesType(value, t))) {
3158
+ errors.push({ path: path || "(root)", message: `expected type ${types.join("|")}, got ${jsType(value)}` });
3159
+ return errors;
3160
+ }
3161
+ }
3162
+ if (schema.enum && Array.isArray(schema.enum)) {
3163
+ if (!schema.enum.some((e) => deepEqual(e, value))) {
3164
+ errors.push({ path: path || "(root)", message: `value not in enum [${schema.enum.map((e) => JSON.stringify(e)).join(", ")}]` });
3165
+ }
3166
+ }
3167
+ if (typeof value === "string") {
3168
+ if (schema.minLength !== void 0 && value.length < schema.minLength) {
3169
+ errors.push({ path: path || "(root)", message: `string shorter than minLength ${schema.minLength}` });
3170
+ }
3171
+ if (schema.maxLength !== void 0 && value.length > schema.maxLength) {
3172
+ errors.push({ path: path || "(root)", message: `string longer than maxLength ${schema.maxLength}` });
3173
+ }
3174
+ }
3175
+ if (typeof value === "number") {
3176
+ if (schema.minimum !== void 0 && value < schema.minimum) {
3177
+ errors.push({ path: path || "(root)", message: `value below minimum ${schema.minimum}` });
3178
+ }
3179
+ if (schema.maximum !== void 0 && value > schema.maximum) {
3180
+ errors.push({ path: path || "(root)", message: `value above maximum ${schema.maximum}` });
3181
+ }
3182
+ }
3183
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
3184
+ const obj = value;
3185
+ for (const key of schema.required ?? []) {
3186
+ if (!(key in obj)) {
3187
+ errors.push({ path: childPath(path, key), message: "required property is missing" });
3188
+ }
3189
+ }
3190
+ if (schema.properties) {
3191
+ for (const [key, subSchema] of Object.entries(schema.properties)) {
3192
+ if (key in obj) {
3193
+ errors.push(...validate(obj[key], subSchema, childPath(path, key)));
3194
+ }
3195
+ }
3196
+ if (schema.additionalProperties === false) {
3197
+ for (const key of Object.keys(obj)) {
3198
+ if (!(key in schema.properties)) {
3199
+ errors.push({ path: childPath(path, key), message: "unknown property (additionalProperties: false)" });
3200
+ }
3201
+ }
3202
+ }
3203
+ }
3204
+ }
3205
+ if (Array.isArray(value) && schema.items) {
3206
+ value.forEach((item, i) => {
3207
+ errors.push(...validate(item, schema.items, `${path}[${i}]`));
3208
+ });
3209
+ }
3210
+ return errors;
3211
+ }
3212
+ function matchesType(value, t) {
3213
+ switch (t) {
3214
+ case "string":
3215
+ return typeof value === "string";
3216
+ case "number":
3217
+ return typeof value === "number" && !Number.isNaN(value);
3218
+ case "integer":
3219
+ return typeof value === "number" && Number.isInteger(value);
3220
+ case "boolean":
3221
+ return typeof value === "boolean";
3222
+ case "null":
3223
+ return value === null;
3224
+ case "object":
3225
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3226
+ case "array":
3227
+ return Array.isArray(value);
3228
+ default:
3229
+ return true;
3230
+ }
3231
+ }
3232
+ function jsType(value) {
3233
+ if (value === null) return "null";
3234
+ if (Array.isArray(value)) return "array";
3235
+ return typeof value;
3236
+ }
3237
+ function childPath(parent, key) {
3238
+ return parent ? `${parent}.${key}` : key;
3239
+ }
3240
+ function deepEqual(a, b) {
3241
+ if (a === b) return true;
3242
+ if (typeof a !== typeof b) return false;
3243
+ if (a && b && typeof a === "object") {
3244
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
3245
+ if (Array.isArray(a)) {
3246
+ if (a.length !== b.length) return false;
3247
+ return a.every((v, i) => deepEqual(v, b[i]));
3248
+ }
3249
+ const aKeys = Object.keys(a);
3250
+ const bKeys = Object.keys(b);
3251
+ if (aKeys.length !== bKeys.length) return false;
3252
+ return aKeys.every((k) => deepEqual(a[k], b[k]));
3253
+ }
3254
+ return false;
3255
+ }
3256
+
3257
+ // src/routes/tasks.ts
3150
3258
  var rpcResolvers = /* @__PURE__ */ new Map();
3151
3259
  function createTaskRoutes(db, accountManager2, config) {
3152
3260
  const router = Router10();
3153
3261
  router.post("/tasks/assign", requireAuth, async (req, res, next) => {
3154
3262
  try {
3155
- const { assignee, taskType, payload, expiresInSeconds } = req.body || {};
3263
+ const { assignee, taskType, payload, expiresInSeconds, outputSchema } = req.body || {};
3156
3264
  if (!assignee) {
3157
3265
  res.status(400).json({ error: "assignee (agent name) is required" });
3158
3266
  return;
@@ -3162,13 +3270,32 @@ function createTaskRoutes(db, accountManager2, config) {
3162
3270
  res.status(404).json({ error: `Agent "${assignee}" not found` });
3163
3271
  return;
3164
3272
  }
3273
+ if (outputSchema !== void 0 && (typeof outputSchema !== "object" || outputSchema === null)) {
3274
+ res.status(400).json({ error: "outputSchema must be a JSON Schema object" });
3275
+ return;
3276
+ }
3165
3277
  const assignerId = req.agent?.id ?? "master";
3166
3278
  const id = uuidv43();
3167
3279
  const expiresAt = expiresInSeconds ? new Date(Date.now() + expiresInSeconds * 1e3).toISOString() : null;
3168
3280
  db.prepare(
3169
- "INSERT INTO agent_tasks (id, assigner_id, assignee_id, task_type, payload, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
3170
- ).run(id, assignerId, target.id, taskType || "generic", JSON.stringify(payload || {}), expiresAt);
3281
+ "INSERT INTO agent_tasks (id, assigner_id, assignee_id, task_type, payload, expires_at, output_schema) VALUES (?, ?, ?, ?, ?, ?, ?)"
3282
+ ).run(
3283
+ id,
3284
+ assignerId,
3285
+ target.id,
3286
+ taskType || "generic",
3287
+ JSON.stringify(payload || {}),
3288
+ expiresAt,
3289
+ outputSchema ? JSON.stringify(outputSchema) : null
3290
+ );
3171
3291
  const taskDescription = payload?.task || payload?.description || JSON.stringify(payload || {});
3292
+ const schemaSection = outputSchema ? `
3293
+
3294
+ Your submit_result MUST conform to this JSON Schema:
3295
+ \`\`\`json
3296
+ ${JSON.stringify(outputSchema, null, 2)}
3297
+ \`\`\`
3298
+ The API will reject a non-conformant result with the validator errors so you can retry.` : "";
3172
3299
  const spawnEvent = {
3173
3300
  type: "task",
3174
3301
  taskId: id,
@@ -3176,7 +3303,7 @@ function createTaskRoutes(db, accountManager2, config) {
3176
3303
  task: `You have a pending task (ID: ${id}). Check your pending tasks, claim it, process it, and submit the result.
3177
3304
 
3178
3305
  Type: ${taskType || "generic"}
3179
- Task: ${taskDescription}`,
3306
+ Task: ${taskDescription}${schemaSection}`,
3180
3307
  assignee: target.name,
3181
3308
  from: req.agent?.name ?? "system"
3182
3309
  };
@@ -3257,6 +3384,25 @@ Please check your pending tasks.`
3257
3384
  router.post("/tasks/:id/result", requireAgent, async (req, res, next) => {
3258
3385
  try {
3259
3386
  const { result } = req.body || {};
3387
+ const taskRow = db.prepare("SELECT output_schema FROM agent_tasks WHERE id = ?").get(req.params.id);
3388
+ if (taskRow?.output_schema) {
3389
+ let schema;
3390
+ try {
3391
+ schema = JSON.parse(taskRow.output_schema);
3392
+ } catch {
3393
+ }
3394
+ if (schema) {
3395
+ const errors = validate(result, schema);
3396
+ if (errors.length > 0) {
3397
+ res.status(400).json({
3398
+ error: "Result does not match the task outputSchema",
3399
+ schemaErrors: errors,
3400
+ hint: "Retry submit_result with a value matching the schema. Use GET /tasks/:id to re-read the schema."
3401
+ });
3402
+ return;
3403
+ }
3404
+ }
3405
+ }
3260
3406
  const resultJson = JSON.stringify(result ?? null);
3261
3407
  const dbResult = db.prepare(
3262
3408
  "UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = datetime('now') WHERE id = ? AND status = 'claimed'"
@@ -3443,6 +3589,7 @@ function parseTask(row) {
3443
3589
  if (!row) return null;
3444
3590
  let payload = {};
3445
3591
  let result = null;
3592
+ let outputSchema = null;
3446
3593
  try {
3447
3594
  payload = JSON.parse(row.payload);
3448
3595
  } catch {
@@ -3453,6 +3600,11 @@ function parseTask(row) {
3453
3600
  } catch {
3454
3601
  result = row.result;
3455
3602
  }
3603
+ try {
3604
+ outputSchema = row.output_schema ? JSON.parse(row.output_schema) : null;
3605
+ } catch {
3606
+ outputSchema = null;
3607
+ }
3456
3608
  return {
3457
3609
  id: row.id,
3458
3610
  assignerId: row.assigner_id,
@@ -3465,7 +3617,8 @@ function parseTask(row) {
3465
3617
  createdAt: row.created_at,
3466
3618
  claimedAt: row.claimed_at,
3467
3619
  completedAt: row.completed_at,
3468
- expiresAt: row.expires_at
3620
+ expiresAt: row.expires_at,
3621
+ outputSchema
3469
3622
  };
3470
3623
  }
3471
3624
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
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/index.html CHANGED
@@ -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>
package/public/js/api.js CHANGED
@@ -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
+ }
package/public/js/app.js CHANGED
@@ -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 `
package/public/styles.css CHANGED
@@ -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;