@agenticmail/api 0.7.16 → 0.7.18

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/dist/index.js CHANGED
@@ -2167,9 +2167,23 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
2167
2167
  res.status(400).json({ error: "Invalid UID" });
2168
2168
  return;
2169
2169
  }
2170
+ const permanent = req.query.permanent === "true" || req.query.permanent === "1";
2171
+ const sourceFolder = req.query.folder || "INBOX";
2170
2172
  const password = getAgentPassword(agent);
2171
2173
  const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2172
- await receiver.deleteMessage(uid);
2174
+ if (permanent) {
2175
+ await receiver.expungeMessage(uid, sourceFolder);
2176
+ res.status(204).send();
2177
+ return;
2178
+ }
2179
+ const folders = await receiver.listFolders();
2180
+ const trashRe = /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i;
2181
+ const trashFolder = folders.find((f) => trashRe.test(f.name) || trashRe.test(f.path))?.path ?? folders.find((f) => f.specialUse === "\\Trash")?.path;
2182
+ if (!trashFolder || trashFolder === sourceFolder) {
2183
+ await receiver.expungeMessage(uid, sourceFolder);
2184
+ } else {
2185
+ await receiver.moveToTrash(uid, sourceFolder, trashFolder);
2186
+ }
2173
2187
  res.status(204).send();
