@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 +8 -1
- package/dist/index.js +158 -5
- package/package.json +1 -1
- package/public/branding/agenticmail-logo.png +0 -0
- package/public/index.html +1 -0
- package/public/js/api.js +24 -0
- package/public/js/app.js +10 -1
- package/public/js/compose.js +108 -1
- package/public/js/list-view.js +9 -0
- package/public/js/message-view.js +42 -1
- package/public/js/sidebar.js +14 -2
- package/public/styles.css +6 -1
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.
|
|
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(
|
|
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
|
Binary file
|
package/public/index.html
CHANGED
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);
|
package/public/js/compose.js
CHANGED
|
@@ -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);
|
package/public/js/list-view.js
CHANGED
|
@@ -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
|
+
}
|
package/public/js/sidebar.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|