@agenticmail/api 0.7.15 → 0.7.16

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.16",
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
@@ -90,6 +90,7 @@
90
90
  </div>
91
91
  <div class="compose-foot">
92
92
  <button class="btn-send" id="compose-send">Send</button>
93
+ <span class="compose-status" id="compose-status"></span>
93
94
  <button class="btn-discard" id="compose-cancel">Discard</button>
94
95
  </div>
95
96
  </div>
package/public/js/api.js CHANGED
@@ -23,3 +23,27 @@ 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
+ export async function apiDelete(path, opts = {}) {
41
+ const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
42
+ method: 'DELETE',
43
+ headers: { Authorization: `Bearer ${opts.agentKey ?? state.masterKey}` },
44
+ });
45
+ if (!r.ok) throw new Error(`${r.status} ${path}`);
46
+ // DELETE may return 204 No Content — guard against empty body.
47
+ const text = await r.text();
48
+ return text ? JSON.parse(text) : { ok: true };
49
+ }
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,21 @@
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
+
8
19
  export function populateComposeFrom() {
9
20
  const sel = document.getElementById('compose-from');
10
21
  sel.innerHTML = state.agents
@@ -14,11 +25,14 @@ export function populateComposeFrom() {
14
25
 
15
26
  export function openCompose() {
16
27
  state.composeReplyContext = null;
28
+ state.composeDraftId = null;
17
29
  document.getElementById('compose-title').textContent = 'New message';
18
30
  if (state.selectedAgent) document.getElementById('compose-from').value = state.selectedAgent.id;
19
31
  ['compose-to', 'compose-cc', 'compose-wake', 'compose-subject', 'compose-body']
20
32
  .forEach(id => { document.getElementById(id).value = ''; });
33
+ setComposeStatus('');
21
34
  showModal();
35
+ wireAutosave();
22
36
  setTimeout(() => document.getElementById('compose-to').focus(), 50);
23
37
  }
24
38
 
@@ -26,6 +40,7 @@ export function openReply(replyAll) {
26
40
  if (!state.currentMessage) return;
27
41
  const msg = state.currentMessage;
28
42
  state.composeReplyContext = { uid: msg.uid, agent: state.selectedAgent, replyAll };
43
+ state.composeDraftId = null;
29
44
  document.getElementById('compose-title').textContent =
30
45
  `Reply${replyAll ? ' all' : ''}: ${msg.subject ?? '(no subject)'}`;
31
46
  document.getElementById('compose-from').value = state.selectedAgent.id;
@@ -45,18 +60,104 @@ export function openReply(replyAll) {
45
60
  const quoted = (msg.text ?? '').split('\n').map(l => `> ${l}`).join('\n');
46
61
  const stub = `\n\nOn ${msg.date}, ${fromAddr} wrote:\n${quoted}`;
47
62
  document.getElementById('compose-body').value = stub;
63
+ setComposeStatus('');
48
64
  showModal();
65
+ wireAutosave();
49
66
  setTimeout(() => document.getElementById('compose-body').focus(), 50);
50
67
  }
51
68
 
52
69
  export function closeCompose() {
53
70
  document.getElementById('compose-bg').style.display = 'none';
71
+ // Flush a final save synchronously-ish on close so a quick
72
+ // "type → close" doesn't lose work. We only fire if there's a
73
+ // pending debounce — if the user already saved or never typed,
74
+ // skip the network call.
75
+ if (autosaveTimer) {
76
+ clearTimeout(autosaveTimer);
77
+ autosaveTimer = null;
78
+ void runAutosave();
79
+ }
54
80
  }
55
81
 
56
82
  function showModal() {
57
83
  document.getElementById('compose-bg').style.display = 'flex';
58
84
  }
59
85
 
86
+ /**
87
+ * Build the field set the drafts API expects from current modal
88
+ * state. Returns null when the draft is empty (no point persisting
89
+ * a blank shell).
90
+ */
91
+ function readComposeFields() {
92
+ const to = document.getElementById('compose-to').value.trim();
93
+ const subject = document.getElementById('compose-subject').value.trim();
94
+ const text = document.getElementById('compose-body').value;
95
+ const cc = document.getElementById('compose-cc').value.trim();
96
+ if (!to && !subject && !text.trim() && !cc) return null;
97
+ return {
98
+ to: to || null,
99
+ subject: subject || null,
100
+ text: text || null,
101
+ cc: cc || null,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Wire the autosave debounce to every input/textarea in the modal.
107
+ * Re-wires on every open() so removed/replaced DOM nodes don't
108
+ * accumulate listeners.
109
+ */
110
+ function wireAutosave() {
111
+ ['compose-to', 'compose-cc', 'compose-subject', 'compose-body'].forEach(id => {
112
+ const el = document.getElementById(id);
113
+ if (!el) return;
114
+ // Marker prevents double-binding.
115
+ if (el._autosaveBound) return;
116
+ el._autosaveBound = true;
117
+ el.addEventListener('input', scheduleAutosave);
118
+ });
119
+ }
120
+
121
+ function scheduleAutosave() {
122
+ setComposeStatus('Saving…');
123
+ if (autosaveTimer) clearTimeout(autosaveTimer);
124
+ autosaveTimer = setTimeout(runAutosave, AUTOSAVE_DEBOUNCE_MS);
125
+ }
126
+
127
+ async function runAutosave() {
128
+ autosaveTimer = null;
129
+ if (autosaveInFlight) {
130
+ // Coalesce: re-schedule one more pass after the current
131
+ // request lands so we don't lose the latest keystroke.
132
+ autosaveTimer = setTimeout(runAutosave, AUTOSAVE_DEBOUNCE_MS);
133
+ return;
134
+ }
135
+ const fields = readComposeFields();
136
+ if (!fields) { setComposeStatus(''); return; }
137
+ const agentId = document.getElementById('compose-from').value;
138
+ const agent = state.agents.find(a => a.id === agentId) ?? state.selectedAgent;
139
+ if (!agent) return;
140
+ autosaveInFlight = true;
141
+ try {
142
+ if (state.composeDraftId) {
143
+ await apiPut(`/drafts/${state.composeDraftId}`, fields, { agentKey: agent.apiKey });
144
+ } else {
145
+ const r = await apiPost('/drafts', fields, { agentKey: agent.apiKey });
146
+ state.composeDraftId = r?.id ?? null;
147
+ }
148
+ setComposeStatus('Saved to Drafts');
149
+ } catch (err) {
150
+ setComposeStatus(`Save failed: ${err.message}`);
151
+ } finally {
152
+ autosaveInFlight = false;
153
+ }
154
+ }
155
+
156
+ function setComposeStatus(text) {
157
+ const el = document.getElementById('compose-status');
158
+ if (el) el.textContent = text;
159
+ }
160
+
60
161
  export async function sendCompose() {
61
162
  const agentId = document.getElementById('compose-from').value;
62
163
  const agent = state.agents.find(a => a.id === agentId);
@@ -72,6 +173,12 @@ export async function sendCompose() {
72
173
  if (wakeRaw) body.wake = wakeRaw.split(',').map(s => s.trim()).filter(Boolean);
73
174
  try {
74
175
  await apiPost('/mail/send', body, { agentKey: agent.apiKey });
176
+ // Clean up the autosaved draft (if any) — the message is in
177
+ // the real Sent folder now, no need to keep a Drafts entry.
178
+ if (state.composeDraftId) {
179
+ try { await apiDelete(`/drafts/${state.composeDraftId}`, { agentKey: agent.apiKey }); } catch { /* ignore */ }
180
+ state.composeDraftId = null;
181
+ }
75
182
  closeCompose();
76
183
  toast('Sent.');
77
184
  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,7 +4,7 @@ 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 } 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';
@@ -19,6 +19,8 @@ export async function openMessage(uid) {
19
19
  <button class="icon-btn" id="msg-reply" title="Reply">${icon('reply')}</button>
20
20
  <button class="icon-btn" id="msg-reply-all" title="Reply all">${icon('replyAll')}</button>
21
21
  <button class="icon-btn" id="msg-unread" title="Mark unread">${icon('mailUnread')}</button>
22
+ <button class="icon-btn" id="msg-spam" title="Report spam">${icon('spam')}</button>
23
+ <button class="icon-btn" id="msg-delete" title="Delete">${icon('trash')}</button>
22
24
  <div class="toolbar-spacer"></div>
23
25
  </div>
24
26
  <div class="message-view"><div class="empty">Loading…</div></div>
@@ -27,6 +29,8 @@ export async function openMessage(uid) {
27
29
  document.getElementById('msg-reply').addEventListener('click', () => openReply(false));
28
30
  document.getElementById('msg-reply-all').addEventListener('click', () => openReply(true));
29
31
  document.getElementById('msg-unread').addEventListener('click', () => markUnread());
32
+ document.getElementById('msg-spam').addEventListener('click', () => markSpam());
33
+ document.getElementById('msg-delete').addEventListener('click', () => deleteMessage());
30
34
 
31
35
  try {
32
36
  const msg = await apiGet(`/mail/messages/${uid}`, { agentKey: state.selectedAgent.apiKey });
@@ -85,3 +89,40 @@ async function markUnread() {
85
89
  toast(`Failed: ${err.message}`, true);
86
90
  }
87
91
  }
92
+
93
+ /**
94
+ * Move the open message to the Junk Mail folder (IMAP). The API
95
+ * route is POST /mail/messages/:uid/spam — it does the move +
96
+ * flags the message so future scans treat it as known spam.
97
+ */
98
+ async function markSpam() {
99
+ if (!state.currentMessage || !state.selectedAgent) return;
100
+ if (!confirm('Report this message as spam? It will be moved to the Junk folder.')) return;
101
+ try {
102
+ await apiPost(`/mail/messages/${state.currentMessage.uid}/spam`, {}, { agentKey: state.selectedAgent.apiKey });
103
+ toast('Reported as spam.');
104
+ location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
105
+ await loadList(state.selectedAgent, state.selectedFolder);
106
+ } catch (err) {
107
+ toast(`Spam failed: ${err.message}`, true);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Delete the open message. DELETE /mail/messages/:uid moves it
113
+ * to the IMAP \Deleted state (Stalwart auto-expunges on server
114
+ * config, otherwise it stays in Trash). Confirm before firing
115
+ * since this is destructive.
116
+ */
117
+ async function deleteMessage() {
118
+ if (!state.currentMessage || !state.selectedAgent) return;
119
+ if (!confirm('Delete this message?')) return;
120
+ try {
121
+ await apiDelete(`/mail/messages/${state.currentMessage.uid}`, { agentKey: state.selectedAgent.apiKey });
122
+ toast('Deleted.');
123
+ location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
124
+ await loadList(state.selectedAgent, state.selectedFolder);
125
+ } catch (err) {
126
+ toast(`Delete failed: ${err.message}`, true);
127
+ }
128
+ }
@@ -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
@@ -691,8 +691,13 @@ mark.search-hl {
691
691
  font-weight: 500; font-size: 14px;
692
692
  }
693
693
  .btn-send:hover { background: var(--accent-strong); }
694
+ .compose-status {
695
+ font-size: 12px; color: var(--muted);
696
+ margin-left: 12px;
697
+ flex: 1; min-width: 0;
698
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
699
+ }
694
700
  .btn-discard {
695
- margin-left: auto;
696
701
  color: var(--muted);
697
702
  padding: 8px 12px;
698
703
  }