@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 +15 -1
- package/package.json +1 -1
- package/public/index.html +3 -0
- package/public/js/api.js +26 -0
- package/public/js/compose.js +108 -0
- package/public/js/message-view.js +156 -9
- package/public/js/modal.js +107 -0
- package/public/styles.css +143 -2
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
|
-
|
|
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
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',
|
package/public/js/compose.js
CHANGED
|
@@ -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
|
-
`<
|
|
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">${
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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;
|