@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 +18 -0
- package/package.json +1 -1
- package/public/js/app.js +24 -7
- package/public/js/list-view.js +150 -32
- package/public/js/message-view.js +2 -2
- package/public/js/state.js +12 -0
- package/public/styles.css +70 -57
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/app.js
CHANGED
|
@@ -83,7 +83,9 @@ async function bootstrap() {
|
|
|
83
83
|
populateComposeFrom();
|
|
84
84
|
subscribeToAllAgents();
|
|
85
85
|
maybeRequestNotificationPermission();
|
|
86
|
-
if
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
|
@@ -27,43 +27,100 @@ function flagsHas(flags, name) {
|
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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-
|
|
53
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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:
|
|
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
|
-
<
|
|
125
|
-
<span class="
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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 = '
|
|
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);
|
package/public/js/state.js
CHANGED
|
@@ -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
|
-
/*
|
|
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,42 +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:
|
|
413
|
+
grid-template-columns: 36px 32px 180px 1fr 90px;
|
|
393
414
|
align-items: center; gap: 0;
|
|
394
|
-
padding: 0 16px; height:
|
|
415
|
+
padding: 0 16px; height: 36px;
|
|
395
416
|
cursor: pointer;
|
|
396
|
-
border-bottom: 1px solid var(--
|
|
417
|
+
border-bottom: 1px solid var(--line);
|
|
397
418
|
position: relative;
|
|
398
419
|
}
|
|
399
|
-
@media (max-width:
|
|
420
|
+
@media (max-width: 1100px) { .list-row { grid-template-columns: 36px 32px 140px 1fr 80px; } }
|
|
400
421
|
.list-row:hover {
|
|
401
|
-
background: var(--bg
|
|
402
|
-
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);
|
|
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
|
-
|
|
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:
|
|
450
|
+
padding-right: 12px;
|
|
424
451
|
}
|
|
425
452
|
.list-row .subject-cell {
|
|
426
|
-
|
|
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
|
-
|
|
455
|
+
min-width: 0;
|
|
456
|
+
display: block; /* one line, ellipsis at the end */
|
|
439
457
|
}
|
|
440
|
-
.list-row .
|
|
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;
|