@agenticmail/api 0.7.8 → 0.7.9

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/public/index.html CHANGED
@@ -3,1282 +3,96 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>🎀 AgenticMail</title>
7
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🎀%3C/text%3E%3C/svg%3E" />
8
- <style>
9
- /* ─── Brand & palette ───────────────────────────────────────────── */
10
- :root {
11
- --pink: #ec4899;
12
- --pink-soft: #fdf2f8;
13
- --pink-rule: #fbcfe8;
14
- --ink: #111827;
15
- --ink-soft: #374151;
16
- --muted: #6b7280;
17
- --line: #e5e7eb;
18
- --bg: #ffffff;
19
- --bg-soft: #f9fafb;
20
- --row-hover: #f3f4f6;
21
- --row-selected: var(--pink-soft);
22
- --accent-strong: #be185d;
23
- --code-bg: #f3f4f6;
24
- --code-fg: #b91c1c;
25
- --unread-dot: var(--pink);
26
- }
27
- @media (prefers-color-scheme: dark) {
28
- :root {
29
- --pink-soft: #2a1726;
30
- --pink-rule: #5b1d44;
31
- --ink: #f3f4f6;
32
- --ink-soft: #d1d5db;
33
- --muted: #9ca3af;
34
- --line: #2d2d33;
35
- --bg: #0e0e10;
36
- --bg-soft: #18181b;
37
- --row-hover: #1f1f23;
38
- --row-selected: #2a1726;
39
- --accent-strong: #f9a8d4;
40
- --code-bg: #1f1f23;
41
- --code-fg: #fb7185;
42
- }
43
- }
44
- * { box-sizing: border-box; }
45
- html, body { height: 100%; margin: 0; }
46
- body {
47
- font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
48
- color: var(--ink); background: var(--bg);
49
- overflow: hidden;
50
- }
51
- button { font: inherit; cursor: pointer; }
52
- a { color: var(--accent-strong); }
53
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
54
-
55
- /* ─── App shell ─────────────────────────────────────────────────── */
56
- .app { display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
57
- .topbar {
58
- display: flex; align-items: center; gap: 16px;
59
- padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--bg);
60
- position: relative;
61
- }
62
- .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
63
- .brand-bow { font-size: 22px; }
64
- .brand-name { color: var(--pink); }
65
- .search {
66
- flex: 1; max-width: 720px;
67
- position: relative;
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; }
161
- .search input {
162
- width: 100%; height: 38px; padding: 0 38px 0 38px;
163
- border: 1px solid var(--line); border-radius: 10px;
164
- background: var(--bg-soft); color: var(--ink); outline: none;
165
- transition: border-color .15s, background .15s;
166
- }
167
- .search input:focus { border-color: var(--pink); background: var(--bg); }
168
- .search input.has-query { background: var(--bg); border-color: var(--pink-rule); }
169
- .search::before {
170
- content: "🔍"; position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
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;
191
- }
192
- .btn {
193
- height: 36px; padding: 0 14px;
194
- border: 1px solid var(--line); border-radius: 8px;
195
- background: var(--bg-soft); color: var(--ink);
196
- display: inline-flex; align-items: center; gap: 6px;
197
- }
198
- .btn:hover { background: var(--row-hover); }
199
- .btn-primary {
200
- background: var(--pink); color: white; border-color: var(--pink);
201
- }
202
- .btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
203
- .btn-ghost { background: transparent; border-color: transparent; padding: 0 10px; }
204
- .btn-ghost:hover { background: var(--row-hover); }
205
-
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; } }
209
- .pane { overflow-y: auto; border-right: 1px solid var(--line); }
210
- .pane:last-child { border-right: none; }
211
- .pane-header {
212
- padding: 12px 14px 6px; font-size: 11px; font-weight: 600;
213
- color: var(--muted); text-transform: uppercase; letter-spacing: .05em;
214
- }
215
-
216
- /* ─── Inbox list ────────────────────────────────────────────────── */
217
- .pane-inbox { background: var(--bg); }
218
- .inbox-row {
219
- padding: 12px 16px; cursor: pointer; border-bottom: 1px solid var(--line);
220
- display: grid; grid-template-rows: auto auto auto; gap: 2px;
221
- }
222
- .inbox-row:hover { background: var(--row-hover); }
223
- .inbox-row.selected { background: var(--row-selected); }
224
- .inbox-row.unread .subject { font-weight: 600; color: var(--ink); }
225
- .inbox-row.unread::before {
226
- content: ""; width: 8px; height: 8px; border-radius: 50%;
227
- background: var(--unread-dot); position: absolute; margin-left: -12px; margin-top: 8px;
228
- }
229
- .inbox-row { position: relative; }
230
- .inbox-from { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
231
- .inbox-from .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
232
- .inbox-from .date { font-variant-numeric: tabular-nums; flex-shrink: 0; }
233
- .subject {
234
- font-size: 14px; color: var(--ink-soft);
235
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
236
- }
237
- .preview {
238
- font-size: 12px; color: var(--muted);
239
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
240
- }
241
- .empty { padding: 24px 16px; text-align: center; color: var(--muted); font-size: 13px; }
242
-
243
- /* ─── Message detail ────────────────────────────────────────────── */
244
- .pane-message { background: var(--bg); padding: 0; }
245
- .msg-header {
246
- padding: 20px 28px 12px;
247
- border-bottom: 1px solid var(--pink-rule);
248
- background: linear-gradient(to bottom, var(--pink-soft) 0%, transparent 100%);
249
- }
250
- .msg-subject { font-size: 20px; font-weight: 600; margin: 0 0 12px; color: var(--ink); }
251
- .msg-meta { font-size: 13px; color: var(--ink-soft); display: grid; grid-template-columns: 60px 1fr; gap: 4px 12px; }
252
- .msg-meta .label { color: var(--muted); }
253
- .msg-meta .date { color: var(--accent-strong); font-variant-numeric: tabular-nums; }
254
- .msg-actions { display: flex; gap: 8px; margin-top: 14px; }
255
- .msg-body {
256
- padding: 20px 28px;
257
- font-size: 14px; line-height: 1.65; color: var(--ink);
258
- white-space: pre-wrap; word-wrap: break-word;
259
- }
260
- .msg-body h1, .msg-body h2, .msg-body h3 {
261
- color: var(--pink); margin: 1.2em 0 .4em;
262
- }
263
- .msg-body h1 { font-size: 1.4em; }
264
- .msg-body h2 { font-size: 1.2em; }
265
- .msg-body h3 { font-size: 1.05em; }
266
- .msg-body code {
267
- background: var(--code-bg); color: var(--code-fg);
268
- padding: 1px 5px; border-radius: 4px; font-size: 13px;
269
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
270
- }
271
- .msg-body pre {
272
- background: var(--code-bg); padding: 12px 14px; border-radius: 8px;
273
- overflow-x: auto; font-size: 13px;
274
- }
275
- .msg-body pre code { background: transparent; padding: 0; color: var(--ink); }
276
- .msg-body strong { color: var(--ink); }
277
- .msg-body em { color: var(--ink-soft); }
278
- .msg-body blockquote {
279
- border-left: 3px solid var(--pink-rule);
280
- margin: .8em 0; padding: .2em 0 .2em 12px;
281
- color: var(--muted); white-space: pre-wrap;
282
- }
283
- .msg-body blockquote blockquote { border-left-color: #c084fc; }
284
- .msg-body blockquote blockquote blockquote { border-left-color: #f59e0b; }
285
- .msg-body table { border-collapse: collapse; margin: .5em 0; }
286
- .msg-body th, .msg-body td { border: 1px solid var(--line); padding: 6px 10px; }
287
- .msg-body th { background: var(--bg-soft); font-weight: 600; }
288
- .msg-body ul, .msg-body ol { padding-left: 24px; }
289
- .msg-body hr { border: none; border-top: 1px solid var(--line); margin: 1.2em 0; }
290
- .msg-body a { color: var(--accent-strong); }
291
- .msg-body input[type=checkbox] { margin-right: 6px; }
292
- .msg-attachments {
293
- padding: 12px 28px; border-top: 1px solid var(--line);
294
- display: flex; flex-wrap: wrap; gap: 8px;
295
- }
296
- .msg-attachment {
297
- display: inline-flex; align-items: center; gap: 6px;
298
- padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px;
299
- font-size: 12px; color: var(--ink-soft);
300
- }
301
- .msg-empty { padding: 80px 28px; text-align: center; color: var(--muted); }
302
- .msg-empty .big { font-size: 48px; margin-bottom: 12px; opacity: .4; }
303
-
304
- /* ─── Auth gate ─────────────────────────────────────────────────── */
305
- .auth {
306
- position: fixed; inset: 0; background: var(--bg);
307
- display: flex; align-items: center; justify-content: center;
308
- z-index: 10;
309
- }
310
- .auth-card {
311
- width: 380px; padding: 28px;
312
- border: 1px solid var(--line); border-radius: 14px;
313
- background: var(--bg-soft);
314
- }
315
- .auth-card h1 { margin: 0 0 6px; font-size: 22px; color: var(--pink); }
316
- .auth-card p { margin: 0 0 16px; color: var(--muted); font-size: 13px; }
317
- .auth-card input {
318
- width: 100%; height: 40px; padding: 0 12px; margin-bottom: 12px;
319
- border: 1px solid var(--line); border-radius: 8px;
320
- background: var(--bg); color: var(--ink); outline: none;
321
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
322
- }
323
- .auth-card input:focus { border-color: var(--pink); }
324
- .auth-card .btn-primary { width: 100%; height: 40px; justify-content: center; }
325
- .auth-err { color: #dc2626; font-size: 12px; margin-bottom: 12px; }
326
-
327
- /* ─── Compose modal ─────────────────────────────────────────────── */
328
- .modal-bg {
329
- position: fixed; inset: 0; background: rgba(0,0,0,.4);
330
- display: flex; align-items: center; justify-content: center;
331
- z-index: 20;
332
- }
333
- .modal {
334
- width: 640px; max-height: 80vh; overflow: hidden;
335
- background: var(--bg); border-radius: 14px;
336
- border: 1px solid var(--line);
337
- display: grid; grid-template-rows: auto 1fr auto;
338
- }
339
- .modal-head {
340
- padding: 14px 18px; border-bottom: 1px solid var(--line);
341
- display: flex; justify-content: space-between; align-items: center;
342
- font-weight: 600; color: var(--pink);
343
- }
344
- .modal-body { padding: 14px 18px; overflow-y: auto; }
345
- .modal-foot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; }
346
- .field { display: grid; grid-template-columns: 80px 1fr; gap: 8px; margin-bottom: 10px; align-items: center; }
347
- .field label { color: var(--muted); font-size: 12px; }
348
- .field input, .field textarea {
349
- width: 100%; padding: 8px 10px;
350
- border: 1px solid var(--line); border-radius: 6px;
351
- background: var(--bg); color: var(--ink); outline: none;
352
- font: inherit;
353
- }
354
- .field textarea { resize: vertical; min-height: 200px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
355
- .field input:focus, .field textarea:focus { border-color: var(--pink); }
356
- .hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
357
-
358
- /* ─── Toast / status ────────────────────────────────────────────── */
359
- .toast {
360
- position: fixed; bottom: 20px; right: 20px; z-index: 30;
361
- padding: 10px 16px; border-radius: 8px;
362
- background: var(--ink); color: white; font-size: 13px;
363
- opacity: 0; transform: translateY(8px); transition: all .2s;
364
- pointer-events: none;
365
- }
366
- .toast.show { opacity: 1; transform: translateY(0); }
367
- .toast.error { background: #dc2626; }
368
- </style>
6
+ <title>AgenticMail</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ec4899'%3E%3Cpath d='M12 12c-2-3-5-5-8-5-1 0-2 1-2 2s1 2 2 2c2 0 4 1 5 2-1 1-3 2-5 2-1 0-2 1-2 2s1 2 2 2c3 0 6-2 8-5 2 3 5 5 8 5 1 0 2-1 2-2s-1-2-2-2c-2 0-4-1-5-2 1-1 3-2 5-2 1 0 2-1 2-2s-1-2-2-2c-3 0-6 2-8 5zm0 2a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z'/%3E%3C/svg%3E" />
8
+ <link rel="stylesheet" href="styles.css" />
369
9
  </head>
370
10
  <body>
371
11
 
372
12
  <!-- ─── Auth gate (shown until master key is entered) ────────────── -->
373
- <div id="auth" class="auth">
13
+ <div id="auth" class="auth-gate">
374
14
  <div class="auth-card">
375
- <h1>🎀 AgenticMail</h1>
15
+ <h1><span data-icon="bow" data-icon-size="28"></span> AgenticMail</h1>
376
16
  <p>Enter your master key to sign in. The key is stored locally in your browser; we never send it anywhere except to <span class="mono" id="auth-api-url"></span>.</p>
377
17
  <div id="auth-err" class="auth-err" style="display:none"></div>
378
18
  <input id="auth-key" type="password" placeholder="mk_…" autocomplete="off" autofocus />
379
- <button class="btn btn-primary" onclick="signIn()">Sign in</button>
380
- <p class="hint" style="margin-top:14px">Don't have the master key? Run <code class="mono">cat ~/.agenticmail/config.json</code> in your terminal.</p>
19
+ <button class="submit" id="auth-submit">Sign in</button>
20
+ <p class="mono" style="font-size:12px;color:var(--muted);margin-top:14px">Don't have the master key? Run <code>cat ~/.agenticmail/config.json</code> in your terminal.</p>
381
21
  </div>
382
22
  </div>
383
23
 
384
24
  <!-- ─── App shell ─────────────────────────────────────────────────── -->
385
25
  <div class="app" id="app" style="display:none">
26
+ <!-- Top bar -->
386
27
  <header class="topbar">
28
+ <button class="menu-btn" id="menu-btn" title="Menu" data-icon="menu"></button>
387
29
  <div class="brand">
388
- <span class="brand-bow">🎀</span>
389
- <span>AgenticMail</span>
30
+ <span class="brand-bow" data-icon="bow" data-icon-size="28"></span>
31
+ <span class="brand-name">AgenticMail</span>
390
32
  </div>
391
- <div class="search">
392
- <input id="search-input" placeholder="Search this inbox — subject, sender, body, or from:name" autocomplete="off" />
33
+ <div class="search-container">
34
+ <span class="search-icon" data-icon="search"></span>
35
+ <input id="search-input" class="search-input" placeholder="Search mail" autocomplete="off" />
393
36
  <span id="search-hint" class="search-hint"></span>
394
- <button id="search-clear" class="search-clear" onclick="clearSearch()" title="Clear (Esc)">✕</button>
37
+ <button id="search-clear" class="search-clear-btn" title="Clear (Esc)" data-icon="close"></button>
395
38
  </div>
396
- <button class="btn btn-ghost" onclick="refresh()" title="Refresh (r)">⟳</button>
397
- <button class="btn btn-primary" onclick="openCompose()">Compose</button>
398
- <button id="profile-btn" class="profile" onclick="toggleProfileMenu(event)">
39
+ <div class="topbar-spacer"></div>
40
+ <button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
41
+ <button id="profile-btn" class="profile-trigger" title="Account">
399
42
  <span id="profile-avatar"></span>
400
- <span class="profile-name" id="profile-name">Loading…</span>
401
- <span class="profile-caret">▾</span>
402
43
  </button>
403
- <div id="profile-menu" class="profile-menu" onclick="event.stopPropagation()">
404
- <div class="profile-menu-section">Inbox</div>
44
+ <div id="profile-menu" class="profile-menu">
45
+ <div class="profile-menu-section">Inboxes</div>
405
46
  <div id="profile-menu-list"></div>
406
47
  <div class="profile-menu-divider"></div>
407
48
  <div class="profile-menu-footer">
408
- <a onclick="signOut()">Sign out</a>
49
+ <a id="signout-link">Sign out</a>
409
50
  </div>
410
51
  </div>
411
52
  </header>
412
53
 
54
+ <!-- Sidebar + content -->
413
55
  <div class="main">
414
- <section class="pane pane-inbox">
415
- <div class="pane-header"><span id="inbox-title">Inbox</span></div>
416
- <div id="inbox-list"><div class="empty">Loading…</div></div>
56
+ <aside class="sidebar">
57
+ <button class="compose-btn" id="compose-btn">
58
+ <span class="compose-icon" data-icon="compose" data-icon-size="22"></span>
59
+ <span class="compose-text">Compose</span>
60
+ </button>
61
+ <div class="folder-list" id="folder-list"></div>
62
+ </aside>
63
+
64
+ <section class="content" id="content">
65
+ <!-- list-view or message-view is rendered here by the router -->
66
+ <div class="empty"><div class="big" data-icon="bow" data-icon-size="48"></div>Loading…</div>
417
67
  </section>
418
-
419
- <article class="pane pane-message">
420
- <div id="message-view">
421
- <div class="msg-empty">
422
- <div class="big">🎀</div>
423
- <div>Select an email to read it here.</div>
424
- </div>
425
- </div>
426
- </article>
427
68
  </div>
428
69
  </div>
429
70
 
430
- <!-- ─── Compose modal ────────────────────────────────────────────── -->
431
- <div id="compose-bg" class="modal-bg" style="display:none" onclick="if(event.target===this) closeCompose()">
432
- <div class="modal">
433
- <div class="modal-head">
71
+ <!-- ─── Compose modal (Gmail-style bottom-right popup) ───────────── -->
72
+ <div id="compose-bg" class="compose-bg" style="display:none">
73
+ <div class="compose-modal">
74
+ <div class="compose-head">
434
75
  <span id="compose-title">New message</span>
435
- <button class="btn btn-ghost" onclick="closeCompose()">✕</button>
76
+ <button id="compose-close" data-icon="close"></button>
436
77
  </div>
437
- <div class="modal-body">
438
- <div class="field"><label>From</label><select id="compose-from"></select></div>
439
- <div class="field"><label>To</label><input id="compose-to" placeholder="alice@localhost, bob@localhost" /></div>
440
- <div class="field"><label>Cc</label><input id="compose-cc" placeholder="(optional, comma-separated)" /></div>
441
- <div class="field"><label>Wake</label><input id="compose-wake" placeholder="(optional) names to actually wake — e.g. alice, bob" /></div>
442
- <div class="field"><label>Subject</label><input id="compose-subject" /></div>
443
- <div class="field"><label>Body</label><textarea id="compose-body" placeholder="Markdown supported: **bold**, *italic*, `code`, ```fenced```, ## headings, lists, tables…"></textarea></div>
444
- <div class="hint">Tip: pass <code class="mono">wake</code> to limit which CC'd agents get a Claude turn from the dispatcher. Leave blank to wake everyone CC'd (default). Add <code class="mono">[FINAL]</code> to the subject to close the thread.</div>
78
+ <div class="compose-body">
79
+ <div class="compose-row"><label>From</label><select id="compose-from"></select></div>
80
+ <div class="compose-row"><label>To</label><input id="compose-to" placeholder="alice@localhost, bob@localhost" /></div>
81
+ <div class="compose-row"><label>Cc</label><input id="compose-cc" placeholder="(optional)" /></div>
82
+ <div class="compose-row"><label>Wake</label><input id="compose-wake" placeholder="(optional) names to wake — e.g. alice, bob" /></div>
83
+ <div class="compose-row"><label>Subject</label><input id="compose-subject" /></div>
84
+ <textarea id="compose-body" placeholder="Markdown supported: **bold**, *italic*, `code`, ```fenced```, ## headings, lists, tables…"></textarea>
85
+ <p class="compose-hint">Tip: pass <code class="mono">wake</code> to limit which CC'd agents get a Claude turn. Add <code class="mono">[FINAL]</code> to the subject to close the thread.</p>
445
86
  </div>
446
- <div class="modal-foot">
447
- <button class="btn" onclick="closeCompose()">Cancel</button>
448
- <button class="btn btn-primary" onclick="sendCompose()">Send</button>
87
+ <div class="compose-foot">
88
+ <button class="btn-send" id="compose-send">Send</button>
89
+ <button class="btn-discard" id="compose-cancel">Discard</button>
449
90
  </div>
450
91
  </div>
451
92
  </div>
452
93
 
453
94
  <div id="toast" class="toast"></div>
454
95
 
455
- <script>
456
- /* ─── State ──────────────────────────────────────────────────────── */
457
- const state = {
458
- masterKey: null,
459
- agents: [],
460
- selectedAgent: null,
461
- inboxMessages: [],
462
- selectedUid: null,
463
- currentMessage: null,
464
- composeReplyContext: null, // { uid, agent } when replying
465
- searchQuery: '',
466
- sseControllers: [],
467
- };
468
-
469
- const API_URL = window.location.origin; // same origin as the API server
470
- document.getElementById('auth-api-url').textContent = API_URL;
471
-
472
- /* ─── Auth ───────────────────────────────────────────────────────── */
473
- async function signIn() {
474
- const key = document.getElementById('auth-key').value.trim();
475
- if (!key) return showAuthErr('Master key is required.');
476
- // Verify by hitting /accounts (master-key gated).
477
- try {
478
- const resp = await fetch(`${API_URL}/api/agenticmail/accounts`, {
479
- headers: { Authorization: `Bearer ${key}` },
480
- });
481
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
482
- localStorage.setItem('agenticmail.masterKey', key);
483
- state.masterKey = key;
484
- document.getElementById('auth').style.display = 'none';
485
- document.getElementById('app').style.display = 'grid';
486
- await bootstrap();
487
- } catch (err) {
488
- showAuthErr(`Sign-in failed: ${err.message}. Check the key and that the API is running on ${API_URL}.`);
489
- }
490
- }
491
- function showAuthErr(msg) {
492
- const e = document.getElementById('auth-err');
493
- e.textContent = msg; e.style.display = 'block';
494
- }
495
- function signOut() {
496
- localStorage.removeItem('agenticmail.masterKey');
497
- location.reload();
498
- }
499
- document.getElementById('auth-key').addEventListener('keydown', e => {
500
- if (e.key === 'Enter') signIn();
501
- });
502
-
503
- /* ─── API helpers ────────────────────────────────────────────────── */
504
- async function apiGet(path, opts = {}) {
505
- const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
506
- headers: { Authorization: `Bearer ${opts.agentKey ?? state.masterKey}` },
507
- });
508
- if (!r.ok) throw new Error(`${r.status} ${path}`);
509
- return await r.json();
510
- }
511
- async function apiPost(path, body, opts = {}) {
512
- const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
513
- method: 'POST',
514
- headers: {
515
- 'Content-Type': 'application/json',
516
- Authorization: `Bearer ${opts.agentKey ?? state.masterKey}`,
517
- },
518
- body: JSON.stringify(body),
519
- });
520
- if (!r.ok) throw new Error(`${r.status} ${path}`);
521
- return await r.json();
522
- }
523
-
524
- /* ─── Bootstrap ──────────────────────────────────────────────────── */
525
- async function bootstrap() {
526
- try {
527
- const data = await apiGet('/accounts');
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();
544
- populateComposeFrom();
545
- subscribeToAllAgents();
546
- maybeRequestNotificationPermission();
547
- } catch (err) {
548
- toast(`Failed to load agents: ${err.message}`, true);
549
- }
550
- }
551
-
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>`;
596
- }
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 => {
639
- el.addEventListener('click', () => {
640
- const id = el.dataset.id;
641
- const agent = state.agents.find(a => a.id === id);
642
- if (agent && agent.id !== state.selectedAgent?.id) {
643
- selectAgent(agent);
644
- }
645
- closeProfileMenu();
646
- });
647
- });
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
-
666
- async function selectAgent(agent) {
667
- state.selectedAgent = agent;
668
- state.selectedUid = null;
669
- state.currentMessage = null;
670
- document.getElementById('inbox-title').textContent = `Inbox — ${agent.name}`;
671
- document.querySelector('.pane-message #message-view').innerHTML = `
672
- <div class="msg-empty"><div class="big">🎀</div><div>Select an email to read it here.</div></div>
673
- `;
674
- renderProfile();
675
- await loadInbox(agent);
676
- }
677
-
678
- /* ─── Inbox list ─────────────────────────────────────────────────── */
679
- async function loadInbox(agent) {
680
- document.getElementById('inbox-list').innerHTML = '<div class="empty">Loading…</div>';
681
- try {
682
- const data = await apiGet('/mail/inbox?limit=50&offset=0', { agentKey: agent.apiKey });
683
- state.inboxMessages = data.messages ?? [];
684
- renderInbox();
685
- } catch (err) {
686
- document.getElementById('inbox-list').innerHTML = `<div class="empty">Failed to load: ${escapeHtml(err.message)}</div>`;
687
- }
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
-
738
- function renderInbox() {
739
- const root = document.getElementById('inbox-list');
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))
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
-
759
- if (filtered.length === 0) {
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>';
763
- return;
764
- }
765
- root.innerHTML = filtered.map(m => {
766
- const unread = !(m.flags ?? []).includes('\\Seen');
767
- const fromAddr = m.from?.[0]?.address ?? '?';
768
- const fromName = m.from?.[0]?.name || fromAddr;
769
- const subject = m.subject ?? '(no subject)';
770
- const date = formatDate(m.date);
771
- return `
772
- <div class="inbox-row ${unread ? 'unread' : ''} ${state.selectedUid === m.uid ? 'selected' : ''}" data-uid="${m.uid}">
773
- <div class="inbox-from">
774
- <span class="name">${highlightTerm(fromName, hlTerm)}</span>
775
- <span class="date">${escapeHtml(date)}</span>
776
- </div>
777
- <div class="subject">${highlightTerm(subject, hlTerm)}</div>
778
- <div class="preview">${highlightTerm((m.preview ?? '').slice(0, 120), hlTerm)}</div>
779
- </div>
780
- `;
781
- }).join('');
782
- root.querySelectorAll('.inbox-row').forEach(el => {
783
- el.addEventListener('click', () => openMessage(Number(el.dataset.uid)));
784
- });
785
- }
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
-
798
- /* ─── Message view ───────────────────────────────────────────────── */
799
- async function openMessage(uid) {
800
- if (!state.selectedAgent) return;
801
- state.selectedUid = uid;
802
- renderInbox();
803
- document.querySelector('.pane-message #message-view').innerHTML = '<div class="msg-empty">Loading…</div>';
804
- try {
805
- const msg = await apiGet(`/mail/messages/${uid}`, { agentKey: state.selectedAgent.apiKey });
806
- state.currentMessage = msg;
807
- renderMessage(msg);
808
- } catch (err) {
809
- document.querySelector('.pane-message #message-view').innerHTML =
810
- `<div class="msg-empty"><div>Failed to load: ${escapeHtml(err.message)}</div></div>`;
811
- }
812
- }
813
- function renderMessage(msg) {
814
- const view = document.querySelector('.pane-message #message-view');
815
- const fromStr = (msg.from ?? []).map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(', ') || '?';
816
- const toStr = (msg.to ?? []).map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(', ') || '?';
817
- const ccStr = (msg.cc ?? []).map(a => a.address).join(', ');
818
- const attachmentsHtml = (msg.attachments ?? []).length > 0
819
- ? `<div class="msg-attachments">${msg.attachments.map(a =>
820
- `<span class="msg-attachment">📎 ${escapeHtml(a.filename ?? '(unnamed)')} ${a.size ? `(${Math.round(a.size/1024)}KB)` : ''}</span>`
821
- ).join('')}</div>`
822
- : '';
823
- const bodyText = msg.text ?? stripHtml(msg.html ?? '');
824
- view.innerHTML = `
825
- <div class="msg-header">
826
- <h2 class="msg-subject">${escapeHtml(msg.subject ?? '(no subject)')}</h2>
827
- <div class="msg-meta">
828
- <span class="label">From</span><span>${escapeHtml(fromStr)}</span>
829
- <span class="label">To</span><span>${escapeHtml(toStr)}</span>
830
- ${ccStr ? `<span class="label">Cc</span><span>${escapeHtml(ccStr)}</span>` : ''}
831
- <span class="label">Date</span><span class="date">${escapeHtml(formatDateFull(msg.date))}</span>
832
- </div>
833
- <div class="msg-actions">
834
- <button class="btn" onclick="reply(false)">↩ Reply</button>
835
- <button class="btn" onclick="reply(true)">↩↩ Reply all</button>
836
- <button class="btn btn-ghost" onclick="markUnread()">Mark unread</button>
837
- </div>
838
- </div>
839
- <div class="msg-body">${renderMarkdown(bodyText)}</div>
840
- ${attachmentsHtml}
841
- `;
842
- }
843
-
844
- /* ─── Markdown rendering (small, sufficient for agent emails) ────── */
845
- function renderMarkdown(src) {
846
- if (!src) return '<div class="empty">(no body)</div>';
847
- // Convert quoted-reply chains to blockquotes BEFORE escaping so HTML
848
- // tagging survives. Each quote level wraps in <blockquote>.
849
- const lines = src.split('\n');
850
- let out = '';
851
- let codeFence = false;
852
- let codeBuf = [];
853
- let codeLang = '';
854
- let listBuf = null; // { type: 'ul'|'ol', items: [...] }
855
- let blockquoteDepth = 0;
856
- function flushList() {
857
- if (!listBuf) return;
858
- out += `<${listBuf.type}>${listBuf.items.map(i => `<li>${i}</li>`).join('')}</${listBuf.type}>`;
859
- listBuf = null;
860
- }
861
- function setBlockquote(depth) {
862
- while (blockquoteDepth < depth) { out += '<blockquote>'; blockquoteDepth++; }
863
- while (blockquoteDepth > depth) { out += '</blockquote>'; blockquoteDepth--; }
864
- }
865
- for (const rawLine of lines) {
866
- // Fenced code block toggle.
867
- const fence = rawLine.match(/^\s*```([\w+-]*)\s*$/);
868
- if (fence) {
869
- if (codeFence) {
870
- out += `<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`;
871
- codeBuf = []; codeFence = false; codeLang = '';
872
- } else {
873
- flushList();
874
- codeFence = true; codeLang = fence[1];
875
- }
876
- continue;
877
- }
878
- if (codeFence) { codeBuf.push(rawLine); continue; }
879
- // Quote depth.
880
- let line = rawLine, depth = 0;
881
- while (/^>/.test(line)) { depth++; line = line.replace(/^>\s?/, ''); }
882
- if (depth !== blockquoteDepth) { flushList(); setBlockquote(depth); }
883
- // Headings.
884
- const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
885
- if (heading) {
886
- flushList();
887
- const level = heading[1].length;
888
- out += `<h${Math.min(level, 6)}>${inlineMd(heading[2])}</h${Math.min(level, 6)}>`;
889
- continue;
890
- }
891
- // Horizontal rule.
892
- if (/^\s*(?:-{3,}|_{3,}|\*{3,})\s*$/.test(line)) { flushList(); out += '<hr>'; continue; }
893
- // Task list.
894
- const task = line.match(/^(\s*)([-*+])\s+\[([ xX])\]\s+(.*)$/);
895
- if (task) {
896
- const checked = task[3] !== ' ';
897
- const item = `<input type="checkbox" disabled ${checked ? 'checked' : ''}> ${inlineMd(task[4])}`;
898
- if (!listBuf || listBuf.type !== 'ul') { flushList(); listBuf = { type: 'ul', items: [] }; }
899
- listBuf.items.push(item);
900
- continue;
901
- }
902
- // Bullet list.
903
- const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
904
- if (bullet) {
905
- if (!listBuf || listBuf.type !== 'ul') { flushList(); listBuf = { type: 'ul', items: [] }; }
906
- listBuf.items.push(inlineMd(bullet[3]));
907
- continue;
908
- }
909
- // Numbered list.
910
- const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
911
- if (numbered) {
912
- if (!listBuf || listBuf.type !== 'ol') { flushList(); listBuf = { type: 'ol', items: [] }; }
913
- listBuf.items.push(inlineMd(numbered[3]));
914
- continue;
915
- }
916
- // Table (light support: pipe-bounded rows + separator).
917
- if (/^\s*\|.*\|\s*$/.test(line)) {
918
- flushList();
919
- const cells = line.trim().slice(1, -1).split('|').map(c => inlineMd(c.trim()));
920
- // Heuristic: separator row marks header → use <th>.
921
- if (/^\s*\|?(\s*:?-{3,}:?\s*\|)+/.test(line)) {
922
- // Skip the separator line.
923
- continue;
924
- }
925
- out += `<table><tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr></table>`;
926
- continue;
927
- }
928
- // Blank line.
929
- if (line.trim() === '') { flushList(); out += '<br>'; continue; }
930
- // Paragraph (with inline markdown).
931
- flushList();
932
- out += `<div>${inlineMd(line)}</div>`;
933
- }
934
- flushList();
935
- setBlockquote(0);
936
- return out;
937
- }
938
- function inlineMd(text) {
939
- // Escape first, then re-introduce safe HTML for markdown shapes.
940
- let s = escapeHtml(text);
941
- // Inline code.
942
- s = s.replace(/`([^`\n]+)`/g, (_, c) => `<code>${c}</code>`);
943
- // Bold+italic, bold, italic.
944
- s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, '<strong><em>$1</em></strong>');
945
- s = s.replace(/___([^_\n]+)___/g, '<strong><em>$1</em></strong>');
946
- s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
947
- s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
948
- s = s.replace(/(^|[^\w*])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
949
- s = s.replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
950
- // Strikethrough.
951
- s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
952
- // Links + auto-links.
953
- s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
954
- s = s.replace(/&lt;(https?:\/\/[^&\s]+)&gt;/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
955
- return s;
956
- }
957
- function stripHtml(s) { return (s ?? '').replace(/<[^>]*>/g, ''); }
958
-
959
- /* ─── Date formatting ────────────────────────────────────────────── */
960
- function formatDate(iso) {
961
- if (!iso) return '';
962
- const d = new Date(iso); if (Number.isNaN(d.getTime())) return '';
963
- const now = new Date();
964
- const sameDay = d.toDateString() === now.toDateString();
965
- if (sameDay) return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
966
- const days = Math.round((now - d) / (24 * 3600 * 1000));
967
- if (days < 7) return d.toLocaleDateString(undefined, { weekday: 'short' });
968
- if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
969
- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
970
- }
971
- function formatDateFull(iso) {
972
- if (!iso) return '';
973
- const d = new Date(iso); if (Number.isNaN(d.getTime())) return '';
974
- const now = new Date();
975
- const deltaMs = now - d;
976
- let rel = '';
977
- if (deltaMs < 45_000) rel = 'just now';
978
- else if (deltaMs < 60 * 60 * 1000) rel = `${Math.round(deltaMs / 60_000)} minutes ago`;
979
- else if (deltaMs < 24 * 60 * 60 * 1000) rel = `${Math.round(deltaMs / 3_600_000)} hours ago`;
980
- const abs = d.toLocaleString(undefined, {
981
- weekday: 'short', month: 'short', day: 'numeric',
982
- hour: 'numeric', minute: '2-digit',
983
- ...(d.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}),
984
- });
985
- return rel ? `${rel} — ${abs}` : abs;
986
- }
987
-
988
- /* ─── Compose ────────────────────────────────────────────────────── */
989
- function populateComposeFrom() {
990
- const sel = document.getElementById('compose-from');
991
- sel.innerHTML = state.agents.map(a => `<option value="${a.id}">${escapeHtml(a.name)} &lt;${escapeHtml(a.email)}&gt;</option>`).join('');
992
- }
993
- function openCompose() {
994
- state.composeReplyContext = null;
995
- document.getElementById('compose-title').textContent = 'New message';
996
- if (state.selectedAgent) document.getElementById('compose-from').value = state.selectedAgent.id;
997
- ['compose-to', 'compose-cc', 'compose-wake', 'compose-subject', 'compose-body'].forEach(id => document.getElementById(id).value = '');
998
- document.getElementById('compose-bg').style.display = 'flex';
999
- setTimeout(() => document.getElementById('compose-to').focus(), 50);
1000
- }
1001
- function closeCompose() { document.getElementById('compose-bg').style.display = 'none'; }
1002
- function reply(replyAll) {
1003
- if (!state.currentMessage) return;
1004
- const msg = state.currentMessage;
1005
- state.composeReplyContext = { uid: msg.uid, agent: state.selectedAgent, replyAll };
1006
- document.getElementById('compose-title').textContent = `Reply${replyAll ? ' all' : ''} to: ${msg.subject ?? '(no subject)'}`;
1007
- document.getElementById('compose-from').value = state.selectedAgent.id;
1008
- const fromAddr = msg.from?.[0]?.address ?? '';
1009
- let toAddr = fromAddr;
1010
- if (replyAll) {
1011
- const all = [fromAddr, ...(msg.to ?? []).map(a => a.address), ...(msg.cc ?? []).map(a => a.address)]
1012
- .filter(Boolean).filter((v, i, a) => a.indexOf(v) === i)
1013
- .filter(addr => addr !== state.selectedAgent.email);
1014
- toAddr = all.join(', ');
1015
- }
1016
- document.getElementById('compose-to').value = toAddr;
1017
- document.getElementById('compose-cc').value = '';
1018
- document.getElementById('compose-wake').value = '';
1019
- document.getElementById('compose-subject').value =
1020
- (msg.subject ?? '').startsWith('Re:') ? msg.subject : `Re: ${msg.subject ?? ''}`;
1021
- const quoted = (msg.text ?? '').split('\n').map(l => `> ${l}`).join('\n');
1022
- const stub = `\n\nOn ${msg.date}, ${fromAddr} wrote:\n${quoted}`;
1023
- document.getElementById('compose-body').value = stub;
1024
- document.getElementById('compose-bg').style.display = 'flex';
1025
- setTimeout(() => document.getElementById('compose-body').focus(), 50);
1026
- }
1027
- async function sendCompose() {
1028
- const agentId = document.getElementById('compose-from').value;
1029
- const agent = state.agents.find(a => a.id === agentId);
1030
- if (!agent) return toast('Pick an agent to send from.', true);
1031
- const to = document.getElementById('compose-to').value.trim();
1032
- const subject = document.getElementById('compose-subject').value.trim();
1033
- const text = document.getElementById('compose-body').value;
1034
- const cc = document.getElementById('compose-cc').value.trim();
1035
- const wakeRaw = document.getElementById('compose-wake').value.trim();
1036
- if (!to || !subject) return toast('To and Subject are required.', true);
1037
- const body = { to, subject, text };
1038
- if (cc) body.cc = cc;
1039
- if (wakeRaw) body.wake = wakeRaw.split(',').map(s => s.trim()).filter(Boolean);
1040
- try {
1041
- await apiPost('/mail/send', body, { agentKey: agent.apiKey });
1042
- closeCompose();
1043
- toast('Sent.');
1044
- if (state.selectedAgent?.id === agent.id) await loadInbox(agent);
1045
- } catch (err) {
1046
- toast(`Send failed: ${err.message}`, true);
1047
- }
1048
- }
1049
-
1050
- /* ─── Mark unread ────────────────────────────────────────────────── */
1051
- async function markUnread() {
1052
- if (!state.currentMessage || !state.selectedAgent) return;
1053
- try {
1054
- await apiPost(`/mail/messages/${state.currentMessage.uid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
1055
- toast('Marked unread.');
1056
- await loadInbox(state.selectedAgent);
1057
- } catch (err) { toast(`Failed: ${err.message}`, true); }
1058
- }
1059
-
1060
- /* ─── Refresh ────────────────────────────────────────────────────── */
1061
- async function refresh() {
1062
- if (state.selectedAgent) {
1063
- await loadInbox(state.selectedAgent);
1064
- toast('Refreshed.');
1065
- }
1066
- }
1067
-
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;
1073
- document.getElementById('search-input').addEventListener('input', e => {
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
- }
1088
- });
1089
-
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.
1102
- function subscribeToAllAgents() {
1103
- // Tear down any previous controllers.
1104
- for (const c of state.sseControllers) { try { c.abort(); } catch {} }
1105
- state.sseControllers = [];
1106
- for (const agent of state.agents) {
1107
- const ctrl = new AbortController();
1108
- state.sseControllers.push(ctrl);
1109
- fetch(`${API_URL}/api/agenticmail/events`, {
1110
- headers: { Authorization: `Bearer ${agent.apiKey}`, Accept: 'text/event-stream' },
1111
- signal: ctrl.signal,
1112
- }).then(async res => {
1113
- if (!res.ok || !res.body) return;
1114
- const reader = res.body.getReader();
1115
- const dec = new TextDecoder();
1116
- let buf = '';
1117
- while (!ctrl.signal.aborted) {
1118
- const { done, value } = await reader.read();
1119
- if (done) break;
1120
- buf += dec.decode(value, { stream: true });
1121
- let i;
1122
- while ((i = buf.indexOf('\n\n')) !== -1) {
1123
- const frame = buf.slice(0, i); buf = buf.slice(i + 2);
1124
- for (const line of frame.split('\n')) {
1125
- if (!line.startsWith('data: ')) continue;
1126
- try { handleSseEvent(agent, JSON.parse(line.slice(6))); } catch {}
1127
- }
1128
- }
1129
- }
1130
- }).catch(() => { /* connection dropped — browser will retry on user action */ });
1131
- }
1132
- }
1133
-
1134
- async function handleSseEvent(agent, event) {
1135
- if (event.type !== 'new') return;
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');
1170
- if (!el) return;
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 */ }
1215
- }
1216
-
1217
- /* ─── Toast ──────────────────────────────────────────────────────── */
1218
- function toast(msg, error = false) {
1219
- const t = document.getElementById('toast');
1220
- t.textContent = msg;
1221
- t.classList.toggle('error', error);
1222
- t.classList.add('show');
1223
- clearTimeout(t._timer);
1224
- t._timer = setTimeout(() => t.classList.remove('show'), 2500);
1225
- }
1226
-
1227
- /* ─── Utils ──────────────────────────────────────────────────────── */
1228
- function escapeHtml(s) {
1229
- return String(s ?? '')
1230
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1231
- .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1232
- }
1233
-
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)
1239
- document.addEventListener('keydown', e => {
1240
- if (document.getElementById('compose-bg').style.display !== 'none') return;
1241
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
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
- }
1248
- });
1249
-
1250
- /* ─── Boot ───────────────────────────────────────────────────────── */
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.
1274
- const saved = localStorage.getItem('agenticmail.masterKey');
1275
- if (saved) {
1276
- state.masterKey = saved;
1277
- document.getElementById('auth').style.display = 'none';
1278
- document.getElementById('app').style.display = 'grid';
1279
- bootstrap();
1280
- }
1281
- })();
1282
- </script>
96
+ <script type="module" src="js/app.js"></script>
1283
97
  </body>
1284
98
  </html>