@agenticmail/api 0.7.7 → 0.7.8

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/README.md CHANGED
@@ -8,6 +8,14 @@ The API server for [AgenticMail](https://github.com/agenticmail/agenticmail) —
8
8
 
9
9
  This package runs a web server that handles everything: sending email and SMS, reading inboxes, managing agents, phone number access, real-time notifications, inter-agent messaging, spam filtering, outbound security scanning, and gateway configuration. Every feature in AgenticMail is accessible through this API.
10
10
 
11
+ ## ✨ What's new in 0.7.7
12
+
13
+ - **🌐 Lightweight Gmail-style web UI bundled** — `packages/api/public/index.html` is served by `express.static` at the API root. Open `http://127.0.0.1:3829/` in any browser, paste the master key, and you get a Gmail/Outlook-style three-pane email client (agents / inbox / message). Real-time SSE updates, markdown rendering, compose + reply with the new `wake` parameter as a field. Run via `agenticmail web` from the CLI.
14
+ - **Wake allowlist on `POST /mail/send`** — accept a `wake` parameter (array of agent names or comma-separated string). The API normalises it, sets an `X-AgenticMail-Wake` header on the outgoing SMTP envelope, AND surfaces it as `wakeAllowlist` on the SSE event so the dispatcher can decide which CC'd recipients to actually give a Claude turn.
15
+ - **Shared helpers exported from `routes/mail.ts`** — `normalizeWakeList`, `wakeHeaders`, and `pushLocalRecipientWakes` so every send path (`/mail/send`, `/templates/:id/send`, `/drafts/:id/send`, `/mail/pending/:id/approve`) uses the same primitives.
16
+ - **System events SSE** at `GET /system/events` — master-auth stream that emits `account_created` / `account_deleted` / `worker_started` / `worker_finished` events. Powers the dispatcher's zero-wait wake on newly-created agents and the `check_activity` MCP tool.
17
+ - **Dispatcher activity registry** — `GET /dispatcher/activity` returns the currently-active and recently-finished workers; `POST /dispatcher/worker-{started,finished}` lets the dispatcher push updates. Master-auth.
18
+
11
19
  ## Install
12
20
 
13
21
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
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/index.html CHANGED
@@ -57,6 +57,7 @@
57
57
  .topbar {
58
58
  display: flex; align-items: center; gap: 16px;
59
59
  padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--bg);
60
+ position: relative;
60
61
  }
61
62
  .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
62
63
  .brand-bow { font-size: 22px; }
@@ -65,16 +66,128 @@
65
66
  flex: 1; max-width: 720px;
66
67
  position: relative;
67
68
  }
