@agenticmail/api 0.7.13 → 0.7.14
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 +18 -0
- package/package.json +1 -1
- package/public/js/list-view.js +77 -15
- package/public/styles.css +70 -66
package/dist/index.js
CHANGED
|
@@ -2191,6 +2191,24 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2191
2191
|
next(err);
|
|
2192
2192
|
}
|
|
2193
2193
|
});
|
|
2194
|
+
router.post("/mail/messages/:uid/star", requireAgent, async (req, res, next) => {
|
|
2195
|
+
try {
|
|
2196
|
+
const agent = req.agent;
|
|
2197
|
+
const uid = parseInt(req.params.uid);
|
|
2198
|
+
if (isNaN(uid) || uid < 1) {
|
|
2199
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const starred = req.body?.starred !== false;
|
|
2203
|
+
const folder = req.body?.folder || req.query.folder || "INBOX";
|
|
2204
|
+
const password = getAgentPassword(agent);
|
|
2205
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2206
|
+
await receiver.setStarred(uid, starred, folder);
|
|
2207
|
+
res.json({ ok: true, starred });
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
next(err);
|
|
2210
|
+
}
|
|
2211
|
+
});
|
|
2194
2212
|
router.post("/mail/messages/:uid/move", requireAgent, async (req, res, next) => {
|
|
2195
2213
|
try {
|
|
2196
2214
|
const agent = req.agent;
|
package/package.json
CHANGED
package/public/js/list-view.js
CHANGED
|
@@ -4,7 +4,7 @@ import { state } from './state.js';
|
|
|
4
4
|
import { escapeHtml, toast } from './utils.js';
|
|
5
5
|
import { formatDate } from './time.js';
|
|
6
6
|
import { parseSearch, matchesSearch, highlightTerm } from './search.js';
|
|
7
|
-
import { apiGet } from './api.js';
|
|
7
|
+
import { apiGet, apiPost } from './api.js';
|
|
8
8
|
import { FOLDERS } from './sidebar.js';
|
|
9
9
|
import { icon } from './icons.js';
|
|
10
10
|
|
|
@@ -81,13 +81,22 @@ export async function ensureFolderCache(agent) {
|
|
|
81
81
|
|
|
82
82
|
export async function loadList(agent, folder) {
|
|
83
83
|
const root = document.getElementById('content');
|
|
84
|
+
// Gmail-style toolbar above the list: select-all checkbox,
|
|
85
|
+
// refresh, more-options spacer, count + pagination on the right.
|
|
86
|
+
// Identical layout for every folder so Sent / Drafts / Spam /
|
|
87
|
+
// Trash all share the same UX as Inbox.
|
|
84
88
|
root.innerHTML = `
|
|
85
|
-
<div class="list-
|
|
86
|
-
<
|
|
89
|
+
<div class="list-toolbar">
|
|
90
|
+
<label class="list-select-all" title="Select all">
|
|
91
|
+
<input type="checkbox" id="list-select-all-input" />
|
|
92
|
+
</label>
|
|
93
|
+
<button class="icon-btn list-refresh" title="Refresh" id="list-refresh-btn">${icon('refresh', { size: 18 })}</button>
|
|
94
|
+
<div class="list-toolbar-spacer"></div>
|
|
87
95
|
<span class="count-text" id="list-count"></span>
|
|
88
96
|
</div>
|
|
89
97
|
<div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
|
|
90
98
|
`;
|
|
99
|
+
document.getElementById('list-refresh-btn')?.addEventListener('click', () => loadList(agent, folder));
|
|
91
100
|
await ensureFolderCache(agent);
|
|
92
101
|
|
|
93
102
|
// Resolve the real IMAP folder. Starred reuses INBOX + a client-
|
|
@@ -159,6 +168,12 @@ export function renderList() {
|
|
|
159
168
|
return;
|
|
160
169
|
}
|
|
161
170
|
|
|
171
|
+
// Gmail-style single-line row: checkbox · star · sender · subject
|
|
172
|
+
// — preview · date. Subject and preview sit on the same line
|
|
173
|
+
// separated by an em-dash; CSS truncates the joint cell with
|
|
174
|
+
// ellipsis so longer preview lines never wrap. Identical markup
|
|
175
|
+
// for every folder so Sent / Drafts / Spam etc render the same
|
|
176
|
+
// way Inbox does.
|
|
162
177
|
root.innerHTML = filtered.map(m => {
|
|
163
178
|
const unread = !flagsHas(m.flags, '\\Seen');
|
|
164
179
|
const starred = flagsHas(m.flags, '\\Flagged');
|
|
@@ -166,23 +181,19 @@ export function renderList() {
|
|
|
166
181
|
const fromName = m.from?.[0]?.name || fromAddr;
|
|
167
182
|
const subject = m.subject ?? '(no subject)';
|
|
168
183
|
const date = formatDate(m.date);
|
|
169
|
-
const starIcon = icon(starred ? 'starFilled' : 'starOutline', { size:
|
|
170
|
-
// Compact the preview body for the row: collapse whitespace,
|
|
171
|
-
// strip quoted-reply chevrons, cap at a comfortable two-line
|
|
172
|
-
// length. CSS handles the actual line clamp.
|
|
184
|
+
const starIcon = icon(starred ? 'starFilled' : 'starOutline', { size: 16 });
|
|
173
185
|
const cleanPreview = (m.preview ?? '')
|
|
174
186
|
.replace(/^>+ ?/gm, '')
|
|
175
187
|
.replace(/\s+/g, ' ')
|
|
176
|
-
.trim()
|
|
177
|
-
.slice(0, 280);
|
|
188
|
+
.trim();
|
|
178
189
|
return `
|
|
179
190
|
<div class="list-row ${unread ? 'unread' : ''}" data-uid="${m.uid}">
|
|
180
|
-
<
|
|
181
|
-
<span class="
|
|
182
|
-
<span class="from">${highlightTerm(fromName, hlTerm)}</span>
|
|
191
|
+
<label class="row-check" data-action="select"><input type="checkbox" /></label>
|
|
192
|
+
<span class="star ${starred ? 'starred' : ''}" data-action="star" data-uid="${m.uid}">${starIcon}</span>
|
|
193
|
+
<span class="from" title="${escapeHtml(fromAddr)}">${highlightTerm(fromName, hlTerm)}</span>
|
|
183
194
|
<span class="subject-cell">
|
|
184
195
|
<span class="subject">${highlightTerm(subject, hlTerm)}</span>
|
|
185
|
-
|
|
196
|
+
${cleanPreview ? `<span class="preview-sep"> — </span><span class="preview">${highlightTerm(cleanPreview, hlTerm)}</span>` : ''}
|
|
186
197
|
</span>
|
|
187
198
|
<span class="date">${escapeHtml(date)}</span>
|
|
188
199
|
</div>
|
|
@@ -191,9 +202,17 @@ export function renderList() {
|
|
|
191
202
|
|
|
192
203
|
root.querySelectorAll('.list-row').forEach(el => {
|
|
193
204
|
el.addEventListener('click', (e) => {
|
|
194
|
-
|
|
205
|
+
// Star click — toggle via API and optimistically update the
|
|
206
|
+
// local flags so the icon flips without a reload.
|
|
207
|
+
const starEl = e.target.closest('[data-action="star"]');
|
|
208
|
+
if (starEl) {
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
toggleStar(Number(el.dataset.uid), starEl);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Checkbox click — swallow so we don't navigate.
|
|
214
|
+
if (e.target.closest('[data-action="select"]')) {
|
|
195
215
|
e.stopPropagation();
|
|
196
|
-
toast('Starring not wired through API yet.');
|
|
197
216
|
return;
|
|
198
217
|
}
|
|
199
218
|
const uid = Number(el.dataset.uid);
|
|
@@ -202,6 +221,49 @@ export function renderList() {
|
|
|
202
221
|
});
|
|
203
222
|
}
|
|
204
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Toggle the IMAP \Flagged flag on a message via the API. Updates
|
|
226
|
+
* the in-memory message object on success so renderList reflects
|
|
227
|
+
* the new state without a full reload — and reverts on failure so
|
|
228
|
+
* the icon doesn't drift from server truth.
|
|
229
|
+
*/
|
|
230
|
+
async function toggleStar(uid, starEl) {
|
|
231
|
+
const agent = state.selectedAgent;
|
|
232
|
+
if (!agent) return;
|
|
233
|
+
const msg = state.messages.find(m => m.uid === uid);
|
|
234
|
+
if (!msg) return;
|
|
235
|
+
const wasStarred = flagsHas(msg.flags, '\\Flagged');
|
|
236
|
+
const nextStarred = !wasStarred;
|
|
237
|
+
|
|
238
|
+
// Optimistic UI flip.
|
|
239
|
+
starEl.classList.toggle('starred', nextStarred);
|
|
240
|
+
starEl.innerHTML = icon(nextStarred ? 'starFilled' : 'starOutline', { size: 16 });
|
|
241
|
+
|
|
242
|
+
// Local flags mutation so a re-render keeps the new state.
|
|
243
|
+
const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
|
|
244
|
+
if (Array.isArray(msg.flags)) {
|
|
245
|
+
msg.flags = nextStarred
|
|
246
|
+
? Array.from(new Set([...msg.flags, '\\Flagged']))
|
|
247
|
+
: msg.flags.filter(f => f !== '\\Flagged');
|
|
248
|
+
} else {
|
|
249
|
+
msg.flags = nextStarred ? ['\\Flagged'] : [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await apiPost(`/mail/messages/${uid}/star`, { starred: nextStarred, folder: imap }, { agentKey: agent.apiKey });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// Revert on failure.
|
|
256
|
+
starEl.classList.toggle('starred', wasStarred);
|
|
257
|
+
starEl.innerHTML = icon(wasStarred ? 'starFilled' : 'starOutline', { size: 16 });
|
|
258
|
+
if (Array.isArray(msg.flags)) {
|
|
259
|
+
msg.flags = wasStarred
|
|
260
|
+
? Array.from(new Set([...msg.flags, '\\Flagged']))
|
|
261
|
+
: msg.flags.filter(f => f !== '\\Flagged');
|
|
262
|
+
}
|
|
263
|
+
toast(`Star failed: ${err.message}`, true);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
205
267
|
export function clearSearch() {
|
|
206
268
|
const input = document.getElementById('search-input');
|
|
207
269
|
if (input) {
|
package/public/styles.css
CHANGED
|
@@ -272,22 +272,33 @@ a { color: var(--accent-strong); }
|
|
|
272
272
|
.brand-name { font-size: 18px; }
|
|
273
273
|
.search-container { max-width: none; }
|
|
274
274
|
.search-input { height: 40px; font-size: 14px; }
|
|
275
|
-
/*
|
|
276
|
-
|
|
275
|
+
/* On narrow screens, drop the checkbox + From column. Sender goes
|
|
276
|
+
in a small line above subject+preview to mimic Gmail's mobile
|
|
277
|
+
two-row stack. */
|
|
277
278
|
.list-row {
|
|
278
|
-
grid-template-columns:
|
|
279
|
-
height:
|
|
280
|
-
|
|
279
|
+
grid-template-columns: 32px 1fr 64px;
|
|
280
|
+
height: auto;
|
|
281
|
+
min-height: 64px;
|
|
282
|
+
padding: 8px 12px;
|
|
283
|
+
align-items: center;
|
|
284
|
+
}
|
|
285
|
+
.list-row .row-check { display: none; }
|
|
286
|
+
.list-row .from {
|
|
287
|
+
grid-column: 2 / 3;
|
|
288
|
+
font-size: 13px;
|
|
289
|
+
padding-right: 0;
|
|
281
290
|
}
|
|
282
|
-
.list-row .from { display: none; }
|
|
283
291
|
.list-row .subject-cell {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
292
|
+
grid-column: 2 / 3;
|
|
293
|
+
grid-row: 2;
|
|
294
|
+
white-space: normal;
|
|
295
|
+
display: -webkit-box;
|
|
296
|
+
-webkit-line-clamp: 2;
|
|
297
|
+
-webkit-box-orient: vertical;
|
|
298
|
+
overflow: hidden;
|
|
287
299
|
}
|
|
288
|
-
.list-row .
|
|
289
|
-
.list-row .
|
|
290
|
-
.list-row .preview::before { content: ''; }
|
|
300
|
+
.list-row .star { grid-row: 1 / span 2; }
|
|
301
|
+
.list-row .date { grid-row: 1; }
|
|
291
302
|
.message-header { padding: 16px 16px 8px; }
|
|
292
303
|
.message-subject { font-size: 18px; }
|
|
293
304
|
.message-body { padding: 8px 16px 24px; max-width: none; }
|
|
@@ -312,8 +323,11 @@ a { color: var(--accent-strong); }
|
|
|
312
323
|
overflow-y: auto;
|
|
313
324
|
}
|
|
314
325
|
.compose-btn {
|
|
326
|
+
/* Gmail's Compose button is 48px tall with 16px corner radius —
|
|
327
|
+
prominent but not pill-shaped. Earlier this was 56px + ~28px
|
|
328
|
+
radius which read as a giant capsule and dominated the sidebar. */
|
|
315
329
|
display: inline-flex; align-items: center; gap: 12px;
|
|
316
|
-
height:
|
|
330
|
+
height: 48px; padding: 0 24px 0 16px;
|
|
317
331
|
margin-bottom: 16px;
|
|
318
332
|
background: var(--pink); color: white;
|
|
319
333
|
border-radius: 16px;
|
|
@@ -364,46 +378,49 @@ a { color: var(--accent-strong); }
|
|
|
364
378
|
display: flex; flex-direction: column;
|
|
365
379
|
}
|
|
366
380
|
|
|
367
|
-
/* List
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
381
|
+
/* List toolbar (sticky, matches Gmail's row of buttons above the list).
|
|
382
|
+
Same markup for every folder so Sent / Drafts / Spam render
|
|
383
|
+
identically to Inbox — no per-folder UX divergence. */
|
|
384
|
+
.list-toolbar {
|
|
385
|
+
display: flex; align-items: center; gap: 4px;
|
|
386
|
+
padding: 4px 16px; height: 48px;
|
|
387
|
+
border-bottom: 1px solid var(--line);
|
|
372
388
|
position: sticky; top: 0; z-index: 5;
|
|
373
389
|
background: var(--bg);
|
|
374
390
|
flex-shrink: 0;
|
|
375
391
|
}
|
|
376
|
-
.list-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
392
|
+
.list-select-all {
|
|
393
|
+
width: 40px; height: 40px;
|
|
394
|
+
display: flex; align-items: center; justify-content: center;
|
|
395
|
+
cursor: pointer; border-radius: 50%;
|
|
380
396
|
}
|
|
381
|
-
.list-
|
|
382
|
-
|
|
383
|
-
|
|
397
|
+
.list-select-all:hover { background: var(--bg-hover); }
|
|
398
|
+
.list-toolbar .list-refresh { width: 40px; height: 40px; }
|
|
399
|
+
.list-toolbar-spacer { flex: 1; }
|
|
400
|
+
.list-toolbar .count-text {
|
|
401
|
+
font-size: 12px; color: var(--muted);
|
|
384
402
|
}
|
|
385
403
|
|
|
386
|
-
/*
|
|
404
|
+
/* Gmail-style compact rows.
|
|
405
|
+
Single line per message; subject + preview share one truncated
|
|
406
|
+
cell so longer previews tail off with ellipsis instead of
|
|
407
|
+
wrapping. Same row shape for every folder. */
|
|
387
408
|
.list-rows {
|
|
388
409
|
flex: 1; overflow-y: auto;
|
|
389
410
|
}
|
|
390
411
|
.list-row {
|
|
391
412
|
display: grid;
|
|
392
|
-
grid-template-columns:
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
align-items: flex-start; gap: 0;
|
|
396
|
-
padding: 10px 16px; min-height: 64px;
|
|
413
|
+
grid-template-columns: 36px 32px 180px 1fr 90px;
|
|
414
|
+
align-items: center; gap: 0;
|
|
415
|
+
padding: 0 16px; height: 36px;
|
|
397
416
|
cursor: pointer;
|
|
398
|
-
border-bottom: 1px solid var(--
|
|
417
|
+
border-bottom: 1px solid var(--line);
|
|
399
418
|
position: relative;
|
|
400
419
|
}
|
|
401
|
-
@media (max-width:
|
|
402
|
-
/* Pull star + dot + date back into the visual midline of the subject. */
|
|
403
|
-
.list-row .star, .list-row .dot, .list-row .date { padding-top: 2px; }
|
|
420
|
+
@media (max-width: 1100px) { .list-row { grid-template-columns: 36px 32px 140px 1fr 80px; } }
|
|
404
421
|
.list-row:hover {
|
|
405
|
-
background: var(--bg
|
|
406
|
-
box-shadow: inset 0 0 0 1px rgba(0,0,0,.
|
|
422
|
+
background: var(--bg);
|
|
423
|
+
box-shadow: inset 1px 0 0 var(--line), inset -1px 0 0 var(--line), 0 1px 3px rgba(0,0,0,.08);
|
|
407
424
|
z-index: 1;
|
|
408
425
|
}
|
|
409
426
|
.list-row.unread { background: var(--bg); }
|
|
@@ -413,52 +430,39 @@ a { color: var(--accent-strong); }
|
|
|
413
430
|
.list-row:not(.unread) .from, .list-row:not(.unread) .subject {
|
|
414
431
|
color: var(--read-text);
|
|
415
432
|
}
|
|
433
|
+
.list-row .row-check {
|
|
434
|
+
width: 36px; height: 36px;
|
|
435
|
+
display: flex; align-items: center; justify-content: center;
|
|
436
|
+
cursor: pointer;
|
|
437
|
+
}
|
|
438
|
+
.list-row .row-check input { cursor: pointer; }
|
|
416
439
|
.list-row .star {
|
|
417
|
-
|
|
418
|
-
width: 24px; height: 24px;
|
|
440
|
+
width: 32px; height: 32px;
|
|
419
441
|
display: flex; align-items: center; justify-content: center;
|
|
420
|
-
border-radius: 50%;
|
|
442
|
+
border-radius: 50%; cursor: pointer;
|
|
443
|
+
color: #dadce0;
|
|
421
444
|
}
|
|
422
|
-
.list-row .star:hover { background: var(--bg-hover); }
|
|
445
|
+
.list-row .star:hover { background: var(--bg-hover); color: var(--muted); }
|
|
423
446
|
.list-row .star.starred { color: #f4b400; }
|
|
424
447
|
.list-row .from {
|
|
425
448
|
font-size: 14px;
|
|
426
449
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
427
|
-
padding-right:
|
|
450
|
+
padding-right: 12px;
|
|
428
451
|
}
|
|
429
452
|
.list-row .subject-cell {
|
|
430
|
-
/* Stacked: subject on top, two-line preview underneath. */
|
|
431
|
-
display: flex; flex-direction: column; gap: 2px;
|
|
432
|
-
overflow: hidden;
|
|
433
|
-
min-width: 0;
|
|
434
|
-
}
|
|
435
|
-
.list-row .subject {
|
|
436
453
|
font-size: 14px;
|
|
437
454
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
455
|
+
min-width: 0;
|
|
456
|
+
display: block; /* one line, ellipsis at the end */
|
|
438
457
|
}
|
|
439
|
-
.list-row .
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
including Firefox/Safari). The break-word stops one long URL
|
|
443
|
-
from blowing out the layout. */
|
|
444
|
-
display: -webkit-box;
|
|
445
|
-
-webkit-line-clamp: 2;
|
|
446
|
-
-webkit-box-orient: vertical;
|
|
447
|
-
overflow: hidden;
|
|
448
|
-
word-break: break-word;
|
|
449
|
-
}
|
|
458
|
+
.list-row .subject { font-weight: inherit; }
|
|
459
|
+
.list-row .preview-sep { color: var(--muted); }
|
|
460
|
+
.list-row .preview { color: var(--muted); font-weight: 400; }
|
|
450
461
|
.list-row .date {
|
|
451
462
|
font-size: 12px; color: var(--muted); font-weight: 500;
|
|
452
463
|
text-align: right; padding-right: 4px;
|
|
453
464
|
}
|
|
454
465
|
.list-row.unread .date { color: var(--unread-bold); font-weight: 700; }
|
|
455
|
-
.list-row .dot {
|
|
456
|
-
width: 8px; height: 8px; border-radius: 50%;
|
|
457
|
-
background: var(--pink);
|
|
458
|
-
display: none;
|
|
459
|
-
margin: 0 auto;
|
|
460
|
-
}
|
|
461
|
-
.list-row.unread .dot { display: block; }
|
|
462
466
|
|
|
463
467
|
mark.search-hl {
|
|
464
468
|
background: #fff475; color: inherit;
|