@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
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
 
@@ -27,25 +27,58 @@ 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');
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
- const route = FOLDER_TO_IMAP[folder] ?? FOLDER_TO_IMAP.inbox;
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
- const sep = route.endpoint.includes('?') ? '&' : '?';
61
- const data = await apiGet(`${route.endpoint}${sep}limit=50&offset=0`, { agentKey: agent.apiKey });
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((m.preview ?? '').slice(0, 160), hlTerm)}</span>
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 = '#/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
@@ -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: 32px; height: 32px;
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: 28px; height: 28px;
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 240px 1fr 100px;
392
- align-items: center; gap: 0;
393
- padding: 0 16px; height: 40px;
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 180px 1fr 80px; } }
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
- display: flex; gap: 8px; align-items: baseline;
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; flex-shrink: 0;
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: 14px; color: var(--muted);
436
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
437
- flex: 1; min-width: 0;
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;