@agenticmail/api 0.7.4 → 0.7.7
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/dist/index.js +157 -56
- package/package.json +2 -1
- package/public/index.html +882 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
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
|
+
}
|
|
61
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
|
|
62
|
+
.brand-bow { font-size: 22px; }
|
|
63
|
+
.brand-name { color: var(--pink); }
|
|
64
|
+
.search {
|
|
65
|
+
flex: 1; max-width: 720px;
|
|
66
|
+
position: relative;
|
|
67
|
+
}
|
|
68
|
+
.search input {
|
|
69
|
+
width: 100%; height: 38px; padding: 0 14px 0 38px;
|
|
70
|
+
border: 1px solid var(--line); border-radius: 10px;
|
|
71
|
+
background: var(--bg-soft); color: var(--ink); outline: none;
|
|
72
|
+
transition: border-color .15s;
|
|
73
|
+
}
|
|
74
|
+
.search input:focus { border-color: var(--pink); }
|
|
75
|
+
.search::before {
|
|
76
|
+
content: "🔍"; position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
|
77
|
+
font-size: 14px; opacity: .7;
|
|
78
|
+
}
|
|
79
|
+
.btn {
|
|
80
|
+
height: 36px; padding: 0 14px;
|
|
81
|
+
border: 1px solid var(--line); border-radius: 8px;
|
|
82
|
+
background: var(--bg-soft); color: var(--ink);
|
|
83
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
84
|
+
}
|
|
85
|
+
.btn:hover { background: var(--row-hover); }
|
|
86
|
+
.btn-primary {
|
|
87
|
+
background: var(--pink); color: white; border-color: var(--pink);
|
|
88
|
+
}
|
|
89
|
+
.btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
|
|
90
|
+
.btn-ghost { background: transparent; border-color: transparent; padding: 0 10px; }
|
|
91
|
+
.btn-ghost:hover { background: var(--row-hover); }
|
|
92
|
+
|
|
93
|
+
/* ─── Three-pane layout ─────────────────────────────────────────── */
|
|
94
|
+
.main { display: grid; grid-template-columns: 240px 380px 1fr; overflow: hidden; }
|
|
95
|
+
@media (max-width: 1100px) { .main { grid-template-columns: 200px 340px 1fr; } }
|
|
96
|
+
.pane { overflow-y: auto; border-right: 1px solid var(--line); }
|
|
97
|
+
.pane:last-child { border-right: none; }
|
|
98
|
+
|
|
99
|
+
/* ─── Agents sidebar ────────────────────────────────────────────── */
|
|
100
|
+
.pane-agents { background: var(--bg-soft); }
|
|
101
|
+
.pane-header {
|
|
102
|
+
padding: 12px 14px 6px; font-size: 11px; font-weight: 600;
|
|
103
|
+
color: var(--muted); text-transform: uppercase; letter-spacing: .05em;
|
|
104
|
+
}
|
|
105
|
+
.agent-row {
|
|
106
|
+
display: flex; align-items: center; gap: 8px;
|
|
107
|
+
padding: 8px 14px; cursor: pointer; border-left: 3px solid transparent;
|
|
108
|
+
color: var(--ink-soft);
|
|
109
|
+
}
|
|
110
|
+
.agent-row:hover { background: var(--row-hover); }
|
|
111
|
+
.agent-row.selected { background: var(--row-selected); border-left-color: var(--pink); color: var(--ink); font-weight: 500; }
|
|
112
|
+
.agent-row .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
|
|
113
|
+
.agent-row.selected .dot { background: var(--pink); }
|
|
114
|
+
.agent-row .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
115
|
+
.agent-row .count {
|
|
116
|
+
font-size: 11px; padding: 1px 6px; border-radius: 10px;
|
|
117
|
+
background: var(--pink); color: white; min-width: 18px; text-align: center;
|
|
118
|
+
}
|
|
119
|
+
.agent-row .count[data-zero] { display: none; }
|
|
120
|
+
|
|
121
|
+
/* ─── Inbox list ────────────────────────────────────────────────── */
|
|
122
|
+
.pane-inbox { background: var(--bg); }
|
|
123
|
+
.inbox-row {
|
|
124
|
+
padding: 12px 16px; cursor: pointer; border-bottom: 1px solid var(--line);
|
|
125
|
+
display: grid; grid-template-rows: auto auto auto; gap: 2px;
|
|
126
|
+
}
|
|
127
|
+
.inbox-row:hover { background: var(--row-hover); }
|
|
128
|
+
.inbox-row.selected { background: var(--row-selected); }
|
|
129
|
+
.inbox-row.unread .subject { font-weight: 600; color: var(--ink); }
|
|
130
|
+
.inbox-row.unread::before {
|
|
131
|
+
content: ""; width: 8px; height: 8px; border-radius: 50%;
|
|
132
|
+
background: var(--unread-dot); position: absolute; margin-left: -12px; margin-top: 8px;
|
|
133
|
+
}
|
|
134
|
+
.inbox-row { position: relative; }
|
|
135
|
+
.inbox-from { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
|
136
|
+
.inbox-from .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
|
|
137
|
+
.inbox-from .date { font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
|
138
|
+
.subject {
|
|
139
|
+
font-size: 14px; color: var(--ink-soft);
|
|
140
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
141
|
+
}
|
|
142
|
+
.preview {
|
|
143
|
+
font-size: 12px; color: var(--muted);
|
|
144
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
145
|
+
}
|
|
146
|
+
.empty { padding: 24px 16px; text-align: center; color: var(--muted); font-size: 13px; }
|
|
147
|
+
|
|
148
|
+
/* ─── Message detail ────────────────────────────────────────────── */
|
|
149
|
+
.pane-message { background: var(--bg); padding: 0; }
|
|
150
|
+
.msg-header {
|
|
151
|
+
padding: 20px 28px 12px;
|
|
152
|
+
border-bottom: 1px solid var(--pink-rule);
|
|
153
|
+
background: linear-gradient(to bottom, var(--pink-soft) 0%, transparent 100%);
|
|
154
|
+
}
|
|
155
|
+
.msg-subject { font-size: 20px; font-weight: 600; margin: 0 0 12px; color: var(--ink); }
|
|
156
|
+
.msg-meta { font-size: 13px; color: var(--ink-soft); display: grid; grid-template-columns: 60px 1fr; gap: 4px 12px; }
|
|
157
|
+
.msg-meta .label { color: var(--muted); }
|
|
158
|
+
.msg-meta .date { color: var(--accent-strong); font-variant-numeric: tabular-nums; }
|
|
159
|
+
.msg-actions { display: flex; gap: 8px; margin-top: 14px; }
|
|
160
|
+
.msg-body {
|
|
161
|
+
padding: 20px 28px;
|
|
162
|
+
font-size: 14px; line-height: 1.65; color: var(--ink);
|
|
163
|
+
white-space: pre-wrap; word-wrap: break-word;
|
|
164
|
+
}
|
|
165
|
+
.msg-body h1, .msg-body h2, .msg-body h3 {
|
|
166
|
+
color: var(--pink); margin: 1.2em 0 .4em;
|
|
167
|
+
}
|
|
168
|
+
.msg-body h1 { font-size: 1.4em; }
|
|
169
|
+
.msg-body h2 { font-size: 1.2em; }
|
|
170
|
+
.msg-body h3 { font-size: 1.05em; }
|
|
171
|
+
.msg-body code {
|
|
172
|
+
background: var(--code-bg); color: var(--code-fg);
|
|
173
|
+
padding: 1px 5px; border-radius: 4px; font-size: 13px;
|
|
174
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
175
|
+
}
|
|
176
|
+
.msg-body pre {
|
|
177
|
+
background: var(--code-bg); padding: 12px 14px; border-radius: 8px;
|
|
178
|
+
overflow-x: auto; font-size: 13px;
|
|
179
|
+
}
|
|
180
|
+
.msg-body pre code { background: transparent; padding: 0; color: var(--ink); }
|
|
181
|
+
.msg-body strong { color: var(--ink); }
|
|
182
|
+
.msg-body em { color: var(--ink-soft); }
|
|
183
|
+
.msg-body blockquote {
|
|
184
|
+
border-left: 3px solid var(--pink-rule);
|
|
185
|
+
margin: .8em 0; padding: .2em 0 .2em 12px;
|
|
186
|
+
color: var(--muted); white-space: pre-wrap;
|
|
187
|
+
}
|
|
188
|
+
.msg-body blockquote blockquote { border-left-color: #c084fc; }
|
|
189
|
+
.msg-body blockquote blockquote blockquote { border-left-color: #f59e0b; }
|
|
190
|
+
.msg-body table { border-collapse: collapse; margin: .5em 0; }
|
|
191
|
+
.msg-body th, .msg-body td { border: 1px solid var(--line); padding: 6px 10px; }
|
|
192
|
+
.msg-body th { background: var(--bg-soft); font-weight: 600; }
|
|
193
|
+
.msg-body ul, .msg-body ol { padding-left: 24px; }
|
|
194
|
+
.msg-body hr { border: none; border-top: 1px solid var(--line); margin: 1.2em 0; }
|
|
195
|
+
.msg-body a { color: var(--accent-strong); }
|
|
196
|
+
.msg-body input[type=checkbox] { margin-right: 6px; }
|
|
197
|
+
.msg-attachments {
|
|
198
|
+
padding: 12px 28px; border-top: 1px solid var(--line);
|
|
199
|
+
display: flex; flex-wrap: wrap; gap: 8px;
|
|
200
|
+
}
|
|
201
|
+
.msg-attachment {
|
|
202
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
203
|
+
padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px;
|
|
204
|
+
font-size: 12px; color: var(--ink-soft);
|
|
205
|
+
}
|
|
206
|
+
.msg-empty { padding: 80px 28px; text-align: center; color: var(--muted); }
|
|
207
|
+
.msg-empty .big { font-size: 48px; margin-bottom: 12px; opacity: .4; }
|
|
208
|
+
|
|
209
|
+
/* ─── Auth gate ─────────────────────────────────────────────────── */
|
|
210
|
+
.auth {
|
|
211
|
+
position: fixed; inset: 0; background: var(--bg);
|
|
212
|
+
display: flex; align-items: center; justify-content: center;
|
|
213
|
+
z-index: 10;
|
|
214
|
+
}
|
|
215
|
+
.auth-card {
|
|
216
|
+
width: 380px; padding: 28px;
|
|
217
|
+
border: 1px solid var(--line); border-radius: 14px;
|
|
218
|
+
background: var(--bg-soft);
|
|
219
|
+
}
|
|
220
|
+
.auth-card h1 { margin: 0 0 6px; font-size: 22px; color: var(--pink); }
|
|
221
|
+
.auth-card p { margin: 0 0 16px; color: var(--muted); font-size: 13px; }
|
|
222
|
+
.auth-card input {
|
|
223
|
+
width: 100%; height: 40px; padding: 0 12px; margin-bottom: 12px;
|
|
224
|
+
border: 1px solid var(--line); border-radius: 8px;
|
|
225
|
+
background: var(--bg); color: var(--ink); outline: none;
|
|
226
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
227
|
+
}
|
|
228
|
+
.auth-card input:focus { border-color: var(--pink); }
|
|
229
|
+
.auth-card .btn-primary { width: 100%; height: 40px; justify-content: center; }
|
|
230
|
+
.auth-err { color: #dc2626; font-size: 12px; margin-bottom: 12px; }
|
|
231
|
+
|
|
232
|
+
/* ─── Compose modal ─────────────────────────────────────────────── */
|
|
233
|
+
.modal-bg {
|
|
234
|
+
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
|
235
|
+
display: flex; align-items: center; justify-content: center;
|
|
236
|
+
z-index: 20;
|
|
237
|
+
}
|
|
238
|
+
.modal {
|
|
239
|
+
width: 640px; max-height: 80vh; overflow: hidden;
|
|
240
|
+
background: var(--bg); border-radius: 14px;
|
|
241
|
+
border: 1px solid var(--line);
|
|
242
|
+
display: grid; grid-template-rows: auto 1fr auto;
|
|
243
|
+
}
|
|
244
|
+
.modal-head {
|
|
245
|
+
padding: 14px 18px; border-bottom: 1px solid var(--line);
|
|
246
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
247
|
+
font-weight: 600; color: var(--pink);
|
|
248
|
+
}
|
|
249
|
+
.modal-body { padding: 14px 18px; overflow-y: auto; }
|
|
250
|
+
.modal-foot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; }
|
|
251
|
+
.field { display: grid; grid-template-columns: 80px 1fr; gap: 8px; margin-bottom: 10px; align-items: center; }
|
|
252
|
+
.field label { color: var(--muted); font-size: 12px; }
|
|
253
|
+
.field input, .field textarea {
|
|
254
|
+
width: 100%; padding: 8px 10px;
|
|
255
|
+
border: 1px solid var(--line); border-radius: 6px;
|
|
256
|
+
background: var(--bg); color: var(--ink); outline: none;
|
|
257
|
+
font: inherit;
|
|
258
|
+
}
|
|
259
|
+
.field textarea { resize: vertical; min-height: 200px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
|
260
|
+
.field input:focus, .field textarea:focus { border-color: var(--pink); }
|
|
261
|
+
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
|
262
|
+
|
|
263
|
+
/* ─── Toast / status ────────────────────────────────────────────── */
|
|
264
|
+
.toast {
|
|
265
|
+
position: fixed; bottom: 20px; right: 20px; z-index: 30;
|
|
266
|
+
padding: 10px 16px; border-radius: 8px;
|
|
267
|
+
background: var(--ink); color: white; font-size: 13px;
|
|
268
|
+
opacity: 0; transform: translateY(8px); transition: all .2s;
|
|
269
|
+
pointer-events: none;
|
|
270
|
+
}
|
|
271
|
+
.toast.show { opacity: 1; transform: translateY(0); }
|
|
272
|
+
.toast.error { background: #dc2626; }
|
|
273
|
+
</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
|
|
277
|
+
<!-- ─── Auth gate (shown until master key is entered) ────────────── -->
|
|
278
|
+
<div id="auth" class="auth">
|
|
279
|
+
<div class="auth-card">
|
|
280
|
+
<h1>🎀 AgenticMail</h1>
|
|
281
|
+
<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>
|
|
282
|
+
<div id="auth-err" class="auth-err" style="display:none"></div>
|
|
283
|
+
<input id="auth-key" type="password" placeholder="mk_…" autocomplete="off" autofocus />
|
|
284
|
+
<button class="btn btn-primary" onclick="signIn()">Sign in</button>
|
|
285
|
+
<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>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<!-- ─── App shell ─────────────────────────────────────────────────── -->
|
|
290
|
+
<div class="app" id="app" style="display:none">
|
|
291
|
+
<header class="topbar">
|
|
292
|
+
<div class="brand">
|
|
293
|
+
<span class="brand-bow">🎀</span>
|
|
294
|
+
<span>AgenticMail</span>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="search">
|
|
297
|
+
<input id="search-input" placeholder="Search this inbox — subject, sender, or text" />
|
|
298
|
+
</div>
|
|
299
|
+
<button class="btn btn-ghost" onclick="refresh()" title="Refresh">⟳</button>
|
|
300
|
+
<button class="btn btn-primary" onclick="openCompose()">Compose</button>
|
|
301
|
+
<button class="btn btn-ghost" onclick="signOut()" title="Sign out">⎋</button>
|
|
302
|
+
</header>
|
|
303
|
+
|
|
304
|
+
<div class="main">
|
|
305
|
+
<aside class="pane pane-agents">
|
|
306
|
+
<div class="pane-header">Agents</div>
|
|
307
|
+
<div id="agent-list"></div>
|
|
308
|
+
</aside>
|
|
309
|
+
|
|
310
|
+
<section class="pane pane-inbox">
|
|
311
|
+
<div class="pane-header"><span id="inbox-title">Inbox</span></div>
|
|
312
|
+
<div id="inbox-list"><div class="empty">Pick an agent on the left.</div></div>
|
|
313
|
+
</section>
|
|
314
|
+
|
|
315
|
+
<article class="pane pane-message">
|
|
316
|
+
<div id="message-view">
|
|
317
|
+
<div class="msg-empty">
|
|
318
|
+
<div class="big">🎀</div>
|
|
319
|
+
<div>Select an email to read it here.</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</article>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<!-- ─── Compose modal ────────────────────────────────────────────── -->
|
|
327
|
+
<div id="compose-bg" class="modal-bg" style="display:none" onclick="if(event.target===this) closeCompose()">
|
|
328
|
+
<div class="modal">
|
|
329
|
+
<div class="modal-head">
|
|
330
|
+
<span id="compose-title">New message</span>
|
|
331
|
+
<button class="btn btn-ghost" onclick="closeCompose()">✕</button>
|
|
332
|
+
</div>
|
|
333
|
+
<div class="modal-body">
|
|
334
|
+
<div class="field"><label>From</label><select id="compose-from"></select></div>
|
|
335
|
+
<div class="field"><label>To</label><input id="compose-to" placeholder="alice@localhost, bob@localhost" /></div>
|
|
336
|
+
<div class="field"><label>Cc</label><input id="compose-cc" placeholder="(optional, comma-separated)" /></div>
|
|
337
|
+
<div class="field"><label>Wake</label><input id="compose-wake" placeholder="(optional) names to actually wake — e.g. alice, bob" /></div>
|
|
338
|
+
<div class="field"><label>Subject</label><input id="compose-subject" /></div>
|
|
339
|
+
<div class="field"><label>Body</label><textarea id="compose-body" placeholder="Markdown supported: **bold**, *italic*, `code`, ```fenced```, ## headings, lists, tables…"></textarea></div>
|
|
340
|
+
<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>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="modal-foot">
|
|
343
|
+
<button class="btn" onclick="closeCompose()">Cancel</button>
|
|
344
|
+
<button class="btn btn-primary" onclick="sendCompose()">Send</button>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<div id="toast" class="toast"></div>
|
|
350
|
+
|
|
351
|
+
<script>
|
|
352
|
+
/* ─── State ──────────────────────────────────────────────────────── */
|
|
353
|
+
const state = {
|
|
354
|
+
masterKey: null,
|
|
355
|
+
agents: [],
|
|
356
|
+
selectedAgent: null,
|
|
357
|
+
inboxMessages: [],
|
|
358
|
+
selectedUid: null,
|
|
359
|
+
currentMessage: null,
|
|
360
|
+
composeReplyContext: null, // { uid, agent } when replying
|
|
361
|
+
searchQuery: '',
|
|
362
|
+
sseControllers: [],
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const API_URL = window.location.origin; // same origin as the API server
|
|
366
|
+
document.getElementById('auth-api-url').textContent = API_URL;
|
|
367
|
+
|
|
368
|
+
/* ─── Auth ───────────────────────────────────────────────────────── */
|
|
369
|
+
async function signIn() {
|
|
370
|
+
const key = document.getElementById('auth-key').value.trim();
|
|
371
|
+
if (!key) return showAuthErr('Master key is required.');
|
|
372
|
+
// Verify by hitting /accounts (master-key gated).
|
|
373
|
+
try {
|
|
374
|
+
const resp = await fetch(`${API_URL}/api/agenticmail/accounts`, {
|
|
375
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
376
|
+
});
|
|
377
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
378
|
+
localStorage.setItem('agenticmail.masterKey', key);
|
|
379
|
+
state.masterKey = key;
|
|
380
|
+
document.getElementById('auth').style.display = 'none';
|
|
381
|
+
document.getElementById('app').style.display = 'grid';
|
|
382
|
+
await bootstrap();
|
|
383
|
+
} catch (err) {
|
|
384
|
+
showAuthErr(`Sign-in failed: ${err.message}. Check the key and that the API is running on ${API_URL}.`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function showAuthErr(msg) {
|
|
388
|
+
const e = document.getElementById('auth-err');
|
|
389
|
+
e.textContent = msg; e.style.display = 'block';
|
|
390
|
+
}
|
|
391
|
+
function signOut() {
|
|
392
|
+
localStorage.removeItem('agenticmail.masterKey');
|
|
393
|
+
location.reload();
|
|
394
|
+
}
|
|
395
|
+
document.getElementById('auth-key').addEventListener('keydown', e => {
|
|
396
|
+
if (e.key === 'Enter') signIn();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
/* ─── API helpers ────────────────────────────────────────────────── */
|
|
400
|
+
async function apiGet(path, opts = {}) {
|
|
401
|
+
const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
|
|
402
|
+
headers: { Authorization: `Bearer ${opts.agentKey ?? state.masterKey}` },
|
|
403
|
+
});
|
|
404
|
+
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
|
405
|
+
return await r.json();
|
|
406
|
+
}
|
|
407
|
+
async function apiPost(path, body, opts = {}) {
|
|
408
|
+
const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers: {
|
|
411
|
+
'Content-Type': 'application/json',
|
|
412
|
+
Authorization: `Bearer ${opts.agentKey ?? state.masterKey}`,
|
|
413
|
+
},
|
|
414
|
+
body: JSON.stringify(body),
|
|
415
|
+
});
|
|
416
|
+
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
|
417
|
+
return await r.json();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* ─── Bootstrap ──────────────────────────────────────────────────── */
|
|
421
|
+
async function bootstrap() {
|
|
422
|
+
try {
|
|
423
|
+
const data = await apiGet('/accounts');
|
|
424
|
+
state.agents = (data.agents ?? data ?? []).sort((a, b) => a.name.localeCompare(b.name));
|
|
425
|
+
renderAgents();
|
|
426
|
+
// Auto-select first agent on load.
|
|
427
|
+
if (state.agents.length > 0) selectAgent(state.agents[0]);
|
|
428
|
+
populateComposeFrom();
|
|
429
|
+
subscribeToAllAgents();
|
|
430
|
+
} catch (err) {
|
|
431
|
+
toast(`Failed to load agents: ${err.message}`, true);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* ─── Agents sidebar ─────────────────────────────────────────────── */
|
|
436
|
+
function renderAgents() {
|
|
437
|
+
const root = document.getElementById('agent-list');
|
|
438
|
+
if (state.agents.length === 0) {
|
|
439
|
+
root.innerHTML = '<div class="empty">No agents yet. Create one with <code class="mono">agenticmail shell</code>.</div>';
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
root.innerHTML = state.agents.map(a => `
|
|
443
|
+
<div class="agent-row ${state.selectedAgent?.id === a.id ? 'selected' : ''}" data-id="${a.id}">
|
|
444
|
+
<span class="dot"></span>
|
|
445
|
+
<span class="name">${escapeHtml(a.name)}</span>
|
|
446
|
+
<span class="count" data-id="${a.id}" data-zero>0</span>
|
|
447
|
+
</div>
|
|
448
|
+
`).join('');
|
|
449
|
+
root.querySelectorAll('.agent-row').forEach(el => {
|
|
450
|
+
el.addEventListener('click', () => {
|
|
451
|
+
const id = el.dataset.id;
|
|
452
|
+
const agent = state.agents.find(a => a.id === id);
|
|
453
|
+
if (agent) selectAgent(agent);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
async function selectAgent(agent) {
|
|
458
|
+
state.selectedAgent = agent;
|
|
459
|
+
state.selectedUid = null;
|
|
460
|
+
state.currentMessage = null;
|
|
461
|
+
document.getElementById('inbox-title').textContent = `Inbox — ${agent.name}`;
|
|
462
|
+
document.querySelector('.pane-message #message-view').innerHTML = `
|
|
463
|
+
<div class="msg-empty"><div class="big">🎀</div><div>Select an email to read it here.</div></div>
|
|
464
|
+
`;
|
|
465
|
+
renderAgents();
|
|
466
|
+
await loadInbox(agent);
|
|
467
|
+
// Clear unread count badge on open.
|
|
468
|
+
setAgentUnread(agent.id, 0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* ─── Inbox list ─────────────────────────────────────────────────── */
|
|
472
|
+
async function loadInbox(agent) {
|
|
473
|
+
document.getElementById('inbox-list').innerHTML = '<div class="empty">Loading…</div>';
|
|
474
|
+
try {
|
|
475
|
+
const data = await apiGet('/mail/inbox?limit=50&offset=0', { agentKey: agent.apiKey });
|
|
476
|
+
state.inboxMessages = data.messages ?? [];
|
|
477
|
+
renderInbox();
|
|
478
|
+
} catch (err) {
|
|
479
|
+
document.getElementById('inbox-list').innerHTML = `<div class="empty">Failed to load: ${escapeHtml(err.message)}</div>`;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function renderInbox() {
|
|
483
|
+
const root = document.getElementById('inbox-list');
|
|
484
|
+
const q = state.searchQuery.toLowerCase();
|
|
485
|
+
const filtered = q
|
|
486
|
+
? state.inboxMessages.filter(m =>
|
|
487
|
+
(m.subject ?? '').toLowerCase().includes(q) ||
|
|
488
|
+
(m.from?.[0]?.address ?? '').toLowerCase().includes(q) ||
|
|
489
|
+
(m.from?.[0]?.name ?? '').toLowerCase().includes(q) ||
|
|
490
|
+
(m.preview ?? '').toLowerCase().includes(q))
|
|
491
|
+
: state.inboxMessages;
|
|
492
|
+
if (filtered.length === 0) {
|
|
493
|
+
root.innerHTML = '<div class="empty">Inbox is empty.</div>';
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
root.innerHTML = filtered.map(m => {
|
|
497
|
+
const unread = !(m.flags ?? []).includes('\\Seen');
|
|
498
|
+
const fromAddr = m.from?.[0]?.address ?? '?';
|
|
499
|
+
const fromName = m.from?.[0]?.name || fromAddr;
|
|
500
|
+
const subject = m.subject ?? '(no subject)';
|
|
501
|
+
const date = formatDate(m.date);
|
|
502
|
+
return `
|
|
503
|
+
<div class="inbox-row ${unread ? 'unread' : ''} ${state.selectedUid === m.uid ? 'selected' : ''}" data-uid="${m.uid}">
|
|
504
|
+
<div class="inbox-from">
|
|
505
|
+
<span class="name">${escapeHtml(fromName)}</span>
|
|
506
|
+
<span class="date">${escapeHtml(date)}</span>
|
|
507
|
+
</div>
|
|
508
|
+
<div class="subject">${escapeHtml(subject)}</div>
|
|
509
|
+
<div class="preview">${escapeHtml((m.preview ?? '').slice(0, 120))}</div>
|
|
510
|
+
</div>
|
|
511
|
+
`;
|
|
512
|
+
}).join('');
|
|
513
|
+
root.querySelectorAll('.inbox-row').forEach(el => {
|
|
514
|
+
el.addEventListener('click', () => openMessage(Number(el.dataset.uid)));
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/* ─── Message view ───────────────────────────────────────────────── */
|
|
519
|
+
async function openMessage(uid) {
|
|
520
|
+
if (!state.selectedAgent) return;
|
|
521
|
+
state.selectedUid = uid;
|
|
522
|
+
renderInbox();
|
|
523
|
+
document.querySelector('.pane-message #message-view').innerHTML = '<div class="msg-empty">Loading…</div>';
|
|
524
|
+
try {
|
|
525
|
+
const msg = await apiGet(`/mail/messages/${uid}`, { agentKey: state.selectedAgent.apiKey });
|
|
526
|
+
state.currentMessage = msg;
|
|
527
|
+
renderMessage(msg);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
document.querySelector('.pane-message #message-view').innerHTML =
|
|
530
|
+
`<div class="msg-empty"><div>Failed to load: ${escapeHtml(err.message)}</div></div>`;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function renderMessage(msg) {
|
|
534
|
+
const view = document.querySelector('.pane-message #message-view');
|
|
535
|
+
const fromStr = (msg.from ?? []).map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(', ') || '?';
|
|
536
|
+
const toStr = (msg.to ?? []).map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(', ') || '?';
|
|
537
|
+
const ccStr = (msg.cc ?? []).map(a => a.address).join(', ');
|
|
538
|
+
const attachmentsHtml = (msg.attachments ?? []).length > 0
|
|
539
|
+
? `<div class="msg-attachments">${msg.attachments.map(a =>
|
|
540
|
+
`<span class="msg-attachment">📎 ${escapeHtml(a.filename ?? '(unnamed)')} ${a.size ? `(${Math.round(a.size/1024)}KB)` : ''}</span>`
|
|
541
|
+
).join('')}</div>`
|
|
542
|
+
: '';
|
|
543
|
+
const bodyText = msg.text ?? stripHtml(msg.html ?? '');
|
|
544
|
+
view.innerHTML = `
|
|
545
|
+
<div class="msg-header">
|
|
546
|
+
<h2 class="msg-subject">${escapeHtml(msg.subject ?? '(no subject)')}</h2>
|
|
547
|
+
<div class="msg-meta">
|
|
548
|
+
<span class="label">From</span><span>${escapeHtml(fromStr)}</span>
|
|
549
|
+
<span class="label">To</span><span>${escapeHtml(toStr)}</span>
|
|
550
|
+
${ccStr ? `<span class="label">Cc</span><span>${escapeHtml(ccStr)}</span>` : ''}
|
|
551
|
+
<span class="label">Date</span><span class="date">${escapeHtml(formatDateFull(msg.date))}</span>
|
|
552
|
+
</div>
|
|
553
|
+
<div class="msg-actions">
|
|
554
|
+
<button class="btn" onclick="reply(false)">↩ Reply</button>
|
|
555
|
+
<button class="btn" onclick="reply(true)">↩↩ Reply all</button>
|
|
556
|
+
<button class="btn btn-ghost" onclick="markUnread()">Mark unread</button>
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="msg-body">${renderMarkdown(bodyText)}</div>
|
|
560
|
+
${attachmentsHtml}
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* ─── Markdown rendering (small, sufficient for agent emails) ────── */
|
|
565
|
+
function renderMarkdown(src) {
|
|
566
|
+
if (!src) return '<div class="empty">(no body)</div>';
|
|
567
|
+
// Convert quoted-reply chains to blockquotes BEFORE escaping so HTML
|
|
568
|
+
// tagging survives. Each quote level wraps in <blockquote>.
|
|
569
|
+
const lines = src.split('\n');
|
|
570
|
+
let out = '';
|
|
571
|
+
let codeFence = false;
|
|
572
|
+
let codeBuf = [];
|
|
573
|
+
let codeLang = '';
|
|
574
|
+
let listBuf = null; // { type: 'ul'|'ol', items: [...] }
|
|
575
|
+
let blockquoteDepth = 0;
|
|
576
|
+
function flushList() {
|
|
577
|
+
if (!listBuf) return;
|
|
578
|
+
out += `<${listBuf.type}>${listBuf.items.map(i => `<li>${i}</li>`).join('')}</${listBuf.type}>`;
|
|
579
|
+
listBuf = null;
|
|
580
|
+
}
|
|
581
|
+
function setBlockquote(depth) {
|
|
582
|
+
while (blockquoteDepth < depth) { out += '<blockquote>'; blockquoteDepth++; }
|
|
583
|
+
while (blockquoteDepth > depth) { out += '</blockquote>'; blockquoteDepth--; }
|
|
584
|
+
}
|
|
585
|
+
for (const rawLine of lines) {
|
|
586
|
+
// Fenced code block toggle.
|
|
587
|
+
const fence = rawLine.match(/^\s*```([\w+-]*)\s*$/);
|
|
588
|
+
if (fence) {
|
|
589
|
+
if (codeFence) {
|
|
590
|
+
out += `<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`;
|
|
591
|
+
codeBuf = []; codeFence = false; codeLang = '';
|
|
592
|
+
} else {
|
|
593
|
+
flushList();
|
|
594
|
+
codeFence = true; codeLang = fence[1];
|
|
595
|
+
}
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (codeFence) { codeBuf.push(rawLine); continue; }
|
|
599
|
+
// Quote depth.
|
|
600
|
+
let line = rawLine, depth = 0;
|
|
601
|
+
while (/^>/.test(line)) { depth++; line = line.replace(/^>\s?/, ''); }
|
|
602
|
+
if (depth !== blockquoteDepth) { flushList(); setBlockquote(depth); }
|
|
603
|
+
// Headings.
|
|
604
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
605
|
+
if (heading) {
|
|
606
|
+
flushList();
|
|
607
|
+
const level = heading[1].length;
|
|
608
|
+
out += `<h${Math.min(level, 6)}>${inlineMd(heading[2])}</h${Math.min(level, 6)}>`;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
// Horizontal rule.
|
|
612
|
+
if (/^\s*(?:-{3,}|_{3,}|\*{3,})\s*$/.test(line)) { flushList(); out += '<hr>'; continue; }
|
|
613
|
+
// Task list.
|
|
614
|
+
const task = line.match(/^(\s*)([-*+])\s+\[([ xX])\]\s+(.*)$/);
|
|
615
|
+
if (task) {
|
|
616
|
+
const checked = task[3] !== ' ';
|
|
617
|
+
const item = `<input type="checkbox" disabled ${checked ? 'checked' : ''}> ${inlineMd(task[4])}`;
|
|
618
|
+
if (!listBuf || listBuf.type !== 'ul') { flushList(); listBuf = { type: 'ul', items: [] }; }
|
|
619
|
+
listBuf.items.push(item);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
// Bullet list.
|
|
623
|
+
const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
|
|
624
|
+
if (bullet) {
|
|
625
|
+
if (!listBuf || listBuf.type !== 'ul') { flushList(); listBuf = { type: 'ul', items: [] }; }
|
|
626
|
+
listBuf.items.push(inlineMd(bullet[3]));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
// Numbered list.
|
|
630
|
+
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
|
|
631
|
+
if (numbered) {
|
|
632
|
+
if (!listBuf || listBuf.type !== 'ol') { flushList(); listBuf = { type: 'ol', items: [] }; }
|
|
633
|
+
listBuf.items.push(inlineMd(numbered[3]));
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
// Table (light support: pipe-bounded rows + separator).
|
|
637
|
+
if (/^\s*\|.*\|\s*$/.test(line)) {
|
|
638
|
+
flushList();
|
|
639
|
+
const cells = line.trim().slice(1, -1).split('|').map(c => inlineMd(c.trim()));
|
|
640
|
+
// Heuristic: separator row marks header → use <th>.
|
|
641
|
+
if (/^\s*\|?(\s*:?-{3,}:?\s*\|)+/.test(line)) {
|
|
642
|
+
// Skip the separator line.
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
out += `<table><tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr></table>`;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
// Blank line.
|
|
649
|
+
if (line.trim() === '') { flushList(); out += '<br>'; continue; }
|
|
650
|
+
// Paragraph (with inline markdown).
|
|
651
|
+
flushList();
|
|
652
|
+
out += `<div>${inlineMd(line)}</div>`;
|
|
653
|
+
}
|
|
654
|
+
flushList();
|
|
655
|
+
setBlockquote(0);
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
function inlineMd(text) {
|
|
659
|
+
// Escape first, then re-introduce safe HTML for markdown shapes.
|
|
660
|
+
let s = escapeHtml(text);
|
|
661
|
+
// Inline code.
|
|
662
|
+
s = s.replace(/`([^`\n]+)`/g, (_, c) => `<code>${c}</code>`);
|
|
663
|
+
// Bold+italic, bold, italic.
|
|
664
|
+
s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
665
|
+
s = s.replace(/___([^_\n]+)___/g, '<strong><em>$1</em></strong>');
|
|
666
|
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
667
|
+
s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
|
|
668
|
+
s = s.replace(/(^|[^\w*])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
|
|
669
|
+
s = s.replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
|
|
670
|
+
// Strikethrough.
|
|
671
|
+
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
|
|
672
|
+
// Links + auto-links.
|
|
673
|
+
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
674
|
+
s = s.replace(/<(https?:\/\/[^&\s]+)>/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
|
675
|
+
return s;
|
|
676
|
+
}
|
|
677
|
+
function stripHtml(s) { return (s ?? '').replace(/<[^>]*>/g, ''); }
|
|
678
|
+
|
|
679
|
+
/* ─── Date formatting ────────────────────────────────────────────── */
|
|
680
|
+
function formatDate(iso) {
|
|
681
|
+
if (!iso) return '';
|
|
682
|
+
const d = new Date(iso); if (Number.isNaN(d.getTime())) return '';
|
|
683
|
+
const now = new Date();
|
|
684
|
+
const sameDay = d.toDateString() === now.toDateString();
|
|
685
|
+
if (sameDay) return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
686
|
+
const days = Math.round((now - d) / (24 * 3600 * 1000));
|
|
687
|
+
if (days < 7) return d.toLocaleDateString(undefined, { weekday: 'short' });
|
|
688
|
+
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
689
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
690
|
+
}
|
|
691
|
+
function formatDateFull(iso) {
|
|
692
|
+
if (!iso) return '';
|
|
693
|
+
const d = new Date(iso); if (Number.isNaN(d.getTime())) return '';
|
|
694
|
+
const now = new Date();
|
|
695
|
+
const deltaMs = now - d;
|
|
696
|
+
let rel = '';
|
|
697
|
+
if (deltaMs < 45_000) rel = 'just now';
|
|
698
|
+
else if (deltaMs < 60 * 60 * 1000) rel = `${Math.round(deltaMs / 60_000)} minutes ago`;
|
|
699
|
+
else if (deltaMs < 24 * 60 * 60 * 1000) rel = `${Math.round(deltaMs / 3_600_000)} hours ago`;
|
|
700
|
+
const abs = d.toLocaleString(undefined, {
|
|
701
|
+
weekday: 'short', month: 'short', day: 'numeric',
|
|
702
|
+
hour: 'numeric', minute: '2-digit',
|
|
703
|
+
...(d.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}),
|
|
704
|
+
});
|
|
705
|
+
return rel ? `${rel} — ${abs}` : abs;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/* ─── Compose ────────────────────────────────────────────────────── */
|
|
709
|
+
function populateComposeFrom() {
|
|
710
|
+
const sel = document.getElementById('compose-from');
|
|
711
|
+
sel.innerHTML = state.agents.map(a => `<option value="${a.id}">${escapeHtml(a.name)} <${escapeHtml(a.email)}></option>`).join('');
|
|
712
|
+
}
|
|
713
|
+
function openCompose() {
|
|
714
|
+
state.composeReplyContext = null;
|
|
715
|
+
document.getElementById('compose-title').textContent = 'New message';
|
|
716
|
+
if (state.selectedAgent) document.getElementById('compose-from').value = state.selectedAgent.id;
|
|
717
|
+
['compose-to', 'compose-cc', 'compose-wake', 'compose-subject', 'compose-body'].forEach(id => document.getElementById(id).value = '');
|
|
718
|
+
document.getElementById('compose-bg').style.display = 'flex';
|
|
719
|
+
setTimeout(() => document.getElementById('compose-to').focus(), 50);
|
|
720
|
+
}
|
|
721
|
+
function closeCompose() { document.getElementById('compose-bg').style.display = 'none'; }
|
|
722
|
+
function reply(replyAll) {
|
|
723
|
+
if (!state.currentMessage) return;
|
|
724
|
+
const msg = state.currentMessage;
|
|
725
|
+
state.composeReplyContext = { uid: msg.uid, agent: state.selectedAgent, replyAll };
|
|
726
|
+
document.getElementById('compose-title').textContent = `Reply${replyAll ? ' all' : ''} to: ${msg.subject ?? '(no subject)'}`;
|
|
727
|
+
document.getElementById('compose-from').value = state.selectedAgent.id;
|
|
728
|
+
const fromAddr = msg.from?.[0]?.address ?? '';
|
|
729
|
+
let toAddr = fromAddr;
|
|
730
|
+
if (replyAll) {
|
|
731
|
+
const all = [fromAddr, ...(msg.to ?? []).map(a => a.address), ...(msg.cc ?? []).map(a => a.address)]
|
|
732
|
+
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i)
|
|
733
|
+
.filter(addr => addr !== state.selectedAgent.email);
|
|
734
|
+
toAddr = all.join(', ');
|
|
735
|
+
}
|
|
736
|
+
document.getElementById('compose-to').value = toAddr;
|
|
737
|
+
document.getElementById('compose-cc').value = '';
|
|
738
|
+
document.getElementById('compose-wake').value = '';
|
|
739
|
+
document.getElementById('compose-subject').value =
|
|
740
|
+
(msg.subject ?? '').startsWith('Re:') ? msg.subject : `Re: ${msg.subject ?? ''}`;
|
|
741
|
+
const quoted = (msg.text ?? '').split('\n').map(l => `> ${l}`).join('\n');
|
|
742
|
+
const stub = `\n\nOn ${msg.date}, ${fromAddr} wrote:\n${quoted}`;
|
|
743
|
+
document.getElementById('compose-body').value = stub;
|
|
744
|
+
document.getElementById('compose-bg').style.display = 'flex';
|
|
745
|
+
setTimeout(() => document.getElementById('compose-body').focus(), 50);
|
|
746
|
+
}
|
|
747
|
+
async function sendCompose() {
|
|
748
|
+
const agentId = document.getElementById('compose-from').value;
|
|
749
|
+
const agent = state.agents.find(a => a.id === agentId);
|
|
750
|
+
if (!agent) return toast('Pick an agent to send from.', true);
|
|
751
|
+
const to = document.getElementById('compose-to').value.trim();
|
|
752
|
+
const subject = document.getElementById('compose-subject').value.trim();
|
|
753
|
+
const text = document.getElementById('compose-body').value;
|
|
754
|
+
const cc = document.getElementById('compose-cc').value.trim();
|
|
755
|
+
const wakeRaw = document.getElementById('compose-wake').value.trim();
|
|
756
|
+
if (!to || !subject) return toast('To and Subject are required.', true);
|
|
757
|
+
const body = { to, subject, text };
|
|
758
|
+
if (cc) body.cc = cc;
|
|
759
|
+
if (wakeRaw) body.wake = wakeRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
760
|
+
try {
|
|
761
|
+
await apiPost('/mail/send', body, { agentKey: agent.apiKey });
|
|
762
|
+
closeCompose();
|
|
763
|
+
toast('Sent.');
|
|
764
|
+
if (state.selectedAgent?.id === agent.id) await loadInbox(agent);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
toast(`Send failed: ${err.message}`, true);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/* ─── Mark unread ────────────────────────────────────────────────── */
|
|
771
|
+
async function markUnread() {
|
|
772
|
+
if (!state.currentMessage || !state.selectedAgent) return;
|
|
773
|
+
try {
|
|
774
|
+
await apiPost(`/mail/messages/${state.currentMessage.uid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
|
|
775
|
+
toast('Marked unread.');
|
|
776
|
+
await loadInbox(state.selectedAgent);
|
|
777
|
+
} catch (err) { toast(`Failed: ${err.message}`, true); }
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/* ─── Refresh ────────────────────────────────────────────────────── */
|
|
781
|
+
async function refresh() {
|
|
782
|
+
if (state.selectedAgent) {
|
|
783
|
+
await loadInbox(state.selectedAgent);
|
|
784
|
+
toast('Refreshed.');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/* ─── Search ─────────────────────────────────────────────────────── */
|
|
789
|
+
document.getElementById('search-input').addEventListener('input', e => {
|
|
790
|
+
state.searchQuery = e.target.value;
|
|
791
|
+
renderInbox();
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
/* ─── Real-time SSE (per-agent) ─────────────────────────────────── */
|
|
795
|
+
function subscribeToAllAgents() {
|
|
796
|
+
// Tear down any previous controllers.
|
|
797
|
+
for (const c of state.sseControllers) { try { c.abort(); } catch {} }
|
|
798
|
+
state.sseControllers = [];
|
|
799
|
+
for (const agent of state.agents) {
|
|
800
|
+
const ctrl = new AbortController();
|
|
801
|
+
state.sseControllers.push(ctrl);
|
|
802
|
+
fetch(`${API_URL}/api/agenticmail/events`, {
|
|
803
|
+
headers: { Authorization: `Bearer ${agent.apiKey}`, Accept: 'text/event-stream' },
|
|
804
|
+
signal: ctrl.signal,
|
|
805
|
+
}).then(async res => {
|
|
806
|
+
if (!res.ok || !res.body) return;
|
|
807
|
+
const reader = res.body.getReader();
|
|
808
|
+
const dec = new TextDecoder();
|
|
809
|
+
let buf = '';
|
|
810
|
+
while (!ctrl.signal.aborted) {
|
|
811
|
+
const { done, value } = await reader.read();
|
|
812
|
+
if (done) break;
|
|
813
|
+
buf += dec.decode(value, { stream: true });
|
|
814
|
+
let i;
|
|
815
|
+
while ((i = buf.indexOf('\n\n')) !== -1) {
|
|
816
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
817
|
+
for (const line of frame.split('\n')) {
|
|
818
|
+
if (!line.startsWith('data: ')) continue;
|
|
819
|
+
try { handleSseEvent(agent, JSON.parse(line.slice(6))); } catch {}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}).catch(() => { /* connection dropped */ });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function handleSseEvent(agent, event) {
|
|
827
|
+
if (event.type !== 'new') return;
|
|
828
|
+
// Bump unread badge on the agent row.
|
|
829
|
+
const el = document.querySelector(`.count[data-id="${agent.id}"]`);
|
|
830
|
+
if (el) {
|
|
831
|
+
const n = parseInt(el.textContent || '0', 10) + 1;
|
|
832
|
+
el.textContent = String(n);
|
|
833
|
+
el.removeAttribute('data-zero');
|
|
834
|
+
}
|
|
835
|
+
// If this agent's inbox is open, reload it.
|
|
836
|
+
if (state.selectedAgent?.id === agent.id) loadInbox(agent);
|
|
837
|
+
}
|
|
838
|
+
function setAgentUnread(agentId, n) {
|
|
839
|
+
const el = document.querySelector(`.count[data-id="${agentId}"]`);
|
|
840
|
+
if (!el) return;
|
|
841
|
+
el.textContent = String(n);
|
|
842
|
+
if (n === 0) el.setAttribute('data-zero', ''); else el.removeAttribute('data-zero');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/* ─── Toast ──────────────────────────────────────────────────────── */
|
|
846
|
+
function toast(msg, error = false) {
|
|
847
|
+
const t = document.getElementById('toast');
|
|
848
|
+
t.textContent = msg;
|
|
849
|
+
t.classList.toggle('error', error);
|
|
850
|
+
t.classList.add('show');
|
|
851
|
+
clearTimeout(t._timer);
|
|
852
|
+
t._timer = setTimeout(() => t.classList.remove('show'), 2500);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/* ─── Utils ──────────────────────────────────────────────────────── */
|
|
856
|
+
function escapeHtml(s) {
|
|
857
|
+
return String(s ?? '')
|
|
858
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
859
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/* ─── Keyboard shortcuts ─────────────────────────────────────────── */
|
|
863
|
+
document.addEventListener('keydown', e => {
|
|
864
|
+
if (document.getElementById('compose-bg').style.display !== 'none') return;
|
|
865
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
866
|
+
if (e.key === 'r') { refresh(); }
|
|
867
|
+
if (e.key === 'c') { openCompose(); }
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
/* ─── Boot ───────────────────────────────────────────────────────── */
|
|
871
|
+
(() => {
|
|
872
|
+
const saved = localStorage.getItem('agenticmail.masterKey');
|
|
873
|
+
if (saved) {
|
|
874
|
+
state.masterKey = saved;
|
|
875
|
+
document.getElementById('auth').style.display = 'none';
|
|
876
|
+
document.getElementById('app').style.display = 'grid';
|
|
877
|
+
bootstrap();
|
|
878
|
+
}
|
|
879
|
+
})();
|
|
880
|
+
</script>
|
|
881
|
+
</body>
|
|
882
|
+
</html>
|