2174
2188
  } catch (err) {
2175
2189
  next(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.16",
3
+ "version": "0.7.18",
4
4
  "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/public/index.html CHANGED
@@ -86,10 +86,13 @@
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>
93
96
  <span class="compose-status" id="compose-status"></span>
94
97
  <button class="btn-discard" id="compose-cancel">Discard</button>
95
98
  </div>
package/public/js/api.js CHANGED
@@ -37,6 +37,32 @@ export async function apiPut(path, body, opts = {}) {
37
37
  return await r.json();
38
38
  }
39
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
+
40
66
  export async function apiDelete(path, opts = {}) {
41
67
  const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
42
68
  method: 'DELETE',
@@ -16,6 +16,15 @@ const AUTOSAVE_DEBOUNCE_MS = 2000;
16
16
  let autosaveTimer = null;
17
17
  let autosaveInFlight = false;
18
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
+
19
28
  export function populateComposeFrom() {
20
29
  const sel = document.getElementById('compose-from');
21
30
  sel.innerHTML = state.agents
@@ -26,13 +35,16 @@ export function populateComposeFrom() {
26
35
  export function openCompose() {
27
36
  state.composeReplyContext = null;
28
37
  state.composeDraftId = null;
38
+ pendingAttachments = [];
29
39
  document.getElementById('compose-title').textContent = 'New message';
30
40
  if (state.selectedAgent) document.getElementById('compose-from').value = state.selectedAgent.id;
31
41
  ['compose-to', 'compose-cc', 'compose-wake', 'compose-subject', 'compose-body']
32
42
  .forEach(id => { document.getElementById(id).value = ''; });
43
+ renderAttachmentChips();
33
44
  setComposeStatus('');
34
45
  showModal();
35
46
  wireAutosave();
47
+ wireAttachmentPicker();
36
48
  setTimeout(() => document.getElementById('compose-to').focus(), 50);
37
49
  }
38
50
 
@@ -60,9 +72,12 @@ export function openReply(replyAll) {
60
72
  const quoted = (msg.text ?? '').split('\n').map(l => `> ${l}`).join('\n');
61
73
  const stub = `\n\nOn ${msg.date}, ${fromAddr} wrote:\n${quoted}`;
62
74
  document.getElementById('compose-body').value = stub;
75
+ pendingAttachments = [];
76
+ renderAttachmentChips();
63
77
  setComposeStatus('');
64
78
  showModal();
65
79
  wireAutosave();
80
+ wireAttachmentPicker();
66
81
  setTimeout(() => document.getElementById('compose-body').focus(), 50);
67
82
  }
68
83
 
@@ -158,6 +173,87 @@ function setComposeStatus(text) {
158
173
  if (el) el.textContent = text;
159
174
  }
160
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
+
161
257
  export async function sendCompose() {
162
258
  const agentId = document.getElementById('compose-from').value;
163
259
  const agent = state.agents.find(a => a.id === agentId);
@@ -171,6 +267,17 @@ export async function sendCompose() {
171
267
  const body = { to, subject, text };
172
268
  if (cc) body.cc = cc;
173
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
+ }
174
281
  try {
175
282
  await apiPost('/mail/send', body, { agentKey: agent.apiKey });
176
283
  // Clean up the autosaved draft (if any) — the message is in
@@ -179,6 +286,7 @@ export async function sendCompose() {
179
286
  try { await apiDelete(`/drafts/${state.composeDraftId}`, { agentKey: agent.apiKey }); } catch { /* ignore */ }
180
287
  state.composeDraftId = null;
181
288
  }
289
+ pendingAttachments = [];
182
290
  closeCompose();
183
291
  toast('Sent.');
184
292
  if (state.selectedAgent?.id === agent.id) await loadList(agent, state.selectedFolder);
@@ -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, apiDelete } 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;
@@ -53,8 +54,12 @@ function renderMessage(msg) {
53
54
  const bodyText = msg.text ?? stripHtml(msg.html ?? '');
54
55
 
55
56
  const attachmentsHtml = (msg.attachments ?? []).length > 0
56
- ? `<div class="message-attachments">${msg.attachments.map(a =>
57
- `<span class="message-attachment"><span class="att-icon">${icon('attachment', { size: 18 })}</span>${escapeHtml(a.filename ?? '(unnamed)')}${a.size ? ` · ${Math.round(a.size/1024)}KB` : ''}</span>`
57
+ ? `<div class="message-attachments">${msg.attachments.map((a, i) =>
58
+ `<button class="message-attachment" data-att-index="${i}" data-att-filename="${escapeHtml(a.filename ?? 'attachment')}" title="Click to download">
59
+ <span class="att-icon">${icon('attachment', { size: 18 })}</span>
60
+ <span class="att-name">${escapeHtml(a.filename ?? '(unnamed)')}</span>
61
+ ${a.size ? `<span class="att-size">${formatBytes(a.size)}</span>` : ''}
62
+ </button>`
58
63
  ).join('')}</div>`
59
64
  : '';
60
65
 
@@ -73,15 +78,134 @@ function renderMessage(msg) {
73
78
  <div class="message-date">${escapeHtml(formatDateFull(msg.date))}</div>
74
79
  </div>
75
80
  </div>
76
- <div class="message-body">${renderMarkdown(bodyText)}</div>
81
+ <div class="message-body">${renderBodyWithThreading(bodyText)}</div>
77
82
  ${attachmentsHtml}
78
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
+ `;
79
203
  }
80
204
 
81
205
  async function markUnread() {
82
206
  if (!state.currentMessage || !state.selectedAgent) return;
83
207
  try {
84
- await apiPost(`/mail/messages/${state.currentMessage.uid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
208
+ await apiPost(`/mail/messages/${state.selectedUid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
85
209
  toast('Marked unread.');
86
210
  location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
87
211
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -97,9 +221,15 @@ async function markUnread() {
97
221
  */
98
222
  async function markSpam() {
99
223
  if (!state.currentMessage || !state.selectedAgent) return;
100
- if (!confirm('Report this message as spam? It will be moved to the Junk folder.')) 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;
101
231
  try {
102
- await apiPost(`/mail/messages/${state.currentMessage.uid}/spam`, {}, { agentKey: state.selectedAgent.apiKey });
232
+ await apiPost(`/mail/messages/${state.selectedUid}/spam`, {}, { agentKey: state.selectedAgent.apiKey });
103
233
  toast('Reported as spam.');
104
234
  location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
105
235
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -116,9 +246,26 @@ async function markSpam() {
116
246
  */
117
247
  async function deleteMessage() {
118
248
  if (!state.currentMessage || !state.selectedAgent) return;
119
- if (!confirm('Delete this message?')) return;
249
+ const subject = state.currentMessage.subject ?? '(no subject)';
250
+ // From Trash, delete is permanent (no further fallback). From
251
+ // every other folder it's a move-to-trash, recoverable.
252
+ const isTrash = state.selectedFolder === 'trash';
253
+ const ok = await confirmModal({
254
+ title: isTrash ? 'Delete this message forever?' : 'Delete this message?',
255
+ body: isTrash
256
+ ? `"${subject}" will be permanently removed. This can't be undone.`
257
+ : `"${subject}" will be moved to Trash. You can recover it from there.`,
258
+ confirm: isTrash ? 'Delete forever' : 'Move to Trash',
259
+ danger: true,
260
+ });
261
+ if (!ok) return;
120
262
  try {
121
- await apiDelete(`/mail/messages/${state.currentMessage.uid}`, { agentKey: state.selectedAgent.apiKey });
263
+ // Pass the real IMAP folder name + permanent flag. The API
264
+ // uses the folder for the IMAP source mailbox and decides
265
+ // move-to-trash vs expunge based on `permanent`.
266
+ const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
267
+ const qs = `?folder=${encodeURIComponent(imap)}${isTrash ? '&permanent=true' : ''}`;
268
+ await apiDelete(`/mail/messages/${state.selectedUid}${qs}`, { agentKey: state.selectedAgent.apiKey });
122
269
  toast('Deleted.');
123
270
  location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
124
271
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -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/styles.css CHANGED
@@ -579,6 +579,42 @@ mark.search-hl {
579
579
  margin: .8em 0; padding: .2em 0 .2em 12px;
580
580
  color: var(--muted); white-space: pre-wrap;
581
581
  }
582
+
583
+ /* ─── In-body thread quote (replies inline in the body) ───────── */
584
+ /* When a message body contains an "On <date>, <addr> wrote:" line
585
+ followed by `>`-quoted text, we render that section as a
586
+ styled card with proper avatar + name + friendly date instead
587
+ of leaving the raw ISO timestamp visible. Nested replies recurse
588
+ so a 3-deep thread gets 3 nested cards. */
589
+ .thread-quote {
590
+ margin: 16px 0;
591
+ border-left: 3px solid var(--pink-rule);
592
+ padding: 0 0 0 16px;
593
+ }
594
+ .thread-quote .thread-quote {
595
+ border-left-color: #c084fc;
596
+ margin: 12px 0;
597
+ }
598
+ .thread-quote .thread-quote .thread-quote { border-left-color: #f59e0b; }
599
+ .thread-quote-head {
600
+ display: flex; align-items: center; gap: 8px;
601
+ margin: 0 0 8px;
602
+ font-size: 13px;
603
+ color: var(--muted);
604
+ }
605
+ .thread-quote-head .avatar-sm {
606
+ width: 22px; height: 22px; font-size: 10px;
607
+ }
608
+ .thread-quote-from { font-weight: 500; color: var(--ink-soft); }
609
+ .thread-quote-dot { opacity: .5; }
610
+ .thread-quote-date { color: var(--muted); }
611
+ .thread-quote-body {
612
+ color: var(--ink-soft);
613
+ }
614
+ .thread-quote-body p,
615
+ .thread-quote-body div { color: var(--ink-soft); }
616
+ .thread-quote-body code { background: var(--code-bg); color: var(--code-fg); }
617
+
582
618
  .message-body blockquote blockquote { border-left-color: #c084fc; }
583
619
  .message-body blockquote blockquote blockquote { border-left-color: #f59e0b; }
584
620
  .message-body table { border-collapse: collapse; margin: .5em 0; }
@@ -597,11 +633,62 @@ mark.search-hl {
597
633
  width: 100%;
598
634
  }
599
635
  .message-attachment {
636
+ /* Clickable chip — fetches the file via the authed download
637
+ helper and triggers a browser download. */
600
638
  display: inline-flex; align-items: center; gap: 8px;
601
639
  padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px;
602
- font-size: 13px; color: var(--ink-soft);
640
+ background: var(--bg-soft); color: var(--ink-soft);
641
+ font-size: 13px; cursor: pointer;
642
+ font: inherit;
643
+ transition: background .12s, border-color .12s, transform .06s;
644
+ }
645
+ .message-attachment:hover {
646
+ background: var(--bg-hover);
647
+ border-color: var(--accent-strong);
648
+ color: var(--ink);
649
+ }
650
+ .message-attachment:active { transform: translateY(1px); }
651
+ .message-attachment.downloading { opacity: .6; cursor: progress; }
652
+ .message-attachment .att-icon { display: inline-flex; }
653
+ .message-attachment .att-size {
654
+ color: var(--muted); font-size: 12px;
655
+ padding-left: 4px;
656
+ border-left: 1px solid var(--line);
657
+ }
658
+
659
+ /* Compose attachment chips (in the open compose modal) */
660
+ .compose-attachments {
661
+ display: flex; flex-wrap: wrap; gap: 6px;
662
+ padding: 8px 0 0;
663
+ }
664
+ .compose-attachments:empty { display: none; }
665
+ .attachment-chip {
666
+ display: inline-flex; align-items: center; gap: 8px;
667
+ padding: 4px 4px 4px 10px;
668
+ background: var(--bg-soft); border: 1px solid var(--line);
669
+ border-radius: 16px;
670
+ font-size: 12px; color: var(--ink-soft);
671
+ max-width: 240px;
603
672
  }
604
- .message-attachment .att-icon { font-size: 18px; }
673
+ .attachment-chip .chip-name {
674
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
675
+ max-width: 160px;
676
+ }
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 {
@@ -706,6 +793,60 @@ mark.search-hl {
706
793
  font-size: 11px; color: var(--muted); padding: 0 8px;
707
794
  }
708
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
+
709
850
  /* ─── Toast ────────────────────────────────────────────────────────── */
710
851
  .toast {
711
852
  position: fixed; bottom: 24px; left: 24px; z-index: 60;