@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/js/api.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Tiny fetch wrapper that injects the auth header. Every API call goes
|
|
2
|
+
// through here so the master key (or per-agent key) is applied
|
|
3
|
+
// consistently and errors surface as plain Error throws.
|
|
4
|
+
import { state, API_URL } from './state.js';
|
|
5
|
+
|
|
6
|
+
export async function apiGet(path, opts = {}) {
|
|
7
|
+
const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
|
|
8
|
+
headers: { Authorization: `Bearer ${opts.agentKey ?? state.masterKey}` },
|
|
9
|
+
});
|
|
10
|
+
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
|
11
|
+
return await r.json();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function apiPost(path, body, opts = {}) {
|
|
15
|
+
const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
Authorization: `Bearer ${opts.agentKey ?? state.masterKey}`,
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
|
24
|
+
return await r.json();
|
|
25
|
+
}
|
package/public/js/app.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Main entry — wires the modules, runs auth, and drives the
|
|
2
|
+
// hash-based router.
|
|
3
|
+
//
|
|
4
|
+
// Routes:
|
|
5
|
+
// #/inbox → folder list view (active folder lives in state)
|
|
6
|
+
// #/m/:uid → single-message view
|
|
7
|
+
import { state, API_URL } from './state.js';
|
|
8
|
+
import { toast } from './utils.js';
|
|
9
|
+
import { apiGet } from './api.js';
|
|
10
|
+
import { isBridgeAgent } from './avatar.js';
|
|
11
|
+
import { renderProfile, toggleProfileMenu, closeProfileMenu } from './profile.js';
|
|
12
|
+
import { renderSidebar } from './sidebar.js';
|
|
13
|
+
import { loadList, renderList, clearSearch } from './list-view.js';
|
|
14
|
+
import { openMessage } from './message-view.js';
|
|
15
|
+
import { populateComposeFrom, openCompose, closeCompose, sendCompose } from './compose.js';
|
|
16
|
+
import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
|
|
17
|
+
import { icon } from './icons.js';
|
|
18
|
+
|
|
19
|
+
// Hydrate every `data-icon="name"` placeholder in the static HTML
|
|
20
|
+
// with the corresponding inline SVG. Done once on load so we don't
|
|
21
|
+
// keep emojis around as fallback.
|
|
22
|
+
function hydrateIcons(root = document) {
|
|
23
|
+
root.querySelectorAll('[data-icon]').forEach(el => {
|
|
24
|
+
const name = el.dataset.icon;
|
|
25
|
+
const size = el.dataset.iconSize ? Number(el.dataset.iconSize) : undefined;
|
|
26
|
+
el.innerHTML = icon(name, size ? { size } : {});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
hydrateIcons();
|
|
30
|
+
|
|
31
|
+
// ─── Auth ────────────────────────────────────────────────────────────
|
|
32
|
+
const authApiUrl = document.getElementById('auth-api-url');
|
|
33
|
+
if (authApiUrl) authApiUrl.textContent = API_URL;
|
|
34
|
+
|
|
35
|
+
async function signIn() {
|
|
36
|
+
const key = document.getElementById('auth-key').value.trim();
|
|
37
|
+
if (!key) return showAuthErr('Master key is required.');
|
|
38
|
+
try {
|
|
39
|
+
const resp = await fetch(`${API_URL}/api/agenticmail/accounts`, {
|
|
40
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
41
|
+
});
|
|
42
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
43
|
+
localStorage.setItem('agenticmail.masterKey', key);
|
|
44
|
+
state.masterKey = key;
|
|
45
|
+
document.getElementById('auth').style.display = 'none';
|
|
46
|
+
document.getElementById('app').style.display = 'grid';
|
|
47
|
+
await bootstrap();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
showAuthErr(`Sign-in failed: ${err.message}. Check the key and that the API is running on ${API_URL}.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function showAuthErr(msg) {
|
|
53
|
+
const e = document.getElementById('auth-err');
|
|
54
|
+
e.textContent = msg; e.style.display = 'block';
|
|
55
|
+
}
|
|
56
|
+
function signOut() {
|
|
57
|
+
localStorage.removeItem('agenticmail.masterKey');
|
|
58
|
+
location.reload();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
document.getElementById('auth-submit').addEventListener('click', signIn);
|
|
62
|
+
document.getElementById('auth-key').addEventListener('keydown', e => {
|
|
63
|
+
if (e.key === 'Enter') signIn();
|
|
64
|
+
});
|
|
65
|
+
document.getElementById('signout-link').addEventListener('click', signOut);
|
|
66
|
+
|
|
67
|
+
// ─── Bootstrap ───────────────────────────────────────────────────────
|
|
68
|
+
async function bootstrap() {
|
|
69
|
+
try {
|
|
70
|
+
const data = await apiGet('/accounts');
|
|
71
|
+
const all = (data.agents ?? data ?? []);
|
|
72
|
+
// Bridge agent pinned to top of switcher; everyone else alphabetical.
|
|
73
|
+
all.sort((a, b) => {
|
|
74
|
+
const aBridge = isBridgeAgent(a) ? 0 : 1;
|
|
75
|
+
const bBridge = isBridgeAgent(b) ? 0 : 1;
|
|
76
|
+
if (aBridge !== bBridge) return aBridge - bBridge;
|
|
77
|
+
return a.name.localeCompare(b.name);
|
|
78
|
+
});
|
|
79
|
+
state.agents = all;
|
|
80
|
+
const initial = state.agents.find(isBridgeAgent) ?? state.agents[0];
|
|
81
|
+
if (initial) await selectAgent(initial);
|
|
82
|
+
renderProfile();
|
|
83
|
+
populateComposeFrom();
|
|
84
|
+
subscribeToAllAgents();
|
|
85
|
+
maybeRequestNotificationPermission();
|
|
86
|
+
if (!location.hash) location.hash = '#/inbox';
|
|
87
|
+
else route();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
toast(`Failed to load agents: ${err.message}`, true);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function selectAgent(agent) {
|
|
94
|
+
state.selectedAgent = agent;
|
|
95
|
+
state.selectedUid = null;
|
|
96
|
+
state.currentMessage = null;
|
|
97
|
+
renderSidebar(onFolderSelect);
|
|
98
|
+
renderProfile();
|
|
99
|
+
await loadList(agent, state.selectedFolder);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function onFolderSelect(folder) {
|
|
103
|
+
state.selectedFolder = folder;
|
|
104
|
+
renderSidebar(onFolderSelect);
|
|
105
|
+
location.hash = '#/inbox'; // any folder uses the list route
|
|
106
|
+
if (state.selectedAgent) loadList(state.selectedAgent, folder);
|
|
107
|
+
// On mobile (the only viewport where the sidebar is over-canvas),
|
|
108
|
+
// close it after a folder pick so the user sees the list.
|
|
109
|
+
document.getElementById('main')?.classList.remove('sidebar-open');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Hash router ─────────────────────────────────────────────────────
|
|
113
|
+
function route() {
|
|
114
|
+
const hash = location.hash || '#/inbox';
|
|
115
|
+
const msgMatch = hash.match(/^#\/m\/(\d+)$/);
|
|
116
|
+
if (msgMatch) {
|
|
117
|
+
openMessage(Number(msgMatch[1]));
|
|
118
|
+
} else if (state.selectedAgent) {
|
|
119
|
+
loadList(state.selectedAgent, state.selectedFolder);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
window.addEventListener('hashchange', route);
|
|
123
|
+
|
|
124
|
+
// ─── Top bar wiring ──────────────────────────────────────────────────
|
|
125
|
+
// Hamburger toggles the sidebar on mobile. On desktop the sidebar
|
|
126
|
+
// is always visible; the class only changes anything below 800 px,
|
|
127
|
+
// where the CSS slides it off-canvas by default.
|
|
128
|
+
function toggleSidebar() {
|
|
129
|
+
const main = document.getElementById('main');
|
|
130
|
+
main?.classList.toggle('sidebar-open');
|
|
131
|
+
}
|
|
132
|
+
document.getElementById('menu-btn').addEventListener('click', toggleSidebar);
|
|
133
|
+
document.getElementById('sidebar-backdrop').addEventListener('click', () => {
|
|
134
|
+
document.getElementById('main')?.classList.remove('sidebar-open');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
document.getElementById('refresh-btn').addEventListener('click', async () => {
|
|
138
|
+
if (state.selectedAgent) {
|
|
139
|
+
await loadList(state.selectedAgent, state.selectedFolder);
|
|
140
|
+
toast('Refreshed.');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
document.getElementById('compose-btn').addEventListener('click', openCompose);
|
|
144
|
+
document.getElementById('profile-btn').addEventListener('click', toggleProfileMenu);
|
|
145
|
+
document.getElementById('profile-menu').addEventListener('click', e => {
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
const item = e.target.closest('.profile-menu-item');
|
|
148
|
+
if (!item) return;
|
|
149
|
+
const agent = state.agents.find(a => a.id === item.dataset.id);
|
|
150
|
+
if (agent && agent.id !== state.selectedAgent?.id) selectAgent(agent);
|
|
151
|
+
closeProfileMenu();
|
|
152
|
+
});
|
|
153
|
+
document.addEventListener('click', e => {
|
|
154
|
+
const menu = document.getElementById('profile-menu');
|
|
155
|
+
const btn = document.getElementById('profile-btn');
|
|
156
|
+
if (!menu || !btn) return;
|
|
157
|
+
if (!menu.contains(e.target) && !btn.contains(e.target)) closeProfileMenu();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ─── Compose modal wiring ────────────────────────────────────────────
|
|
161
|
+
document.getElementById('compose-close').addEventListener('click', closeCompose);
|
|
162
|
+
document.getElementById('compose-cancel').addEventListener('click', closeCompose);
|
|
163
|
+
document.getElementById('compose-send').addEventListener('click', sendCompose);
|
|
164
|
+
document.getElementById('compose-bg').addEventListener('click', e => {
|
|
165
|
+
if (e.target.id === 'compose-bg') closeCompose();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ─── Search (debounced, Esc clears) ─────────────────────────────────
|
|
169
|
+
let searchDebounce = null;
|
|
170
|
+
const searchInput = document.getElementById('search-input');
|
|
171
|
+
searchInput.addEventListener('input', e => {
|
|
172
|
+
const v = e.target.value;
|
|
173
|
+
e.target.classList.toggle('has-query', v.length > 0);
|
|
174
|
+
document.getElementById('search-clear').classList.toggle('show', v.length > 0);
|
|
175
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
176
|
+
searchDebounce = setTimeout(() => {
|
|
177
|
+
state.searchQuery = v;
|
|
178
|
+
renderList();
|
|
179
|
+
}, 80);
|
|
180
|
+
});
|
|
181
|
+
searchInput.addEventListener('keydown', e => {
|
|
182
|
+
if (e.key === 'Escape') { e.preventDefault(); clearSearch(); }
|
|
183
|
+
});
|
|
184
|
+
document.getElementById('search-clear').addEventListener('click', clearSearch);
|
|
185
|
+
|
|
186
|
+
// ─── Keyboard shortcuts (Gmail-style) ───────────────────────────────
|
|
187
|
+
// r refresh current inbox
|
|
188
|
+
// c compose new
|
|
189
|
+
// / focus the search box
|
|
190
|
+
//
|
|
191
|
+
// IMPORTANT: every shortcut bails when ANY modifier key is held
|
|
192
|
+
// (Cmd / Ctrl / Alt / Meta) — otherwise Cmd+C "copy" was opening
|
|
193
|
+
// the compose modal, Cmd+R was overriding browser refresh, etc.
|
|
194
|
+
// Plain unmodified single-key shortcuts only.
|
|
195
|
+
document.addEventListener('keydown', e => {
|
|
196
|
+
if (document.getElementById('compose-bg').style.display !== 'none') return;
|
|
197
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
198
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return; // never hijack OS shortcuts
|
|
199
|
+
if (e.key === 'r') document.getElementById('refresh-btn').click();
|
|
200
|
+
else if (e.key === 'c') openCompose();
|
|
201
|
+
else if (e.key === '/') {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
searchInput.focus();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ─── Boot ───────────────────────────────────────────────────────────
|
|
208
|
+
(() => {
|
|
209
|
+
// Accept `?key=...` from the CLI's `agenticmail web` command, then
|
|
210
|
+
// strip it from the URL so it doesn't leak via Referer / history /
|
|
211
|
+
// screen shares. Safe because the URL is loopback-only.
|
|
212
|
+
try {
|
|
213
|
+
const params = new URL(location.href).searchParams;
|
|
214
|
+
const urlKey = params.get('key');
|
|
215
|
+
if (urlKey) {
|
|
216
|
+
localStorage.setItem('agenticmail.masterKey', urlKey);
|
|
217
|
+
history.replaceState({}, '', location.pathname + location.hash);
|
|
218
|
+
}
|
|
219
|
+
} catch {}
|
|
220
|
+
|
|
221
|
+
const saved = localStorage.getItem('agenticmail.masterKey');
|
|
222
|
+
if (saved) {
|
|
223
|
+
state.masterKey = saved;
|
|
224
|
+
document.getElementById('auth').style.display = 'none';
|
|
225
|
+
document.getElementById('app').style.display = 'grid';
|
|
226
|
+
bootstrap();
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Agent identity + avatar helpers.
|
|
2
|
+
//
|
|
3
|
+
// The bridge agent (default name "claudecode") is the host's identity
|
|
4
|
+
// inside AgenticMail. We render it with the OFFICIAL Claude starburst
|
|
5
|
+
// mark (sourced from the public Wikipedia SVG, served as a static
|
|
6
|
+
// asset under /branding/claude-mark.svg) and a green verified-tick so
|
|
7
|
+
// the host inbox is recognisable at a glance vs. teammate sub-agents.
|
|
8
|
+
import { escapeHtml } from './utils.js';
|
|
9
|
+
import { icon } from './icons.js';
|
|
10
|
+
|
|
11
|
+
// Official Claude mark, served as a static asset under /branding/.
|
|
12
|
+
// Using <img src=...> rather than inlining the path keeps the SVG
|
|
13
|
+
// out of every avatar render and lets the browser cache the asset.
|
|
14
|
+
const CLAUDE_MARK_URL = '/branding/claude-mark.svg';
|
|
15
|
+
|
|
16
|
+
export function isBridgeAgent(agent) {
|
|
17
|
+
if (!agent) return false;
|
|
18
|
+
const name = (agent.name ?? '').toLowerCase();
|
|
19
|
+
const role = (agent.role ?? '').toLowerCase();
|
|
20
|
+
return name === 'claudecode' || name === 'claude' || role === 'bridge';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Deterministic colour per agent name — keeps teammate colours stable
|
|
24
|
+
// across sessions and reloads.
|
|
25
|
+
const AVATAR_PALETTE = [
|
|
26
|
+
'#ec4899', '#8b5cf6', '#3b82f6', '#06b6d4',
|
|
27
|
+
'#10b981', '#f59e0b', '#ef4444', '#84cc16',
|
|
28
|
+
];
|
|
29
|
+
function avatarColorFor(name) {
|
|
30
|
+
let hash = 0;
|
|
31
|
+
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
|
32
|
+
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function avatarHtml(agent, size = '') {
|
|
36
|
+
const cls = `avatar ${size}`.trim();
|
|
37
|
+
if (isBridgeAgent(agent)) {
|
|
38
|
+
return `<span class="${cls} avatar-host"><img src="${CLAUDE_MARK_URL}" alt="Claude" class="avatar-img" /><span class="avatar-check">${icon('check', { size: 10 })}</span></span>`;
|
|
39
|
+
}
|
|
40
|
+
const initial = (agent.name ?? '?').slice(0, 1).toUpperCase();
|
|
41
|
+
const color = avatarColorFor(agent.name ?? '');
|
|
42
|
+
return `<span class="${cls}" style="background:${color}">${escapeHtml(initial)}</span>`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Gmail-style bottom-right compose popup. Handles both new-message
|
|
2
|
+
// and reply flows. `wake` is the AgenticMail selective-wake hint.
|
|
3
|
+
import { state } from './state.js';
|
|
4
|
+
import { escapeHtml, toast } from './utils.js';
|
|
5
|
+
import { apiPost } from './api.js';
|
|
6
|
+
import { loadList } from './list-view.js';
|
|
7
|
+
|
|
8
|
+
export function populateComposeFrom() {
|
|
9
|
+
const sel = document.getElementById('compose-from');
|
|
10
|
+
sel.innerHTML = state.agents
|
|
11
|
+
.map(a => `<option value="${a.id}">${escapeHtml(a.name)} <${escapeHtml(a.email)}></option>`)
|
|
12
|
+
.join('');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function openCompose() {
|
|
16
|
+
state.composeReplyContext = null;
|
|
17
|
+
document.getElementById('compose-title').textContent = 'New message';
|
|
18
|
+
if (state.selectedAgent) document.getElementById('compose-from').value = state.selectedAgent.id;
|
|
19
|
+
['compose-to', 'compose-cc', 'compose-wake', 'compose-subject', 'compose-body']
|
|
20
|
+
.forEach(id => { document.getElementById(id).value = ''; });
|
|
21
|
+
showModal();
|
|
22
|
+
setTimeout(() => document.getElementById('compose-to').focus(), 50);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function openReply(replyAll) {
|
|
26
|
+
if (!state.currentMessage) return;
|
|
27
|
+
const msg = state.currentMessage;
|
|
28
|
+
state.composeReplyContext = { uid: msg.uid, agent: state.selectedAgent, replyAll };
|
|
29
|
+
document.getElementById('compose-title').textContent =
|
|
30
|
+
`Reply${replyAll ? ' all' : ''}: ${msg.subject ?? '(no subject)'}`;
|
|
31
|
+
document.getElementById('compose-from').value = state.selectedAgent.id;
|
|
32
|
+
const fromAddr = msg.from?.[0]?.address ?? '';
|
|
33
|
+
let toAddr = fromAddr;
|
|
34
|
+
if (replyAll) {
|
|
35
|
+
const all = [fromAddr, ...(msg.to ?? []).map(a => a.address), ...(msg.cc ?? []).map(a => a.address)]
|
|
36
|
+
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i)
|
|
37
|
+
.filter(addr => addr !== state.selectedAgent.email);
|
|
38
|
+
toAddr = all.join(', ');
|
|
39
|
+
}
|
|
40
|
+
document.getElementById('compose-to').value = toAddr;
|
|
41
|
+
document.getElementById('compose-cc').value = '';
|
|
42
|
+
document.getElementById('compose-wake').value = '';
|
|
43
|
+
document.getElementById('compose-subject').value =
|
|
44
|
+
(msg.subject ?? '').startsWith('Re:') ? msg.subject : `Re: ${msg.subject ?? ''}`;
|
|
45
|
+
const quoted = (msg.text ?? '').split('\n').map(l => `> ${l}`).join('\n');
|
|
46
|
+
const stub = `\n\nOn ${msg.date}, ${fromAddr} wrote:\n${quoted}`;
|
|
47
|
+
document.getElementById('compose-body').value = stub;
|
|
48
|
+
showModal();
|
|
49
|
+
setTimeout(() => document.getElementById('compose-body').focus(), 50);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function closeCompose() {
|
|
53
|
+
document.getElementById('compose-bg').style.display = 'none';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function showModal() {
|
|
57
|
+
document.getElementById('compose-bg').style.display = 'flex';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function sendCompose() {
|
|
61
|
+
const agentId = document.getElementById('compose-from').value;
|
|
62
|
+
const agent = state.agents.find(a => a.id === agentId);
|
|
63
|
+
if (!agent) return toast('Pick an agent to send from.', true);
|
|
64
|
+
const to = document.getElementById('compose-to').value.trim();
|
|
65
|
+
const subject = document.getElementById('compose-subject').value.trim();
|
|
66
|
+
const text = document.getElementById('compose-body').value;
|
|
67
|
+
const cc = document.getElementById('compose-cc').value.trim();
|
|
68
|
+
const wakeRaw = document.getElementById('compose-wake').value.trim();
|
|
69
|
+
if (!to || !subject) return toast('To and Subject are required.', true);
|
|
70
|
+
const body = { to, subject, text };
|
|
71
|
+
if (cc) body.cc = cc;
|
|
72
|
+
if (wakeRaw) body.wake = wakeRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
73
|
+
try {
|
|
74
|
+
await apiPost('/mail/send', body, { agentKey: agent.apiKey });
|
|
75
|
+
closeCompose();
|
|
76
|
+
toast('Sent.');
|
|
77
|
+
if (state.selectedAgent?.id === agent.id) await loadList(agent, state.selectedFolder);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
toast(`Send failed: ${err.message}`, true);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Tiny SVG icon library — inline so there are no extra HTTP requests
|
|
2
|
+
// and icons inherit `color` via `fill="currentColor"`.
|
|
3
|
+
//
|
|
4
|
+
// Paths are 24x24 Material-Symbols-style outlines. Single-source so
|
|
5
|
+
// every emoji in the UI can be swapped for a proper vector glyph.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// icon('inbox') // 24x24 default
|
|
9
|
+
// icon('search', { size: 20 }) // override pixel size
|
|
10
|
+
// icon('star', { class: 'starred' }) // extra class on the <svg>
|
|
11
|
+
|
|
12
|
+
const PATHS = {
|
|
13
|
+
// ─── Navigation / chrome ─────────────────────────────────────────
|
|
14
|
+
menu: 'M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z',
|
|
15
|
+
search: 'M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z',
|
|
16
|
+
refresh: 'M17.65 6.35A7.95 7.95 0 0 0 12 4a8 8 0 1 0 7.73 10h-2.08A6 6 0 1 1 12 6a5.88 5.88 0 0 1 4.22 1.78L13 11h7V4l-2.35 2.35z',
|
|
17
|
+
close: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z',
|
|
18
|
+
back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
|
|
19
|
+
caret: 'M7 10l5 5 5-5H7z',
|
|
20
|
+
|
|
21
|
+
// ─── Actions ────────────────────────────────────────────────────
|
|
22
|
+
compose: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z',
|
|
23
|
+
send: 'M2.01 21 23 12 2.01 3 2 10l15 2-15 2z',
|
|
24
|
+
reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',
|
|
25
|
+
replyAll: 'M7 8V5l-7 7 7 7v-3l-4-4 4-4zm6 1V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',
|
|
26
|
+
mailUnread: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z',
|
|
27
|
+
attachment: 'M16.5 6v11.5a4 4 0 0 1-8 0V5a2.5 2.5 0 0 1 5 0v10.5a1 1 0 0 1-2 0V6H10v9.5a2.5 2.5 0 0 0 5 0V5a4 4 0 0 0-8 0v12.5a5.5 5.5 0 0 0 11 0V6h-1.5z',
|
|
28
|
+
|
|
29
|
+
// ─── Stars ──────────────────────────────────────────────────────
|
|
30
|
+
starOutline: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.64-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z',
|
|
31
|
+
starFilled: 'M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27z',
|
|
32
|
+
|
|
33
|
+
// ─── Sidebar folders ────────────────────────────────────────────
|
|
34
|
+
inbox: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H5V5h14v10z',
|
|
35
|
+
sent: 'M2.01 21 23 12 2.01 3 2 10l15 2-15 2z',
|
|
36
|
+
drafts: 'M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2v-7l-8 5-8-5V5l8 5 8-5v2h2V5a2 2 0 0 0-2-2z',
|
|
37
|
+
allMail: 'M22 4h-2v9.38l-2.79-2.79L16 12l4 4 4-4-1.21-1.21L22 13.38V4zM4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8h-2v10H4V6h12V4H4z',
|
|
38
|
+
spam: 'M12 2 1 21h22L12 2zm1 14h-2v-2h2v2zm0-4h-2V8h2v4z',
|
|
39
|
+
trash: 'M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z',
|
|
40
|
+
|
|
41
|
+
// ─── Branding glyph (bow) ───────────────────────────────────────
|
|
42
|
+
// Stylised pink bow, used in place of the 🎀 emoji.
|
|
43
|
+
bow: 'M12 12c-2-3-5-5-8-5-1 0-2 1-2 2s1 2 2 2c2 0 4 1 5 2-1 1-3 2-5 2-1 0-2 1-2 2s1 2 2 2c3 0 6-2 8-5 2 3 5 5 8 5 1 0 2-1 2-2s-1-2-2-2c-2 0-4-1-5-2 1-1 3-2 5-2 1 0 2-1 2-2s-1-2-2-2c-3 0-6 2-8 5zm0 2a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z',
|
|
44
|
+
|
|
45
|
+
// ─── Status / dots ──────────────────────────────────────────────
|
|
46
|
+
dot: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
|
|
47
|
+
check: 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function icon(name, opts = {}) {
|
|
51
|
+
const path = PATHS[name];
|
|
52
|
+
if (!path) return '';
|
|
53
|
+
const size = opts.size ?? 20;
|
|
54
|
+
const cls = `icon-svg ${opts.class ?? ''}`.trim();
|
|
55
|
+
return `<svg class="${cls}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="${path}"/></svg>`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Gmail-style message-list view. One row per email; click to open in
|
|
2
|
+
// the message view. Search filters and inline highlighting run here.
|
|
3
|
+
import { state } from './state.js';
|
|
4
|
+
import { escapeHtml, toast } from './utils.js';
|
|
5
|
+
import { formatDate } from './time.js';
|
|
6
|
+
import { parseSearch, matchesSearch, highlightTerm } from './search.js';
|
|
7
|
+
import { apiGet } from './api.js';
|
|
8
|
+
import { FOLDERS } from './sidebar.js';
|
|
9
|
+
import { icon } from './icons.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Defensive flag check. The API's IMAP layer returns `flags` as an
|
|
13
|
+
* array of strings most of the time (`['\\Seen', '\\Flagged']`) but
|
|
14
|
+
* some envelopes come back with a Set-like serialisation or even an
|
|
15
|
+
* object map. Without this guard, calling `.includes()` on a non-
|
|
16
|
+
* array crashed the list with "(m.flags ?? []).includes is not a
|
|
17
|
+
* function". Coerce everything we don't recognise to an empty list.
|
|
18
|
+
*/
|
|
19
|
+
function flagsHas(flags, name) {
|
|
20
|
+
if (Array.isArray(flags)) return flags.includes(name);
|
|
21
|
+
if (flags && typeof flags === 'object') {
|
|
22
|
+
// `{Seen: true, Flagged: false}` shape — try both with and
|
|
23
|
+
// without the leading backslash since callers can mean either.
|
|
24
|
+
const key = name.replace(/^\\/, '');
|
|
25
|
+
return flags[name] === true || flags[key] === true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Map sidebar folder ids to the actual IMAP folder names the API
|
|
31
|
+
// expects on `/mail/folders/:folder`. `inbox` is special — the API
|
|
32
|
+
// has a dedicated `/mail/inbox` endpoint with extra enrichment, so
|
|
33
|
+
// we use that. Other folders go through the generic listing.
|
|
34
|
+
//
|
|
35
|
+
// Stalwart uses the standard IMAP names: INBOX, Sent, Drafts, Junk
|
|
36
|
+
// Mail (a.k.a. "Spam"), Trash. We use the canonical IMAP capitalisation.
|
|
37
|
+
const FOLDER_TO_IMAP = {
|
|
38
|
+
inbox: { endpoint: '/mail/inbox' },
|
|
39
|
+
sent: { endpoint: '/mail/folders/Sent' },
|
|
40
|
+
drafts: { endpoint: '/mail/folders/Drafts' },
|
|
41
|
+
spam: { endpoint: '/mail/folders/Junk%20Mail' },
|
|
42
|
+
trash: { endpoint: '/mail/folders/Trash' },
|
|
43
|
+
all: { endpoint: '/mail/folders/All%20Mail' },
|
|
44
|
+
// Starred is not a folder — it's the IMAP \Flagged flag, surfaced
|
|
45
|
+
// by client-side filtering over the inbox listing (Gmail-style).
|
|
46
|
+
starred: { endpoint: '/mail/inbox', clientFilter: 'flagged' },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export async function loadList(agent, folder) {
|
|
50
|
+
const root = document.getElementById('content');
|
|
51
|
+
root.innerHTML = `
|
|
52
|
+
<div class="list-header">
|
|
53
|
+
<span class="folder-title">${escapeHtml(folderTitle(folder))}</span>
|
|
54
|
+
<span class="count-text" id="list-count"></span>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
|
|
57
|
+
`;
|
|
58
|
+
const route = FOLDER_TO_IMAP[folder] ?? FOLDER_TO_IMAP.inbox;
|
|
59
|
+
try {
|
|
60
|
+
const sep = route.endpoint.includes('?') ? '&' : '?';
|
|
61
|
+
const data = await apiGet(`${route.endpoint}${sep}limit=50&offset=0`, { agentKey: agent.apiKey });
|
|
62
|
+
state.messages = data.messages ?? [];
|
|
63
|
+
renderList();
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Empty folder is a normal state; "no such folder" lands here
|
|
66
|
+
// too. Show a friendly empty message rather than a raw HTTP error.
|
|
67
|
+
const msg = String(err.message ?? err);
|
|
68
|
+
document.getElementById('list-rows').innerHTML = msg.includes('404')
|
|
69
|
+
? `<div class="empty">${escapeHtml(folderTitle(folder))} is empty.</div>`
|
|
70
|
+
: `<div class="empty">Failed to load: ${escapeHtml(msg)}</div>`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function folderTitle(folder) {
|
|
75
|
+
const f = FOLDERS.find(x => x.id === folder);
|
|
76
|
+
return f ? f.label : 'Inbox';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderList() {
|
|
80
|
+
const root = document.getElementById('list-rows');
|
|
81
|
+
if (!root) return;
|
|
82
|
+
const q = state.searchQuery.trim();
|
|
83
|
+
const filters = q ? parseSearch(q) : null;
|
|
84
|
+
let filtered = filters ? state.messages.filter(m => matchesSearch(m, filters)) : state.messages;
|
|
85
|
+
|
|
86
|
+
// Client-side folder filtering for the folders the API doesn't
|
|
87
|
+
// distinguish for us yet. Starred uses the IMAP \Flagged flag.
|
|
88
|
+
// Flags may come back as an array OR an object map ({Seen: true})
|
|
89
|
+
// depending on the IMAP path — always coerce before .includes().
|
|
90
|
+
if (state.selectedFolder === 'starred') {
|
|
91
|
+
filtered = filtered.filter(m => flagsHas(m.flags, '\\Flagged'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const hlTerm = filters?.subject || filters?.from || filters?.text || '';
|
|
95
|
+
|
|
96
|
+
// Footer count + search hint
|
|
97
|
+
const hintEl = document.getElementById('search-hint');
|
|
98
|
+
if (q && hintEl) {
|
|
99
|
+
hintEl.textContent = `${filtered.length}/${state.messages.length}`;
|
|
100
|
+
hintEl.classList.add('show');
|
|
101
|
+
} else if (hintEl) {
|
|
102
|
+
hintEl.classList.remove('show');
|
|
103
|
+
}
|
|
104
|
+
const countEl = document.getElementById('list-count');
|
|
105
|
+
if (countEl) countEl.textContent = `${filtered.length} of ${state.messages.length}`;
|
|
106
|
+
|
|
107
|
+
if (filtered.length === 0) {
|
|
108
|
+
root.innerHTML = q
|
|
109
|
+
? `<div class="empty">No messages match "${escapeHtml(q)}".</div>`
|
|
110
|
+
: `<div class="empty"><div class="big">${icon('inbox', { size: 48 })}</div>Nothing here yet.</div>`;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
root.innerHTML = filtered.map(m => {
|
|
115
|
+
const unread = !flagsHas(m.flags, '\\Seen');
|
|
116
|
+
const starred = flagsHas(m.flags, '\\Flagged');
|
|
117
|
+
const fromAddr = m.from?.[0]?.address ?? '?';
|
|
118
|
+
const fromName = m.from?.[0]?.name || fromAddr;
|
|
119
|
+
const subject = m.subject ?? '(no subject)';
|
|
120
|
+
const date = formatDate(m.date);
|
|
121
|
+
const starIcon = icon(starred ? 'starFilled' : 'starOutline', { size: 18 });
|
|
122
|
+
return `
|
|
123
|
+
<div class="list-row ${unread ? 'unread' : ''}" data-uid="${m.uid}">
|
|
124
|
+
<span class="star ${starred ? 'starred' : ''}" data-action="star">${starIcon}</span>
|
|
125
|
+
<span class="dot"></span>
|
|
126
|
+
<span class="from">${highlightTerm(fromName, hlTerm)}</span>
|
|
127
|
+
<span class="subject-cell">
|
|
128
|
+
<span class="subject">${highlightTerm(subject, hlTerm)}</span>
|
|
129
|
+
<span class="preview">${highlightTerm((m.preview ?? '').slice(0, 160), hlTerm)}</span>
|
|
130
|
+
</span>
|
|
131
|
+
<span class="date">${escapeHtml(date)}</span>
|
|
132
|
+
</div>
|
|
133
|
+
`;
|
|
134
|
+
}).join('');
|
|
135
|
+
|
|
136
|
+
root.querySelectorAll('.list-row').forEach(el => {
|
|
137
|
+
el.addEventListener('click', (e) => {
|
|
138
|
+
if (e.target.closest('[data-action="star"]')) {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
toast('Starring not wired through API yet.');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const uid = Number(el.dataset.uid);
|
|
144
|
+
location.hash = `#/m/${uid}`;
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function clearSearch() {
|
|
150
|
+
const input = document.getElementById('search-input');
|
|
151
|
+
if (input) {
|
|
152
|
+
input.value = '';
|
|
153
|
+
input.classList.remove('has-query');
|
|
154
|
+
}
|
|
155
|
+
state.searchQuery = '';
|
|
156
|
+
document.getElementById('search-clear')?.classList.remove('show');
|
|
157
|
+
document.getElementById('search-hint')?.classList.remove('show');
|
|
158
|
+
renderList();
|
|
159
|
+
input?.focus();
|
|
160
|
+
}
|