69
+
70
+ /* ─── Profile switcher (Gmail-style top-right) ──────────────────── */
71
+ .profile {
72
+ display: flex; align-items: center; gap: 8px;
73
+ padding: 4px 8px 4px 4px; border-radius: 20px;
74
+ border: 1px solid var(--line); background: var(--bg);
75
+ cursor: pointer; font: inherit; color: var(--ink);
76
+ }
77
+ .profile:hover { background: var(--row-hover); }
78
+ .profile-name { font-weight: 500; font-size: 13px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
79
+ .profile-caret { font-size: 10px; color: var(--muted); }
80
+
81
+ /* Avatar circles */
82
+ .avatar {
83
+ width: 32px; height: 32px; border-radius: 50%;
84
+ display: flex; align-items: center; justify-content: center;
85
+ font-size: 14px; font-weight: 600; color: white;
86
+ flex-shrink: 0; position: relative;
87
+ }
88
+ .avatar-sm { width: 24px; height: 24px; font-size: 11px; }
89
+ .avatar-lg { width: 40px; height: 40px; font-size: 16px; }
90
+ .avatar-host {
91
+ background: #f1f1f3; /* light card behind the Claude mark */
92
+ color: #cc785c; /* Claude orange */
93
+ }
94
+ @media (prefers-color-scheme: dark) { .avatar-host { background: #1f1f23; } }
95
+ .avatar svg { width: 60%; height: 60%; }
96
+ /* Verified-host check badge — small green tick overlaid bottom-right */
97
+ .avatar-check {
98
+ position: absolute; bottom: -2px; right: -2px;
99
+ width: 14px; height: 14px; border-radius: 50%;
100
+ background: #22c55e; color: white;
101
+ display: flex; align-items: center; justify-content: center;
102
+ font-size: 9px; font-weight: 700;
103
+ border: 2px solid var(--bg);
104
+ }
105
+
106
+ /* Dropdown panel */
107
+ .profile-menu {
108
+ position: absolute; top: 56px; right: 8px;
109
+ width: 320px; max-height: 70vh; overflow-y: auto;
110
+ background: var(--bg); border: 1px solid var(--line); border-radius: 12px;
111
+ box-shadow: 0 8px 32px rgba(0,0,0,0.12);
112
+ padding: 8px 0; z-index: 25;
113
+ display: none;
114
+ }
115
+ .profile-menu.open { display: block; }
116
+ .profile-menu-section {
117
+ padding: 8px 14px 4px; font-size: 10px; font-weight: 700;
118
+ color: var(--muted); text-transform: uppercase; letter-spacing: .06em;
119
+ }
120
+ .profile-menu-item {
121
+ display: flex; align-items: center; gap: 12px;
122
+ padding: 10px 14px; cursor: pointer;
123
+ color: var(--ink);
124
+ }
125
+ .profile-menu-item:hover { background: var(--row-hover); }
126
+ .profile-menu-item .meta { flex: 1; min-width: 0; }
127
+ .profile-menu-item .name {
128
+ font-weight: 500; font-size: 14px;
129
+ display: flex; align-items: center; gap: 6px;
130
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
131
+ }
132
+ .profile-menu-item .email {
133
+ font-size: 12px; color: var(--muted);
134
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
135
+ }
136
+ .profile-menu-item .selected-check {
137
+ color: var(--pink); font-size: 18px; font-weight: 700;
138
+ margin-left: 4px;
139
+ }
140
+ .role-badge {
141
+ font-size: 10px; font-weight: 600;
142
+ padding: 2px 7px; border-radius: 10px;
143
+ text-transform: uppercase; letter-spacing: .04em;
144
+ flex-shrink: 0;
145
+ }
146
+ .role-badge-host { background: #fef3c7; color: #92400e; }
147
+ .role-badge-sub { background: var(--pink-soft); color: var(--accent-strong); }
148
+ @media (prefers-color-scheme: dark) {
149
+ .role-badge-host { background: #44290c; color: #fcd34d; }
150
+ }
151
+ .profile-menu-divider {
152
+ height: 1px; background: var(--line); margin: 4px 0;
153
+ }
154
+ .profile-menu-footer {
155
+ padding: 10px 14px; font-size: 12px; color: var(--muted);
156
+ }
157
+ .profile-menu-footer a {
158
+ color: var(--accent-strong); cursor: pointer; text-decoration: none;
159
+ }
160
+ .profile-menu-footer a:hover { text-decoration: underline; }
68
161
  .search input {
69
- width: 100%; height: 38px; padding: 0 14px 0 38px;
162
+ width: 100%; height: 38px; padding: 0 38px 0 38px;
70
163
  border: 1px solid var(--line); border-radius: 10px;
71
164
  background: var(--bg-soft); color: var(--ink); outline: none;
72
- transition: border-color .15s;
165
+ transition: border-color .15s, background .15s;
73
166
  }
74
- .search input:focus { border-color: var(--pink); }
167
+ .search input:focus { border-color: var(--pink); background: var(--bg); }
168
+ .search input.has-query { background: var(--bg); border-color: var(--pink-rule); }
75
169
  .search::before {
76
170
  content: "🔍"; position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
77
- font-size: 14px; opacity: .7;
171
+ font-size: 14px; opacity: .7; pointer-events: none;
172
+ }
173
+ .search-clear {
174
+ position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
175
+ width: 22px; height: 22px; border-radius: 50%; border: none;
176
+ background: var(--row-hover); color: var(--ink-soft);
177
+ cursor: pointer; font-size: 14px; line-height: 1;
178
+ display: none;
179
+ }
180
+ .search-clear.show { display: flex; align-items: center; justify-content: center; }
181
+ .search-clear:hover { background: var(--line); color: var(--ink); }
182
+ .search-hint {
183
+ position: absolute; right: 40px; top: 50%; transform: translateY(-50%);
184
+ font-size: 11px; color: var(--muted); pointer-events: none;
185
+ display: none;
186
+ }
187
+ .search-hint.show { display: block; }
188
+ mark.search-hl {
189
+ background: var(--pink-soft); color: var(--accent-strong);
190
+ padding: 0 2px; border-radius: 2px; font-weight: 500;
78
191
  }
79
192
  .btn {
80
193
  height: 36px; padding: 0 14px;
@@ -90,33 +203,15 @@
90
203
  .btn-ghost { background: transparent; border-color: transparent; padding: 0 10px; }
91
204
  .btn-ghost:hover { background: var(--row-hover); }
92
205
 
93
- /* ─── Three-pane layout ─────────────────────────────────────────── */
94
- .main { display: grid; grid-template-columns: 240px 380px 1fr; overflow: hidden; }
95
- @media (max-width: 1100px) { .main { grid-template-columns: 200px 340px 1fr; } }
206
+ /* ─── Two-pane layout (inbox list | message detail) ─────────────── */
207
+ .main { display: grid; grid-template-columns: 420px 1fr; overflow: hidden; }
208
+ @media (max-width: 1100px) { .main { grid-template-columns: 360px 1fr; } }
96
209
  .pane { overflow-y: auto; border-right: 1px solid var(--line); }
97
210
  .pane:last-child { border-right: none; }
98
-
99
- /* ─── Agents sidebar ────────────────────────────────────────────── */
100
- .pane-agents { background: var(--bg-soft); }
101
211
  .pane-header {
102
212
  padding: 12px 14px 6px; font-size: 11px; font-weight: 600;
103
213
  color: var(--muted); text-transform: uppercase; letter-spacing: .05em;
104
214
  }
105
- .agent-row {
106
- display: flex; align-items: center; gap: 8px;
107
- padding: 8px 14px; cursor: pointer; border-left: 3px solid transparent;
108
- color: var(--ink-soft);
109
- }
110
- .agent-row:hover { background: var(--row-hover); }
111
- .agent-row.selected { background: var(--row-selected); border-left-color: var(--pink); color: var(--ink); font-weight: 500; }
112
- .agent-row .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
113
- .agent-row.selected .dot { background: var(--pink); }
114
- .agent-row .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
115
- .agent-row .count {
116
- font-size: 11px; padding: 1px 6px; border-radius: 10px;
117
- background: var(--pink); color: white; min-width: 18px; text-align: center;
118
- }
119
- .agent-row .count[data-zero] { display: none; }
120
215
 
121
216
  /* ─── Inbox list ────────────────────────────────────────────────── */
122
217
  .pane-inbox { background: var(--bg); }
@@ -294,22 +389,31 @@
294
389
  <span>AgenticMail</span>
295
390
  </div>
296
391
  <div class="search">
297
- <input id="search-input" placeholder="Search this inbox — subject, sender, or text" />
392
+ <input id="search-input" placeholder="Search this inbox — subject, sender, body, or from:name" autocomplete="off" />
393
+ <span id="search-hint" class="search-hint"></span>
394
+ <button id="search-clear" class="search-clear" onclick="clearSearch()" title="Clear (Esc)">✕</button>
298
395
  </div>
299
- <button class="btn btn-ghost" onclick="refresh()" title="Refresh">⟳</button>
396
+ <button class="btn btn-ghost" onclick="refresh()" title="Refresh (r)">⟳</button>
300
397
  <button class="btn btn-primary" onclick="openCompose()">Compose</button>
301
- <button class="btn btn-ghost" onclick="signOut()" title="Sign out">⎋</button>
398
+ <button id="profile-btn" class="profile" onclick="toggleProfileMenu(event)">
399
+ <span id="profile-avatar"></span>
400
+ <span class="profile-name" id="profile-name">Loading…</span>
401
+ <span class="profile-caret">▾</span>
402
+ </button>
403
+ <div id="profile-menu" class="profile-menu" onclick="event.stopPropagation()">
404
+ <div class="profile-menu-section">Inbox</div>
405
+ <div id="profile-menu-list"></div>
406
+ <div class="profile-menu-divider"></div>
407
+ <div class="profile-menu-footer">
408
+ <a onclick="signOut()">Sign out</a>
409
+ </div>
410
+ </div>
302
411
  </header>
303
412
 
304
413
  <div class="main">
305
- <aside class="pane pane-agents">
306
- <div class="pane-header">Agents</div>
307
- <div id="agent-list"></div>
308
- </aside>
309
-
310
414
  <section class="pane pane-inbox">
311
415
  <div class="pane-header"><span id="inbox-title">Inbox</span></div>
312
- <div id="inbox-list"><div class="empty">Pick an agent on the left.</div></div>
416
+ <div id="inbox-list"><div class="empty">Loading…</div></div>
313
417
  </section>
314
418
 
315
419
  <article class="pane pane-message">
@@ -421,39 +525,144 @@ async function apiPost(path, body, opts = {}) {
421
525
  async function bootstrap() {
422
526
  try {
423
527
  const data = await apiGet('/accounts');
424
- state.agents = (data.agents ?? data ?? []).sort((a, b) => a.name.localeCompare(b.name));
425
- renderAgents();
426
- // Auto-select first agent on load.
427
- if (state.agents.length > 0) selectAgent(state.agents[0]);
528
+ // Sort: bridge agent first (always pinned to top of the switcher),
529
+ // everyone else alphabetically.
530
+ const all = (data.agents ?? data ?? []);
531
+ all.sort((a, b) => {
532
+ const aBridge = isBridgeAgent(a) ? 0 : 1;
533
+ const bBridge = isBridgeAgent(b) ? 0 : 1;
534
+ if (aBridge !== bBridge) return aBridge - bBridge;
535
+ return a.name.localeCompare(b.name);
536
+ });
537
+ state.agents = all;
538
+ // Auto-select the bridge agent if present, otherwise the first agent.
539
+ // The bridge is the host's natural "main" inbox — it's the address
540
+ // every kickoff email gets CC'd to.
541
+ const initial = state.agents.find(isBridgeAgent) ?? state.agents[0];
542
+ if (initial) await selectAgent(initial);
543
+ renderProfile();
428
544
  populateComposeFrom();
429
545
  subscribeToAllAgents();
546
+ maybeRequestNotificationPermission();
430
547
  } catch (err) {
431
548
  toast(`Failed to load agents: ${err.message}`, true);
432
549
  }
433
550
  }
434
551
 
435
- /* ─── Agents sidebar ─────────────────────────────────────────────── */
436
- function renderAgents() {
437
- const root = document.getElementById('agent-list');
438
- if (state.agents.length === 0) {
439
- root.innerHTML = '<div class="empty">No agents yet. Create one with <code class="mono">agenticmail shell</code>.</div>';
440
- return;
552
+ /* ─── Identity helpers ───────────────────────────────────────────── */
553
+ //
554
+ // The bridge agent (default name "claudecode") is the host's
555
+ // identity inside AgenticMail — the address every Claude Code
556
+ // session uses to send/receive on its own behalf. We surface it
557
+ // differently in the UI: Claude's orange asterisk icon as the
558
+ // avatar, a "Host" badge, and a verified checkmark badge so users
559
+ // can tell at a glance which inbox is the orchestrator's.
560
+ function isBridgeAgent(agent) {
561
+ if (!agent) return false;
562
+ const name = (agent.name ?? '').toLowerCase();
563
+ const role = (agent.role ?? '').toLowerCase();
564
+ return name === 'claudecode' || name === 'claude' || role === 'bridge';
565
+ }
566
+
567
+ // Deterministic color picker for non-bridge avatars — stable per
568
+ // agent name so each teammate keeps the same color across sessions.
569
+ const AVATAR_PALETTE = [
570
+ '#ec4899', // pink
571
+ '#8b5cf6', // violet
572
+ '#3b82f6', // blue
573
+ '#06b6d4', // cyan
574
+ '#10b981', // emerald
575
+ '#f59e0b', // amber
576
+ '#ef4444', // red
577
+ '#84cc16', // lime
578
+ ];
579
+ function avatarColorFor(name) {
580
+ let hash = 0;
581
+ for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
582
+ return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
583
+ }
584
+
585
+ // Inline SVG approximating Claude's orange asterisk mark. We render
586
+ // the bridge agent's avatar with this so the host inbox is visually
587
+ // recognisable at a glance.
588
+ const CLAUDE_MARK_SVG = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
589
+ <path d="M12 1.5 L13.2 8.6 L19.5 6.6 L15 12 L19.5 17.4 L13.2 15.4 L12 22.5 L10.8 15.4 L4.5 17.4 L9 12 L4.5 6.6 L10.8 8.6 Z"/>
590
+ </svg>`;
591
+
592
+ function avatarHtml(agent, size = '') {
593
+ const cls = `avatar ${size}`.trim();
594
+ if (isBridgeAgent(agent)) {
595
+ return `<span class="${cls} avatar-host">${CLAUDE_MARK_SVG}<span class="avatar-check">✓</span></span>`;
441
596
  }
442
- root.innerHTML = state.agents.map(a => `
443
- <div class="agent-row ${state.selectedAgent?.id === a.id ? 'selected' : ''}" data-id="${a.id}">
444
- <span class="dot"></span>
445
- <span class="name">${escapeHtml(a.name)}</span>
446
- <span class="count" data-id="${a.id}" data-zero>0</span>
447
- </div>
448
- `).join('');
449
- root.querySelectorAll('.agent-row').forEach(el => {
597
+ const initial = (agent.name ?? '?').slice(0, 1).toUpperCase();
598
+ const color = avatarColorFor(agent.name ?? '');
599
+ return `<span class="${cls}" style="background:${color}">${escapeHtml(initial)}</span>`;
600
+ }
601
+
602
+ /* ─── Profile switcher (top-right Gmail-style dropdown) ──────────── */
603
+ function renderProfile() {
604
+ const a = state.selectedAgent;
605
+ // Compute total unread across non-selected agents → red dot on
606
+ // the profile button as a "you have new mail elsewhere" cue.
607
+ const totalOtherUnread = Object.entries(state.unread ?? {})
608
+ .filter(([id]) => id !== a?.id)
609
+ .reduce((sum, [, n]) => sum + n, 0);
610
+
611
+ document.getElementById('profile-avatar').innerHTML = a
612
+ ? avatarHtml(a) + (totalOtherUnread > 0 ? '<span class="avatar-check" style="background:#dc2626">●</span>' : '')
613
+ : '';
614
+ document.getElementById('profile-name').textContent = a ? a.name : '—';
615
+
616
+ const list = document.getElementById('profile-menu-list');
617
+ list.innerHTML = state.agents.map(agent => {
618
+ const selected = state.selectedAgent?.id === agent.id;
619
+ const badge = isBridgeAgent(agent)
620
+ ? '<span class="role-badge role-badge-host">Host</span>'
621
+ : '<span class="role-badge role-badge-sub">Sub-agent</span>';
622
+ const check = selected ? '<span class="selected-check">✓</span>' : '';
623
+ const unread = state.unread?.[agent.id] ?? 0;
624
+ const unreadDot = unread > 0
625
+ ? `<span class="role-badge" style="background:var(--pink);color:white;">${unread} new</span>`
626
+ : '';
627
+ return `
628
+ <div class="profile-menu-item" data-id="${agent.id}">
629
+ ${avatarHtml(agent, 'avatar-lg')}
630
+ <div class="meta">
631
+ <div class="name">${escapeHtml(agent.name)} ${badge} ${unreadDot}</div>
632
+ <div class="email">${escapeHtml(agent.email ?? '')}</div>
633
+ </div>
634
+ ${check}
635
+ </div>
636
+ `;
637
+ }).join('');
638
+ list.querySelectorAll('.profile-menu-item').forEach(el => {
450
639
  el.addEventListener('click', () => {
451
640
  const id = el.dataset.id;
452
641
  const agent = state.agents.find(a => a.id === id);
453
- if (agent) selectAgent(agent);
642
+ if (agent && agent.id !== state.selectedAgent?.id) {
643
+ selectAgent(agent);
644
+ }
645
+ closeProfileMenu();
454
646
  });
455
647
  });
456
648
  }
649
+
650
+ function toggleProfileMenu(e) {
651
+ if (e) e.stopPropagation();
652
+ const menu = document.getElementById('profile-menu');
653
+ menu.classList.toggle('open');
654
+ }
655
+ function closeProfileMenu() {
656
+ document.getElementById('profile-menu').classList.remove('open');
657
+ }
658
+ // Close on outside click.
659
+ document.addEventListener('click', e => {
660
+ const menu = document.getElementById('profile-menu');
661
+ const btn = document.getElementById('profile-btn');
662
+ if (!menu || !btn) return;
663
+ if (!menu.contains(e.target) && !btn.contains(e.target)) closeProfileMenu();
664
+ });
665
+
457
666
  async function selectAgent(agent) {
458
667
  state.selectedAgent = agent;
459
668
  state.selectedUid = null;
@@ -462,10 +671,8 @@ async function selectAgent(agent) {
462
671
  document.querySelector('.pane-message #message-view').innerHTML = `
463
672
  <div class="msg-empty"><div class="big">🎀</div><div>Select an email to read it here.</div></div>
464
673
  `;
465
- renderAgents();
674
+ renderProfile();
466
675
  await loadInbox(agent);
467
- // Clear unread count badge on open.
468
- setAgentUnread(agent.id, 0);
469
676
  }
470
677
 
471
678
  /* ─── Inbox list ─────────────────────────────────────────────────── */
@@ -479,18 +686,80 @@ async function loadInbox(agent) {
479
686
  document.getElementById('inbox-list').innerHTML = `<div class="empty">Failed to load: ${escapeHtml(err.message)}</div>`;
480
687
  }
481
688
  }
689
+ // Parse a Gmail-style query into structured filters + free-text.
690
+ // Supports `from:`, `subject:` operators (case-insensitive). Anything
691
+ // outside an operator is free-text matched against subject + body
692
+ // + sender (case-insensitive substring). Quotes group multi-word values.
693
+ //
694
+ // "from:vesper" → only mail FROM vesper
695
+ // "subject:audit" → only mail with "audit" in the subject
696
+ // "audit from:vesper" → both must match
697
+ // "build small game" → free-text match anywhere
698
+ function parseSearch(query) {
699
+ const filters = { from: '', subject: '', text: '' };
700
+ const remaining = [];
701
+ const tokenRe = /(\w+):("([^"]*)"|(\S+))|("([^"]*)"|(\S+))/g;
702
+ let m;
703
+ while ((m = tokenRe.exec(query)) !== null) {
704
+ const op = m[1]?.toLowerCase();
705
+ const opVal = (m[3] ?? m[4] ?? '').toLowerCase();
706
+ const free = (m[6] ?? m[7] ?? '').toLowerCase();
707
+ if (op === 'from') filters.from = opVal;
708
+ else if (op === 'subject') filters.subject = opVal;
709
+ else if (free) remaining.push(free);
710
+ }
711
+ filters.text = remaining.join(' ');
712
+ return filters;
713
+ }
714
+
715
+ function matchesSearch(msg, filters) {
716
+ const fromAddr = (msg.from?.[0]?.address ?? '').toLowerCase();
717
+ const fromName = (msg.from?.[0]?.name ?? '').toLowerCase();
718
+ const subject = (msg.subject ?? '').toLowerCase();
719
+ const preview = (msg.preview ?? '').toLowerCase();
720
+ if (filters.from && !fromAddr.includes(filters.from) && !fromName.includes(filters.from)) return false;
721
+ if (filters.subject && !subject.includes(filters.subject)) return false;
722
+ if (filters.text) {
723
+ const hay = `${fromAddr} ${fromName} ${subject} ${preview}`;
724
+ if (!hay.includes(filters.text)) return false;
725
+ }
726
+ return true;
727
+ }
728
+
729
+ // Wrap matches in <mark> for visual highlight. Escapes input first.
730
+ function highlightTerm(text, term) {
731
+ const safe = escapeHtml(text ?? '');
732
+ if (!term) return safe;
733
+ // Build a case-insensitive regex but escape the term for safety.
734
+ const escaped = term.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
735
+ return safe.replace(new RegExp(`(${escaped})`, 'ig'), '<mark class="search-hl">$1</mark>');
736
+ }
737
+
482
738
  function renderInbox() {
483
739
  const root = document.getElementById('inbox-list');
484
- const q = state.searchQuery.toLowerCase();
485
- const filtered = q
486
- ? state.inboxMessages.filter(m =>
487
- (m.subject ?? '').toLowerCase().includes(q) ||
488
- (m.from?.[0]?.address ?? '').toLowerCase().includes(q) ||
489
- (m.from?.[0]?.name ?? '').toLowerCase().includes(q) ||
490
- (m.preview ?? '').toLowerCase().includes(q))
740
+ const q = state.searchQuery.trim();
741
+ const filters = q ? parseSearch(q) : null;
742
+ const filtered = filters
743
+ ? state.inboxMessages.filter(m => matchesSearch(m, filters))
491
744
  : state.inboxMessages;
745
+
746
+ // Pick the term used for visual highlighting — the most "primary"
747
+ // user intent. `subject:` wins over `from:` wins over free text.
748
+ const hlTerm = filters?.subject || filters?.from || filters?.text || '';
749
+
750
+ // Hint shows match count when filtering.
751
+ const hintEl = document.getElementById('search-hint');
752
+ if (q) {
753
+ hintEl.textContent = `${filtered.length}/${state.inboxMessages.length}`;
754
+ hintEl.classList.add('show');
755
+ } else {
756
+ hintEl.classList.remove('show');
757
+ }
758
+
492
759
  if (filtered.length === 0) {
493
- root.innerHTML = '<div class="empty">Inbox is empty.</div>';
760
+ root.innerHTML = q
761
+ ? `<div class="empty">No messages match "${escapeHtml(q)}".<br><br><a onclick="clearSearch()" style="cursor:pointer;color:var(--accent-strong)">Clear search</a></div>`
762
+ : '<div class="empty">Inbox is empty.</div>';
494
763
  return;
495
764
  }
496
765
  root.innerHTML = filtered.map(m => {
@@ -502,11 +771,11 @@ function renderInbox() {
502
771
  return `
503
772
  <div class="inbox-row ${unread ? 'unread' : ''} ${state.selectedUid === m.uid ? 'selected' : ''}" data-uid="${m.uid}">
504
773
  <div class="inbox-from">
505
- <span class="name">${escapeHtml(fromName)}</span>
774
+ <span class="name">${highlightTerm(fromName, hlTerm)}</span>
506
775
  <span class="date">${escapeHtml(date)}</span>
507
776
  </div>
508
- <div class="subject">${escapeHtml(subject)}</div>
509
- <div class="preview">${escapeHtml((m.preview ?? '').slice(0, 120))}</div>
777
+ <div class="subject">${highlightTerm(subject, hlTerm)}</div>
778
+ <div class="preview">${highlightTerm((m.preview ?? '').slice(0, 120), hlTerm)}</div>
510
779
  </div>
511
780
  `;
512
781
  }).join('');
@@ -515,6 +784,17 @@ function renderInbox() {
515
784
  });
516
785
  }
517
786
 
787
+ function clearSearch() {
788
+ const input = document.getElementById('search-input');
789
+ input.value = '';
790
+ state.searchQuery = '';
791
+ input.classList.remove('has-query');
792
+ document.getElementById('search-clear').classList.remove('show');
793
+ document.getElementById('search-hint').classList.remove('show');
794
+ renderInbox();
795
+ input.focus();
796
+ }
797
+
518
798
  /* ─── Message view ───────────────────────────────────────────────── */
519
799
  async function openMessage(uid) {
520
800
  if (!state.selectedAgent) return;
@@ -786,12 +1066,39 @@ async function refresh() {
786
1066
  }
787
1067
 
788
1068
  /* ─── Search ─────────────────────────────────────────────────────── */
1069
+ //
1070
+ // Gmail-style operators (from:, subject:) + free-text. Debounced so
1071
+ // typing fast doesn't re-render on every keystroke. Esc clears.
1072
+ let searchDebounce = null;
789
1073
  document.getElementById('search-input').addEventListener('input', e => {
790
- state.searchQuery = e.target.value;
791
- renderInbox();
1074
+ const v = e.target.value;
1075
+ e.target.classList.toggle('has-query', v.length > 0);
1076
+ document.getElementById('search-clear').classList.toggle('show', v.length > 0);
1077
+ if (searchDebounce) clearTimeout(searchDebounce);
1078
+ searchDebounce = setTimeout(() => {
1079
+ state.searchQuery = v;
1080
+ renderInbox();
1081
+ }, 80);
1082
+ });
1083
+ document.getElementById('search-input').addEventListener('keydown', e => {
1084
+ if (e.key === 'Escape') {
1085
+ e.preventDefault();
1086
+ clearSearch();
1087
+ }
792
1088
  });
793
1089
 
794
- /* ─── Real-time SSE (per-agent) ─────────────────────────────────── */
1090
+ /* ─── Real-time SSE — Gmail-style live updates ──────────────────── */
1091
+ //
1092
+ // Every agent gets its own SSE subscription. New-mail events are
1093
+ // fanned out to three places:
1094
+ //
1095
+ // 1. Inbox list — if the agent is the one currently open, prepend
1096
+ // the new message without a full reload (instant "ping" feel).
1097
+ // 2. Profile dropdown — bump the per-agent unread badge so the user
1098
+ // sees other inboxes activity at a glance.
1099
+ // 3. Browser notification — fires a system notification (with
1100
+ // permission) so you get pinged even when the tab is in the
1101
+ // background. Click the notification to jump to the message.
795
1102
  function subscribeToAllAgents() {
796
1103
  // Tear down any previous controllers.
797
1104
  for (const c of state.sseControllers) { try { c.abort(); } catch {} }
@@ -820,26 +1127,91 @@ function subscribeToAllAgents() {
820
1127
  }
821
1128
  }
822
1129
  }
823
- }).catch(() => { /* connection dropped */ });
1130
+ }).catch(() => { /* connection dropped — browser will retry on user action */ });
824
1131
  }
825
1132
  }
826
- function handleSseEvent(agent, event) {
1133
+
1134
+ async function handleSseEvent(agent, event) {
827
1135
  if (event.type !== 'new') return;
828
- // Bump unread badge on the agent row.
829
- const el = document.querySelector(`.count[data-id="${agent.id}"]`);
830
- if (el) {
831
- const n = parseInt(el.textContent || '0', 10) + 1;
832
- el.textContent = String(n);
833
- el.removeAttribute('data-zero');
834
- }
835
- // If this agent's inbox is open, reload it.
836
- if (state.selectedAgent?.id === agent.id) loadInbox(agent);
837
- }
838
- function setAgentUnread(agentId, n) {
839
- const el = document.querySelector(`.count[data-id="${agentId}"]`);
1136
+
1137
+ // 1. Bump the unread counter for this agent. Stored on state so
1138
+ // the profile dropdown picks it up next render.
1139
+ state.unread = state.unread ?? {};
1140
+ state.unread[agent.id] = (state.unread[agent.id] ?? 0) + 1;
1141
+ renderProfile();
1142
+
1143
+ // 2. If this agent's inbox is currently open reload inbox in
1144
+ // place. We use loadInbox rather than a manual prepend because
1145
+ // the API normalises message ordering, pagination, and IMAP UID
1146
+ // resolution; replicating that client-side would drift.
1147
+ const isOpen = state.selectedAgent?.id === agent.id;
1148
+ if (isOpen) {
1149
+ await loadInbox(agent);
1150
+ flashInboxArrival();
1151
+ state.unread[agent.id] = 0; // user is looking — clear the badge
1152
+ renderProfile();
1153
+ }
1154
+
1155
+ // 3. Browser notification (only if the user granted permission).
1156
+ fireBrowserNotification(agent, event, isOpen);
1157
+
1158
+ // 4. Sound a soft toast for in-app awareness regardless of permission.
1159
+ if (!isOpen) {
1160
+ const fromAddr = event.from?.address ?? event.from ?? 'someone';
1161
+ const subject = event.subject ?? '(no subject)';
1162
+ toast(`${agent.name}: ${subject} — from ${fromAddr}`);
1163
+ }
1164
+ }
1165
+
1166
+ // Small green flash animation at the top of the inbox list to draw
1167
+ // the eye when a new message arrives in the currently-open inbox.
1168
+ function flashInboxArrival() {
1169
+ const el = document.getElementById('inbox-list');
840
1170
  if (!el) return;
841
- el.textContent = String(n);
842
- if (n === 0) el.setAttribute('data-zero', ''); else el.removeAttribute('data-zero');
1171
+ el.style.transition = 'background-color .15s';
1172
+ el.style.backgroundColor = 'rgba(34, 197, 94, 0.08)';
1173
+ setTimeout(() => { el.style.backgroundColor = ''; }, 600);
1174
+ }
1175
+
1176
+ // ─── Browser notifications ───────────────────────────────────────
1177
+ // Ask once on first load; remember the answer in localStorage so we
1178
+ // don't pester the user on every refresh. If the user denied, we
1179
+ // silently fall back to the in-app toast only.
1180
+ function maybeRequestNotificationPermission() {
1181
+ if (!('Notification' in window)) return;
1182
+ if (Notification.permission === 'granted' || Notification.permission === 'denied') return;
1183
+ const asked = localStorage.getItem('agenticmail.notif.asked');
1184
+ if (asked) return;
1185
+ // Defer the ask by a couple of seconds so it doesn't pop on load.
1186
+ setTimeout(() => {
1187
+ Notification.requestPermission().finally(() => {
1188
+ localStorage.setItem('agenticmail.notif.asked', '1');
1189
+ });
1190
+ }, 2000);
1191
+ }
1192
+ function fireBrowserNotification(agent, event, isOpen) {
1193
+ if (!('Notification' in window) || Notification.permission !== 'granted') return;
1194
+ // Don't ping if the user is already looking at this inbox AND the
1195
+ // tab is visible — they can see the flash themselves.
1196
+ if (isOpen && document.visibilityState === 'visible') return;
1197
+ const fromAddr = event.from?.address ?? event.from ?? 'unknown sender';
1198
+ const subject = event.subject ?? '(no subject)';
1199
+ const body = `${agent.name} — from ${fromAddr}`;
1200
+ try {
1201
+ const n = new Notification(subject, {
1202
+ body,
1203
+ icon: '/favicon.ico',
1204
+ tag: `agenticmail-${agent.id}-${event.uid}`,
1205
+ silent: false,
1206
+ });
1207
+ n.onclick = () => {
1208
+ window.focus();
1209
+ // Switch to that agent's inbox and open the message.
1210
+ if (state.selectedAgent?.id !== agent.id) selectAgent(agent);
1211
+ if (event.uid) openMessage(event.uid);
1212
+ n.close();
1213
+ };
1214
+ } catch { /* notification failed — user still gets the in-app toast */ }
843
1215
  }
844
1216
 
845
1217
  /* ─── Toast ──────────────────────────────────────────────────────── */
@@ -859,16 +1231,46 @@ function escapeHtml(s) {
859
1231
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
860
1232
  }
861
1233
 
862
- /* ─── Keyboard shortcuts ─────────────────────────────────────────── */
1234
+ /* ─── Keyboard shortcuts (Gmail-style) ───────────────────────────── */
1235
+ // r refresh current inbox
1236
+ // c compose new
1237
+ // / focus the search box
1238
+ // ? show help (not implemented yet — reserved)
863
1239
  document.addEventListener('keydown', e => {
864
1240
  if (document.getElementById('compose-bg').style.display !== 'none') return;
865
1241
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
866
- if (e.key === 'r') { refresh(); }
867
- if (e.key === 'c') { openCompose(); }
1242
+ if (e.key === 'r') refresh();
1243
+ if (e.key === 'c') openCompose();
1244
+ if (e.key === '/') {
1245
+ e.preventDefault();
1246
+ document.getElementById('search-input').focus();
1247
+ }
868
1248
  });
869
1249
 
870
1250
  /* ─── Boot ───────────────────────────────────────────────────────── */
871
1251
  (() => {
1252
+ // 1. Auto-sign-in via `?key=...` URL parameter. The CLI's
1253
+ // `agenticmail web` command passes the master key on the URL so
1254
+ // the user lands signed in without a paste step. We accept it
1255
+ // once, persist to localStorage, then strip the param out of the
1256
+ // address bar via history.replaceState so it doesn't end up in
1257
+ // browser history, Referer headers, or accidental screen shares.
1258
+ // Safe because the URL is loopback-only and the key belongs to
1259
+ // the same user who's looking at the screen.
1260
+ try {
1261
+ const params = new URL(location.href).searchParams;
1262
+ const urlKey = params.get('key');
1263
+ if (urlKey) {
1264
+ localStorage.setItem('agenticmail.masterKey', urlKey);
1265
+ // Clean the URL without reloading.
1266
+ const clean = location.pathname + location.hash;
1267
+ history.replaceState({}, '', clean);
1268
+ }
1269
+ } catch { /* malformed URL — fall through to localStorage / auth gate */ }
1270
+
1271
+ // 2. Use whatever's now in localStorage (just-stored URL key, or
1272
+ // previous session's stored key). If neither, the auth gate
1273
+ // stays up and asks for manual entry.
872
1274
  const saved = localStorage.getItem('agenticmail.masterKey');
873
1275
  if (saved) {
874
1276
  state.masterKey = saved;