@agenticmail/api 0.7.8 → 0.7.11
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 +2 -2
- package/dist/index.js +66 -16
- package/package.json +1 -1
- package/public/branding/agenticmail-logo.png +0 -0
- package/public/branding/claude-mark.svg +2 -0
- package/public/index.html +52 -1237
- package/public/js/api.js +25 -0
- package/public/js/app.js +228 -0
- package/public/js/avatar.js +43 -0
- package/public/js/compose.js +81 -0
- package/public/js/icons.js +56 -0
- package/public/js/list-view.js +160 -0
- package/public/js/markdown.js +97 -0
- package/public/js/message-view.js +87 -0
- package/public/js/profile.js +54 -0
- package/public/js/search.js +45 -0
- package/public/js/sidebar.js +40 -0
- package/public/js/sse.js +97 -0
- package/public/js/state.js +17 -0
- package/public/js/time.js +33 -0
- package/public/js/utils.js +22 -0
- package/public/styles.css +668 -0
package/public/index.html
CHANGED
|
@@ -3,1282 +3,97 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title
|
|
7
|
-
<link rel="icon"
|
|
8
|
-
<
|
|
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" type="image/png" href="/branding/agenticmail-logo.png" />
|
|
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
|
|
15
|
+
<h1><img src="/branding/agenticmail-logo.png" alt="AgenticMail" class="brand-logo" /> 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="
|
|
380
|
-
<p class="
|
|
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
|
-
<
|
|
389
|
-
<span>AgenticMail</span>
|
|
30
|
+
<img src="/branding/agenticmail-logo.png" alt="AgenticMail" class="brand-logo" />
|
|
31
|
+
<span class="brand-name">AgenticMail</span>
|
|
390
32
|
</div>
|
|
391
|
-
<div class="search">
|
|
392
|
-
<
|
|
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"
|
|
37
|
+
<button id="search-clear" class="search-clear-btn" title="Clear (Esc)" data-icon="close"></button>
|
|
395
38
|
</div>
|
|
396
|
-
<
|
|
397
|
-
<button class="btn btn
|
|
398
|
-
<button id="profile-btn" class="profile"
|
|
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"
|
|
404
|
-
<div class="profile-menu-section">
|
|
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
|
|
49
|
+
<a id="signout-link">Sign out</a>
|
|
409
50
|
</div>
|
|
410
51
|
</div>
|
|
411
52
|
</header>
|
|
412
53
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
54
|
+
<!-- Sidebar + content -->
|
|
55
|
+
<div class="main" id="main">
|
|
56
|
+
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
|
|
57
|
+
<aside class="sidebar" id="sidebar">
|
|
58
|
+
<button class="compose-btn" id="compose-btn">
|
|
59
|
+
<span class="compose-icon" data-icon="compose" data-icon-size="22"></span>
|
|
60
|
+
<span class="compose-text">Compose</span>
|
|
61
|
+
</button>
|
|
62
|
+
<div class="folder-list" id="folder-list"></div>
|
|
63
|
+
</aside>
|
|
64
|
+
|
|
65
|
+
<section class="content" id="content">
|
|
66
|
+
<!-- list-view or message-view is rendered here by the router -->
|
|
67
|
+
<div class="empty"><div class="big" data-icon="bow" data-icon-size="48"></div>Loading…</div>
|
|
417
68
|
</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
69
|
</div>
|
|
428
70
|
</div>
|
|
429
71
|
|
|
430
|
-
<!-- ─── Compose modal
|
|
431
|
-
<div id="compose-bg" class="
|
|
432
|
-
<div class="modal">
|
|
433
|
-
<div class="
|
|
72
|
+
<!-- ─── Compose modal (Gmail-style bottom-right popup) ───────────── -->
|
|
73
|
+
<div id="compose-bg" class="compose-bg" style="display:none">
|
|
74
|
+
<div class="compose-modal">
|
|
75
|
+
<div class="compose-head">
|
|
434
76
|
<span id="compose-title">New message</span>
|
|
435
|
-
<button
|
|
77
|
+
<button id="compose-close" data-icon="close"></button>
|
|
436
78
|
</div>
|
|
437
|
-
<div class="
|
|
438
|
-
<div class="
|
|
439
|
-
<div class="
|
|
440
|
-
<div class="
|
|
441
|
-
<div class="
|
|
442
|
-
<div class="
|
|
443
|
-
<
|
|
444
|
-
<
|
|
79
|
+
<div class="compose-body">
|
|
80
|
+
<div class="compose-row"><label>From</label><select id="compose-from"></select></div>
|
|
81
|
+
<div class="compose-row"><label>To</label><input id="compose-to" placeholder="alice@localhost, bob@localhost" /></div>
|
|
82
|
+
<div class="compose-row"><label>Cc</label><input id="compose-cc" placeholder="(optional)" /></div>
|
|
83
|
+
<div class="compose-row"><label>Wake</label><input id="compose-wake" placeholder="(optional) names to wake — e.g. alice, bob" /></div>
|
|
84
|
+
<div class="compose-row"><label>Subject</label><input id="compose-subject" /></div>
|
|
85
|
+
<textarea id="compose-body" placeholder="Markdown supported: **bold**, *italic*, `code`, ```fenced```, ## headings, lists, tables…"></textarea>
|
|
86
|
+
<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
87
|
</div>
|
|
446
|
-
<div class="
|
|
447
|
-
<button class="btn"
|
|
448
|
-
<button class="btn
|
|
88
|
+
<div class="compose-foot">
|
|
89
|
+
<button class="btn-send" id="compose-send">Send</button>
|
|
90
|
+
<button class="btn-discard" id="compose-cancel">Discard</button>
|
|
449
91
|
</div>
|
|
450
92
|
</div>
|
|
451
93
|
</div>
|
|
452
94
|
|
|
453
95
|
<div id="toast" class="toast"></div>
|
|
454
96
|
|
|
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(/<(https?:\/\/[^&\s]+)>/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)} <${escapeHtml(a.email)}></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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1231
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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>
|
|
97
|
+
<script type="module" src="js/app.js"></script>
|
|
1283
98
|
</body>
|
|
1284
99
|
</html>
|