@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 +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 +4 -0
- package/public/js/api.js +50 -0
- package/public/js/app.js +10 -1
- package/public/js/compose.js +216 -1
- package/public/js/list-view.js +9 -0
- package/public/js/message-view.js +183 -5
- package/public/js/modal.js +107 -0
- package/public/js/sidebar.js +14 -2
- package/public/styles.css +149 -3
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
|
@@ -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);
|
package/public/js/compose.js
CHANGED
|
@@ -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);
|
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,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
|
-
`<
|
|
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">${
|
|
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.
|
|
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
|
+
}
|
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
|
@@ -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
|
-
|
|
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
|
-
.
|
|
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;
|