@agenticmail/api 0.7.12 → 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.12",
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",
package/public/js/app.js CHANGED
@@ -83,7 +83,9 @@ async function bootstrap() {
83
83
  populateComposeFrom();
84
84
  subscribeToAllAgents();
85
85
  maybeRequestNotificationPermission();
86
- if (!location.hash) location.hash = '#/inbox';
86
+ // Initial route: if the URL already has a hash (e.g. a refresh
87
+ // on /#/folder/sent), respect it; otherwise default to inbox.
88
+ if (!location.hash) location.hash = '#/folder/inbox';
87
89
  else route();
88
90
  } catch (err) {
89
91
  toast(`Failed to load agents: ${err.message}`, true);
@@ -100,24 +102,39 @@ async function selectAgent(agent) {
100
102
  }
101
103
 
102
104
  function onFolderSelect(folder) {
103
- state.selectedFolder = folder;
104
- renderSidebar(onFolderSelect);
105
- location.hash = '#/inbox'; // any folder uses the list route
106
- if (state.selectedAgent) loadList(state.selectedAgent, folder);
105
+ // URL drives state set the hash and let the router do the work.
106
+ // This is what makes browser back / forward / shareable URLs work,
107
+ // and it stops the previous bug where every folder click stayed on
108
+ // #/inbox in the address bar.
109
+ location.hash = `#/folder/${folder}`;
107
110
  // On mobile (the only viewport where the sidebar is over-canvas),
108
111
  // close it after a folder pick so the user sees the list.
109
112
  document.getElementById('main')?.classList.remove('sidebar-open');
110
113
  }
111
114
 
112
115
  // ─── Hash router ─────────────────────────────────────────────────────
116
+ // Routes:
117
+ // #/inbox → inbox (back-compat shortcut for #/folder/inbox)
118
+ // #/folder/<id> → folder list view (sent, drafts, starred, …)
119
+ // #/m/<uid> → single-message detail
120
+ //
121
+ // Folder switches go through here too so the URL is the source of truth
122
+ // for "what's on screen". If you bookmark or copy-paste a URL like
123
+ // http://127.0.0.1:3829/#/folder/sent, opening it lands you on Sent.
113
124
  function route() {
114
125
  const hash = location.hash || '#/inbox';
115
126
  const msgMatch = hash.match(/^#\/m\/(\d+)$/);
116
127
  if (msgMatch) {
117
128
  openMessage(Number(msgMatch[1]));
118
- } else if (state.selectedAgent) {
119
- loadList(state.selectedAgent, state.selectedFolder);
129
+ return;
130
+ }
131
+ const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
132
+ const folder = folderMatch ? folderMatch[1] : 'inbox';
133
+ if (state.selectedFolder !== folder) {
134
+ state.selectedFolder = folder;
135
+ renderSidebar(onFolderSelect);
120
136
  }
137
+ if (state.selectedAgent) loadList(state.selectedAgent, folder);
121
138
  }
122
139
  window.addEventListener('hashchange', route);
123
140
 
@@ -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
 
@@ -27,43 +27,100 @@ function flagsHas(flags, name) {
27
27
  return false;
28
28
  }
29
29
 
30
- // Map sidebar folder ids to the actual IMAP folder names the API
31
- // expects on `/mail/folders/:folder`. `inbox` is special the API
32
- // has a dedicated `/mail/inbox` endpoint with extra enrichment, so
33
- // we use that. Other folders go through the generic listing.
34
- //
35
- // Stalwart uses the standard IMAP names: INBOX, Sent, Drafts, Junk
36
- // Mail (a.k.a. "Spam"), Trash. We use the canonical IMAP capitalisation.
37
- const FOLDER_TO_IMAP = {
38
- inbox: { endpoint: '/mail/inbox' },
39
- sent: { endpoint: '/mail/folders/Sent' },
40
- drafts: { endpoint: '/mail/folders/Drafts' },
41
- spam: { endpoint: '/mail/folders/Junk%20Mail' },
42
- trash: { endpoint: '/mail/folders/Trash' },
43
- all: { endpoint: '/mail/folders/All%20Mail' },
44
- // Starred is not a folder — it's the IMAP \Flagged flag, surfaced
45
- // by client-side filtering over the inbox listing (Gmail-style).
46
- starred: { endpoint: '/mail/inbox', clientFilter: 'flagged' },
30
+ // Patterns we look for when matching a real IMAP folder name to one
31
+ // of our sidebar folder ids. Different mail servers use different
32
+ // names: Stalwart's defaults are "Sent Items", "Drafts", "Junk Mail",
33
+ // "Trash"; Gmail uses "[Gmail]/Sent Mail"; Outlook uses "Sent Items"
34
+ // + "Deleted Items"; macOS Mail uses "Sent Messages". Auto-discovery
35
+ // makes the sidebar work on all of them.
36
+ const FOLDER_MATCHERS = {
37
+ sent: /^sent\b|sent items|sent mail|sent messages|\[gmail\]\/sent/i,
38
+ drafts: /^drafts?\b|\[gmail\]\/drafts/i,
39
+ spam: /^junk\b|junk mail|^spam\b|\[gmail\]\/spam/i,
40
+ trash: /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i,
41
+ all: /^all mail\b|\[gmail\]\/all/i,
47
42
  };
48
43
 
44
+ /**
45
+ * Look up the real IMAP folder name for a sidebar id, using the
46
+ * per-agent folder cache populated by ensureFolderCache().
47
+ * Returns undefined if no match — callers should treat that as
48
+ * "folder doesn't exist on this server" and render an empty state.
49
+ */
50
+ function imapNameFor(folderId) {
51
+ return state.folderNames?.[folderId];
52
+ }
53
+
54
+ /**
55
+ * Discover real IMAP folder names for the active agent and cache
56
+ * them in state. Called once on agent switch / first folder click.
57
+ * Falls back to canonical names if the discovery endpoint fails so
58
+ * the UI keeps working in degraded mode.
59
+ */
60
+ export async function ensureFolderCache(agent) {
61
+ if (state.folderNames && Object.keys(state.folderNames).length > 0) return;
62
+ state.folderNames = { inbox: 'INBOX' }; // INBOX is universal
63
+ try {
64
+ const data = await apiGet('/mail/folders', { agentKey: agent.apiKey });
65
+ const folders = (data.folders ?? []).map(f =>
66
+ typeof f === 'string' ? f : (f.name ?? f.path ?? ''),
67
+ ).filter(Boolean);
68
+ for (const [id, pattern] of Object.entries(FOLDER_MATCHERS)) {
69
+ const match = folders.find(f => pattern.test(f));
70
+ if (match) state.folderNames[id] = match;
71
+ }
72
+ } catch {
73
+ // Discovery failed — fall back to the most common defaults so
74
+ // at least Inbox + Sent work for vanilla Stalwart.
75
+ state.folderNames.sent = 'Sent Items';
76
+ state.folderNames.drafts = 'Drafts';
77
+ state.folderNames.spam = 'Junk Mail';
78
+ state.folderNames.trash = 'Trash';
79
+ }
80
+ }
81
+
49
82
  export async function loadList(agent, folder) {
50
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.
51
88
  root.innerHTML = `
52
- <div class="list-header">
53
- <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>
54
95
  <span class="count-text" id="list-count"></span>
55
96
  </div>
56
97
  <div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
57
98
  `;
58
- const route = FOLDER_TO_IMAP[folder] ?? FOLDER_TO_IMAP.inbox;
99
+ document.getElementById('list-refresh-btn')?.addEventListener('click', () => loadList(agent, folder));
100
+ await ensureFolderCache(agent);
101
+
102
+ // Resolve the real IMAP folder. Starred reuses INBOX + a client-
103
+ // side flag filter (Gmail convention); other folders need a real
104
+ // mailbox name from the discovery cache.
105
+ const isStarred = folder === 'starred';
106
+ const imap = isStarred ? 'INBOX' : imapNameFor(folder);
107
+ if (!imap) {
108
+ document.getElementById('list-rows').innerHTML =
109
+ `<div class="empty"><div class="big">📭</div>No ${escapeHtml(folderTitle(folder))} folder on this server.</div>`;
110
+ return;
111
+ }
112
+
59
113
  try {
60
- const sep = route.endpoint.includes('?') ? '&' : '?';
61
- const data = await apiGet(`${route.endpoint}${sep}limit=50&offset=0`, { agentKey: agent.apiKey });
114
+ // `/mail/digest` returns envelopes WITH body preview in one call —
115
+ // exactly what the list row needs to render a 2-line preview.
116
+ // Previously we used `/mail/inbox` (no preview) and `/mail/
117
+ // folders/:folder` (no preview, wrong folder names), which left
118
+ // every row stuck on subject + sender alone.
119
+ const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=50&offset=0&previewLength=240`;
120
+ const data = await apiGet(url, { agentKey: agent.apiKey });
62
121
  state.messages = data.messages ?? [];
63
122
  renderList();
64
123
  } catch (err) {
65
- // Empty folder is a normal state; "no such folder" lands here
66
- // too. Show a friendly empty message rather than a raw HTTP error.
67
124
  const msg = String(err.message ?? err);
68
125
  document.getElementById('list-rows').innerHTML = msg.includes('404')
69
126
  ? `<div class="empty">${escapeHtml(folderTitle(folder))} is empty.</div>`
@@ -111,6 +168,12 @@ export function renderList() {
111
168
  return;
112
169
  }
113
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.
114
177
  root.innerHTML = filtered.map(m => {
115
178
  const unread = !flagsHas(m.flags, '\\Seen');
116
179
  const starred = flagsHas(m.flags, '\\Flagged');
@@ -118,15 +181,19 @@ export function renderList() {
118
181
  const fromName = m.from?.[0]?.name || fromAddr;
119
182
  const subject = m.subject ?? '(no subject)';
120
183
  const date = formatDate(m.date);
121
- const starIcon = icon(starred ? 'starFilled' : 'starOutline', { size: 18 });
184
+ const starIcon = icon(starred ? 'starFilled' : 'starOutline', { size: 16 });
185
+ const cleanPreview = (m.preview ?? '')
186
+ .replace(/^>+ ?/gm, '')
187
+ .replace(/\s+/g, ' ')
188
+ .trim();
122
189
  return `
123
190
  <div class="list-row ${unread ? 'unread' : ''}" data-uid="${m.uid}">
124
- <span class="star ${starred ? 'starred' : ''}" data-action="star">${starIcon}</span>
125
- <span class="dot"></span>
126
- <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>
127
194
  <span class="subject-cell">
128
195
  <span class="subject">${highlightTerm(subject, hlTerm)}</span>
129
- <span class="preview">${highlightTerm((m.preview ?? '').slice(0, 160), hlTerm)}</span>
196
+ ${cleanPreview ? `<span class="preview-sep"> — </span><span class="preview">${highlightTerm(cleanPreview, hlTerm)}</span>` : ''}
130
197
  </span>
131
198
  <span class="date">${escapeHtml(date)}</span>
132
199
  </div>
@@ -135,9 +202,17 @@ export function renderList() {
135
202
 
136
203
  root.querySelectorAll('.list-row').forEach(el => {
137
204
  el.addEventListener('click', (e) => {
138
- 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"]')) {
139
215
  e.stopPropagation();
140
- toast('Starring not wired through API yet.');
141
216
  return;
142
217
  }
143
218
  const uid = Number(el.dataset.uid);
@@ -146,6 +221,49 @@ export function renderList() {
146
221
  });
147
222
  }
148
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
+
149
267
  export function clearSearch() {
150
268
  const input = document.getElementById('search-input');
151
269
  if (input) {
@@ -23,7 +23,7 @@ export async function openMessage(uid) {
23
23
  </div>
24
24
  <div class="message-view"><div class="empty">Loading…</div></div>
25
25
  `;
26
- document.getElementById('msg-back').addEventListener('click', () => { location.hash = '#/inbox'; });
26
+ document.getElementById('msg-back').addEventListener('click', () => { location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`; });
27
27
  document.getElementById('msg-reply').addEventListener('click', () => openReply(false));
28
28
  document.getElementById('msg-reply-all').addEventListener('click', () => openReply(true));
29
29
  document.getElementById('msg-unread').addEventListener('click', () => markUnread());
@@ -79,7 +79,7 @@ async function markUnread() {
79
79
  try {
80
80
  await apiPost(`/mail/messages/${state.currentMessage.uid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
81
81
  toast('Marked unread.');
82
- location.hash = '#/inbox';
82
+ location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
83
83
  await loadList(state.selectedAgent, state.selectedFolder);
84
84
  } catch (err) {
85
85
  toast(`Failed: ${err.message}`, true);
@@ -12,6 +12,18 @@ export const state = {
12
12
  searchQuery: '',
13
13
  sseControllers: [],
14
14
  unread: {}, // { [agentId]: count }
15
+ /**
16
+ * Mapping from sidebar folder id ('sent', 'drafts', 'spam', etc.)
17
+ * to the real IMAP folder name on the server.
18
+ *
19
+ * Auto-discovered per agent via `GET /mail/folders` because
20
+ * Stalwart's default folder names differ from server to server
21
+ * (`Sent Items` vs `Sent`, `Junk Mail` vs `Spam`, etc.). Without
22
+ * this, hard-coded names like `Sent` returned empty for Stalwart
23
+ * installs that use `Sent Items` — exactly what the bug report
24
+ * showed.
25
+ */
26
+ folderNames: {}, // { [sidebarId]: imapFolderName }
15
27
  };
16
28
 
17
29
  export const API_URL = window.location.origin;
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,42 +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 240px 1fr 100px;
413
+ grid-template-columns: 36px 32px 180px 1fr 90px;
393
414
  align-items: center; gap: 0;
394
- padding: 0 16px; height: 40px;
415
+ padding: 0 16px; height: 36px;
395
416
  cursor: pointer;
396
- border-bottom: 1px solid var(--bg-soft);
417
+ border-bottom: 1px solid var(--line);
397
418
  position: relative;
398
419
  }
399
- @media (max-width: 1000px) { .list-row { grid-template-columns: 24px 24px 180px 1fr 80px; } }
420
+ @media (max-width: 1100px) { .list-row { grid-template-columns: 36px 32px 140px 1fr 80px; } }
400
421
  .list-row:hover {
401
- background: var(--bg-row-hover);
402
- 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);
403
424
  z-index: 1;
404
425
  }
405
426
  .list-row.unread { background: var(--bg); }
@@ -409,47 +430,39 @@ a { color: var(--accent-strong); }
409
430
  .list-row:not(.unread) .from, .list-row:not(.unread) .subject {
410
431
  color: var(--read-text);
411
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; }
412
439
  .list-row .star {
413
- font-size: 16px; color: #dadce0; cursor: pointer;
414
- width: 24px; height: 24px;
440
+ width: 32px; height: 32px;
415
441
  display: flex; align-items: center; justify-content: center;
416
- border-radius: 50%;
442
+ border-radius: 50%; cursor: pointer;
443
+ color: #dadce0;
417
444
  }
418
- .list-row .star:hover { background: var(--bg-hover); }
445
+ .list-row .star:hover { background: var(--bg-hover); color: var(--muted); }
419
446
  .list-row .star.starred { color: #f4b400; }
420
447
  .list-row .from {
421
448
  font-size: 14px;
422
449
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
423
- padding-right: 8px;
450
+ padding-right: 12px;
424
451
  }
425
452
  .list-row .subject-cell {
426
- display: flex; gap: 8px; align-items: baseline;
427
- overflow: hidden;
428
- min-width: 0;
429
- }
430
- .list-row .subject {
431
- font-size: 14px; flex-shrink: 0;
432
- max-width: 50%;
433
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
434
- }
435
- .list-row .preview {
436
- font-size: 14px; color: var(--muted);
453
+ font-size: 14px;
437
454
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
438
- flex: 1; min-width: 0;
455
+ min-width: 0;
456
+ display: block; /* one line, ellipsis at the end */
439
457
  }
440
- .list-row .preview::before { content: '— '; opacity: .5; }
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; }
441
461
  .list-row .date {
442
462
  font-size: 12px; color: var(--muted); font-weight: 500;
443
463
  text-align: right; padding-right: 4px;
444
464
  }
445
465
  .list-row.unread .date { color: var(--unread-bold); font-weight: 700; }
446
- .list-row .dot {
447
- width: 8px; height: 8px; border-radius: 50%;
448
- background: var(--pink);
449
- display: none;
450
- margin: 0 auto;
451
- }
452
- .list-row.unread .dot { display: block; }
453
466
 
454
467
  mark.search-hl {
455
468
  background: #fff475; color: inherit;