@agenticmail/api 0.7.11 → 0.7.13
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/package.json +1 -1
- package/public/branding/agenticmail-logo.png +0 -0
- package/public/js/app.js +24 -7
- package/public/js/list-view.js +79 -23
- package/public/js/message-view.js +2 -2
- package/public/js/state.js +12 -0
- package/public/styles.css +26 -16
package/package.json
CHANGED
|
Binary file
|
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
|
@@ -27,25 +27,58 @@ 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');
|
|
51
84
|
root.innerHTML = `
|
|
@@ -55,15 +88,30 @@ export async function loadList(agent, folder) {
|
|
|
55
88
|
</div>
|
|
56
89
|
<div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
|
|
57
90
|
`;
|
|
58
|
-
|
|
91
|
+
await ensureFolderCache(agent);
|
|
92
|
+
|
|
93
|
+
// Resolve the real IMAP folder. Starred reuses INBOX + a client-
|
|
94
|
+
// side flag filter (Gmail convention); other folders need a real
|
|
95
|
+
// mailbox name from the discovery cache.
|
|
96
|
+
const isStarred = folder === 'starred';
|
|
97
|
+
const imap = isStarred ? 'INBOX' : imapNameFor(folder);
|
|
98
|
+
if (!imap) {
|
|
99
|
+
document.getElementById('list-rows').innerHTML =
|
|
100
|
+
`<div class="empty"><div class="big">📭</div>No ${escapeHtml(folderTitle(folder))} folder on this server.</div>`;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
59
104
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
105
|
+
// `/mail/digest` returns envelopes WITH body preview in one call —
|
|
106
|
+
// exactly what the list row needs to render a 2-line preview.
|
|
107
|
+
// Previously we used `/mail/inbox` (no preview) and `/mail/
|
|
108
|
+
// folders/:folder` (no preview, wrong folder names), which left
|
|
109
|
+
// every row stuck on subject + sender alone.
|
|
110
|
+
const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=50&offset=0&previewLength=240`;
|
|
111
|
+
const data = await apiGet(url, { agentKey: agent.apiKey });
|
|
62
112
|
state.messages = data.messages ?? [];
|
|
63
113
|
renderList();
|
|
64
114
|
} 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
115
|
const msg = String(err.message ?? err);
|
|
68
116
|
document.getElementById('list-rows').innerHTML = msg.includes('404')
|
|
69
117
|
? `<div class="empty">${escapeHtml(folderTitle(folder))} is empty.</div>`
|
|
@@ -119,6 +167,14 @@ export function renderList() {
|
|
|
119
167
|
const subject = m.subject ?? '(no subject)';
|
|
120
168
|
const date = formatDate(m.date);
|
|
121
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.
|
|
173
|
+
const cleanPreview = (m.preview ?? '')
|
|
174
|
+
.replace(/^>+ ?/gm, '')
|
|
175
|
+
.replace(/\s+/g, ' ')
|
|
176
|
+
.trim()
|
|
177
|
+
.slice(0, 280);
|
|
122
178
|
return `
|
|
123
179
|
<div class="list-row ${unread ? 'unread' : ''}" data-uid="${m.uid}">
|
|
124
180
|
<span class="star ${starred ? 'starred' : ''}" data-action="star">${starIcon}</span>
|
|
@@ -126,7 +182,7 @@ export function renderList() {
|
|
|
126
182
|
<span class="from">${highlightTerm(fromName, hlTerm)}</span>
|
|
127
183
|
<span class="subject-cell">
|
|
128
184
|
<span class="subject">${highlightTerm(subject, hlTerm)}</span>
|
|
129
|
-
<span class="preview">${highlightTerm(
|
|
185
|
+
<span class="preview">${highlightTerm(cleanPreview, hlTerm)}</span>
|
|
130
186
|
</span>
|
|
131
187
|
<span class="date">${escapeHtml(date)}</span>
|
|
132
188
|
</div>
|
|
@@ -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
|
@@ -82,21 +82,22 @@ a { color: var(--accent-strong); }
|
|
|
82
82
|
padding: 0 8px; min-width: 200px;
|
|
83
83
|
}
|
|
84
84
|
.brand-bow { font-size: 28px; line-height: 1; }
|
|
85
|
+
/* The brand bow PNG ships with transparent background — no rounded
|
|
86
|
+
crop, no fill. Sits flush against the topbar. */
|
|
85
87
|
.brand-logo {
|
|
86
|
-
width:
|
|
87
|
-
border-radius: 8px;
|
|
88
|
+
width: 36px; height: 36px;
|
|
88
89
|
flex-shrink: 0;
|
|
89
90
|
display: block;
|
|
91
|
+
object-fit: contain;
|
|
90
92
|
}
|
|
91
93
|
.brand-name {
|
|
92
94
|
font: 500 22px/1 'Google Sans', sans-serif;
|
|
93
95
|
color: var(--pink);
|
|
94
96
|
}
|
|
95
|
-
/* Slightly bigger logo in the auth card. */
|
|
96
97
|
.auth-card .brand-logo {
|
|
97
|
-
width:
|
|
98
|
-
border-radius: 6px;
|
|
98
|
+
width: 32px; height: 32px;
|
|
99
99
|
vertical-align: middle;
|
|
100
|
+
display: inline-block;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
.search-container {
|
|
@@ -388,14 +389,18 @@ a { color: var(--accent-strong); }
|
|
|
388
389
|
}
|
|
389
390
|
.list-row {
|
|
390
391
|
display: grid;
|
|
391
|
-
grid-template-columns: 24px 24px
|
|
392
|
-
align-
|
|
393
|
-
|
|
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;
|
|
394
397
|
cursor: pointer;
|
|
395
398
|
border-bottom: 1px solid var(--bg-soft);
|
|
396
399
|
position: relative;
|
|
397
400
|
}
|
|
398
|
-
@media (max-width: 1000px) { .list-row { grid-template-columns: 24px 24px
|
|
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; }
|
|
399
404
|
.list-row:hover {
|
|
400
405
|
background: var(--bg-row-hover);
|
|
401
406
|
box-shadow: inset 0 0 0 1px rgba(0,0,0,.05);
|
|
@@ -422,21 +427,26 @@ a { color: var(--accent-strong); }
|
|
|
422
427
|
padding-right: 8px;
|
|
423
428
|
}
|
|
424
429
|
.list-row .subject-cell {
|
|
425
|
-
|
|
430
|
+
/* Stacked: subject on top, two-line preview underneath. */
|
|
431
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
426
432
|
overflow: hidden;
|
|
427
433
|
min-width: 0;
|
|
428
434
|
}
|
|
429
435
|
.list-row .subject {
|
|
430
|
-
font-size: 14px;
|
|
431
|
-
max-width: 50%;
|
|
436
|
+
font-size: 14px;
|
|
432
437
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
433
438
|
}
|
|
434
439
|
.list-row .preview {
|
|
435
|
-
font-size:
|
|
436
|
-
|
|
437
|
-
|
|
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;
|
|
438
449
|
}
|
|
439
|
-
.list-row .preview::before { content: '— '; opacity: .5; }
|
|
440
450
|
.list-row .date {
|
|
441
451
|
font-size: 12px; color: var(--muted); font-weight: 500;
|
|
442
452
|
text-align: right; padding-right: 4px;
|