@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 +8 -0
- package/package.json +1 -1
- package/public/index.html +491 -89
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
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
|
|
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
|
-
/* ───
|
|
94
|
-
.main { display: grid; grid-template-columns:
|
|
95
|
-
@media (max-width: 1100px) { .main { grid-template-columns:
|
|
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
|
|
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
|
|
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">
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
/* ───
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
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 =
|
|
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">${
|
|
774
|
+
<span class="name">${highlightTerm(fromName, hlTerm)}</span>
|
|
506
775
|
<span class="date">${escapeHtml(date)}</span>
|
|
507
776
|
</div>
|
|
508
|
-
<div class="subject">${
|
|
509
|
-
<div class="preview">${
|
|
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
|
-
|
|
791
|
-
|
|
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
|
|
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
|
-
|
|
1133
|
+
|
|
1134
|
+
async function handleSseEvent(agent, event) {
|
|
827
1135
|
if (event.type !== 'new') return;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
// If this agent's inbox is open
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const
|
|
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.
|
|
842
|
-
|
|
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, '"').replace(/'/g, ''');
|
|
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')
|
|
867
|
-
if (e.key === 'c')
|
|
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;
|