@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.13",
3
+ "version": "0.7.14",
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",
@@ -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-header">
86
- <span class="folder-title">${escapeHtml(folderTitle(folder))}</span>
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: 18 });
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
- <span class="star ${starred ? 'starred' : ''}" data-action="star">${starIcon}</span>
181
- <span class="dot"></span>
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
- <span class="preview">${highlightTerm(cleanPreview, hlTerm)}</span>
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
- if (e.target.closest('[data-action="star"]')) {
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
- /* List rows lose the from column on narrow screens; the subject
276
- gets full width with the sender folded into the preview. */
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: 24px 24px 1fr 70px;
279
- height: 56px;
280
- padding: 0 12px;
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
- flex-direction: column;
285
- gap: 2px;
286
- align-items: flex-start;
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 .subject { max-width: none; font-size: 14px; }
289
- .list-row .preview { font-size: 13px; }
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: 56px; padding: 0 24px 0 16px;
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 header (above the list itself) */
368
- .list-header {
369
- display: flex; align-items: center; gap: 8px;
370
- padding: 8px 16px; height: 48px;
371
- border-bottom: 1px solid transparent;
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-header .icon-btn { width: 36px; height: 36px; font-size: 16px; }
377
- .list-header .count-text {
378
- margin-left: auto;
379
- font-size: 12px; color: var(--muted);
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-header .folder-title {
382
- font: 500 18px/1 'Google Sans', sans-serif;
383
- color: var(--ink); margin-left: 8px;
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
- /* List rows */
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: 24px 24px 200px 1fr 100px;
393
- /* Top-align so a two-line preview can grow downward without
394
- pushing the star + date out of alignment with the subject. */
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(--bg-soft);
417
+ border-bottom: 1px solid var(--line);
399
418
  position: relative;
400
419
  }
401
- @media (max-width: 1000px) { .list-row { grid-template-columns: 24px 24px 160px 1fr 80px; } }
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-row-hover);
406
- box-shadow: inset 0 0 0 1px rgba(0,0,0,.05);
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
- font-size: 16px; color: #dadce0; cursor: pointer;
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: 8px;
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 .preview {
440
- font-size: 13px; color: var(--muted); line-height: 1.35;
441
- /* 2-line clamp via -webkit-box (works in every shipping browser
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;