@agenticmail/api 0.7.7 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/public/index.html +56 -840
- package/public/js/api.js +25 -0
- package/public/js/app.js +207 -0
- package/public/js/avatar.js +46 -0
- package/public/js/compose.js +81 -0
- package/public/js/icons.js +56 -0
- package/public/js/list-view.js +118 -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 +597 -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,207 @@
|
|
|
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
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Hash router ─────────────────────────────────────────────────────
|
|
110
|
+
function route() {
|
|
111
|
+
const hash = location.hash || '#/inbox';
|
|
112
|
+
const msgMatch = hash.match(/^#\/m\/(\d+)$/);
|
|
113
|
+
if (msgMatch) {
|
|
114
|
+
openMessage(Number(msgMatch[1]));
|
|
115
|
+
} else if (state.selectedAgent) {
|
|
116
|
+
loadList(state.selectedAgent, state.selectedFolder);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
window.addEventListener('hashchange', route);
|
|
120
|
+
|
|
121
|
+
// ─── Top bar wiring ──────────────────────────────────────────────────
|
|
122
|
+
document.getElementById('refresh-btn').addEventListener('click', async () => {
|
|
123
|
+
if (state.selectedAgent) {
|
|
124
|
+
await loadList(state.selectedAgent, state.selectedFolder);
|
|
125
|
+
toast('Refreshed.');
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
document.getElementById('compose-btn').addEventListener('click', openCompose);
|
|
129
|
+
document.getElementById('profile-btn').addEventListener('click', toggleProfileMenu);
|
|
130
|
+
document.getElementById('profile-menu').addEventListener('click', e => {
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
const item = e.target.closest('.profile-menu-item');
|
|
133
|
+
if (!item) return;
|
|
134
|
+
const agent = state.agents.find(a => a.id === item.dataset.id);
|
|
135
|
+
if (agent && agent.id !== state.selectedAgent?.id) selectAgent(agent);
|
|
136
|
+
closeProfileMenu();
|
|
137
|
+
});
|
|
138
|
+
document.addEventListener('click', e => {
|
|
139
|
+
const menu = document.getElementById('profile-menu');
|
|
140
|
+
const btn = document.getElementById('profile-btn');
|
|
141
|
+
if (!menu || !btn) return;
|
|
142
|
+
if (!menu.contains(e.target) && !btn.contains(e.target)) closeProfileMenu();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ─── Compose modal wiring ────────────────────────────────────────────
|
|
146
|
+
document.getElementById('compose-close').addEventListener('click', closeCompose);
|
|
147
|
+
document.getElementById('compose-cancel').addEventListener('click', closeCompose);
|
|
148
|
+
document.getElementById('compose-send').addEventListener('click', sendCompose);
|
|
149
|
+
document.getElementById('compose-bg').addEventListener('click', e => {
|
|
150
|
+
if (e.target.id === 'compose-bg') closeCompose();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Search (debounced, Esc clears) ─────────────────────────────────
|
|
154
|
+
let searchDebounce = null;
|
|
155
|
+
const searchInput = document.getElementById('search-input');
|
|
156
|
+
searchInput.addEventListener('input', e => {
|
|
157
|
+
const v = e.target.value;
|
|
158
|
+
e.target.classList.toggle('has-query', v.length > 0);
|
|
159
|
+
document.getElementById('search-clear').classList.toggle('show', v.length > 0);
|
|
160
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
161
|
+
searchDebounce = setTimeout(() => {
|
|
162
|
+
state.searchQuery = v;
|
|
163
|
+
renderList();
|
|
164
|
+
}, 80);
|
|
165
|
+
});
|
|
166
|
+
searchInput.addEventListener('keydown', e => {
|
|
167
|
+
if (e.key === 'Escape') { e.preventDefault(); clearSearch(); }
|
|
168
|
+
});
|
|
169
|
+
document.getElementById('search-clear').addEventListener('click', clearSearch);
|
|
170
|
+
|
|
171
|
+
// ─── Keyboard shortcuts (Gmail-style) ───────────────────────────────
|
|
172
|
+
// r refresh current inbox
|
|
173
|
+
// c compose new
|
|
174
|
+
// / focus the search box
|
|
175
|
+
document.addEventListener('keydown', e => {
|
|
176
|
+
if (document.getElementById('compose-bg').style.display !== 'none') return;
|
|
177
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
178
|
+
if (e.key === 'r') document.getElementById('refresh-btn').click();
|
|
179
|
+
if (e.key === 'c') openCompose();
|
|
180
|
+
if (e.key === '/') {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
searchInput.focus();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ─── Boot ───────────────────────────────────────────────────────────
|
|
187
|
+
(() => {
|
|
188
|
+
// Accept `?key=...` from the CLI's `agenticmail web` command, then
|
|
189
|
+
// strip it from the URL so it doesn't leak via Referer / history /
|
|
190
|
+
// screen shares. Safe because the URL is loopback-only.
|
|
191
|
+
try {
|
|
192
|
+
const params = new URL(location.href).searchParams;
|
|
193
|
+
const urlKey = params.get('key');
|
|
194
|
+
if (urlKey) {
|
|
195
|
+
localStorage.setItem('agenticmail.masterKey', urlKey);
|
|
196
|
+
history.replaceState({}, '', location.pathname + location.hash);
|
|
197
|
+
}
|
|
198
|
+
} catch {}
|
|
199
|
+
|
|
200
|
+
const saved = localStorage.getItem('agenticmail.masterKey');
|
|
201
|
+
if (saved) {
|
|
202
|
+
state.masterKey = saved;
|
|
203
|
+
document.getElementById('auth').style.display = 'none';
|
|
204
|
+
document.getElementById('app').style.display = 'grid';
|
|
205
|
+
bootstrap();
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
@@ -0,0 +1,46 @@
|
|
|
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 a stylised Claude-asterisk
|
|
5
|
+
// mark and a green verified-tick so the host inbox is recognisable at
|
|
6
|
+
// a glance vs. teammate sub-agents.
|
|
7
|
+
//
|
|
8
|
+
// We deliberately do NOT embed Anthropic's actual trademarked Claude
|
|
9
|
+
// logo here — reproducing it pixel-for-pixel in third-party software
|
|
10
|
+
// has licensing implications. The stylised approximation conveys
|
|
11
|
+
// the same identity cue without the trademark concern.
|
|
12
|
+
import { escapeHtml } from './utils.js';
|
|
13
|
+
import { icon } from './icons.js';
|
|
14
|
+
|
|
15
|
+
export function isBridgeAgent(agent) {
|
|
16
|
+
if (!agent) return false;
|
|
17
|
+
const name = (agent.name ?? '').toLowerCase();
|
|
18
|
+
const role = (agent.role ?? '').toLowerCase();
|
|
19
|
+
return name === 'claudecode' || name === 'claude' || role === 'bridge';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Deterministic colour per agent name — keeps teammate colours stable
|
|
23
|
+
// across sessions and reloads.
|
|
24
|
+
const AVATAR_PALETTE = [
|
|
25
|
+
'#ec4899', '#8b5cf6', '#3b82f6', '#06b6d4',
|
|
26
|
+
'#10b981', '#f59e0b', '#ef4444', '#84cc16',
|
|
27
|
+
];
|
|
28
|
+
function avatarColorFor(name) {
|
|
29
|
+
let hash = 0;
|
|
30
|
+
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
|
31
|
+
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CLAUDE_MARK_SVG = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
|
35
|
+
<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"/>
|
|
36
|
+
</svg>`;
|
|
37
|
+
|
|
38
|
+
export function avatarHtml(agent, size = '') {
|
|
39
|
+
const cls = `avatar ${size}`.trim();
|
|
40
|
+
if (isBridgeAgent(agent)) {
|
|
41
|
+
return `<span class="${cls} avatar-host">${CLAUDE_MARK_SVG}<span class="avatar-check">${icon('check', { size: 10 })}</span></span>`;
|
|
42
|
+
}
|
|
43
|
+
const initial = (agent.name ?? '?').slice(0, 1).toUpperCase();
|
|
44
|
+
const color = avatarColorFor(agent.name ?? '');
|
|
45
|
+
return `<span class="${cls}" style="background:${color}">${escapeHtml(initial)}</span>`;
|
|
46
|
+
}
|
|
@@ -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,118 @@
|
|
|
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
|
+
export async function loadList(agent, folder) {
|
|
12
|
+
const root = document.getElementById('content');
|
|
13
|
+
root.innerHTML = `
|
|
14
|
+
<div class="list-header">
|
|
15
|
+
<span class="folder-title">${escapeHtml(folderTitle(folder))}</span>
|
|
16
|
+
<span class="count-text" id="list-count"></span>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
|
|
19
|
+
`;
|
|
20
|
+
try {
|
|
21
|
+
// Public API today only exposes the inbox listing. Other folders
|
|
22
|
+
// fall through to the inbox endpoint and apply a client-side
|
|
23
|
+
// shape (e.g. starred = flag filter). When the API grows
|
|
24
|
+
// per-mailbox listing we'll route based on `folder` here.
|
|
25
|
+
const data = await apiGet('/mail/inbox?limit=50&offset=0', { agentKey: agent.apiKey });
|
|
26
|
+
state.messages = data.messages ?? [];
|
|
27
|
+
renderList();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
document.getElementById('list-rows').innerHTML =
|
|
30
|
+
`<div class="empty">Failed to load: ${escapeHtml(err.message)}</div>`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function folderTitle(folder) {
|
|
35
|
+
const f = FOLDERS.find(x => x.id === folder);
|
|
36
|
+
return f ? f.label : 'Inbox';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renderList() {
|
|
40
|
+
const root = document.getElementById('list-rows');
|
|
41
|
+
if (!root) return;
|
|
42
|
+
const q = state.searchQuery.trim();
|
|
43
|
+
const filters = q ? parseSearch(q) : null;
|
|
44
|
+
let filtered = filters ? state.messages.filter(m => matchesSearch(m, filters)) : state.messages;
|
|
45
|
+
|
|
46
|
+
// Client-side folder filtering for the folders the API doesn't
|
|
47
|
+
// distinguish for us yet. Starred uses the IMAP \Flagged flag.
|
|
48
|
+
if (state.selectedFolder === 'starred') {
|
|
49
|
+
filtered = filtered.filter(m => (m.flags ?? []).includes('\\Flagged'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hlTerm = filters?.subject || filters?.from || filters?.text || '';
|
|
53
|
+
|
|
54
|
+
// Footer count + search hint
|
|
55
|
+
const hintEl = document.getElementById('search-hint');
|
|
56
|
+
if (q && hintEl) {
|
|
57
|
+
hintEl.textContent = `${filtered.length}/${state.messages.length}`;
|
|
58
|
+
hintEl.classList.add('show');
|
|
59
|
+
} else if (hintEl) {
|
|
60
|
+
hintEl.classList.remove('show');
|
|
61
|
+
}
|
|
62
|
+
const countEl = document.getElementById('list-count');
|
|
63
|
+
if (countEl) countEl.textContent = `${filtered.length} of ${state.messages.length}`;
|
|
64
|
+
|
|
65
|
+
if (filtered.length === 0) {
|
|
66
|
+
root.innerHTML = q
|
|
67
|
+
? `<div class="empty">No messages match "${escapeHtml(q)}".</div>`
|
|
68
|
+
: `<div class="empty"><div class="big">${icon('inbox', { size: 48 })}</div>Nothing here yet.</div>`;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
root.innerHTML = filtered.map(m => {
|
|
73
|
+
const unread = !(m.flags ?? []).includes('\\Seen');
|
|
74
|
+
const starred = (m.flags ?? []).includes('\\Flagged');
|
|
75
|
+
const fromAddr = m.from?.[0]?.address ?? '?';
|
|
76
|
+
const fromName = m.from?.[0]?.name || fromAddr;
|
|
77
|
+
const subject = m.subject ?? '(no subject)';
|
|
78
|
+
const date = formatDate(m.date);
|
|
79
|
+
const starIcon = icon(starred ? 'starFilled' : 'starOutline', { size: 18 });
|
|
80
|
+
return `
|
|
81
|
+
<div class="list-row ${unread ? 'unread' : ''}" data-uid="${m.uid}">
|
|
82
|
+
<span class="star ${starred ? 'starred' : ''}" data-action="star">${starIcon}</span>
|
|
83
|
+
<span class="dot"></span>
|
|
84
|
+
<span class="from">${highlightTerm(fromName, hlTerm)}</span>
|
|
85
|
+
<span class="subject-cell">
|
|
86
|
+
<span class="subject">${highlightTerm(subject, hlTerm)}</span>
|
|
87
|
+
<span class="preview">${highlightTerm((m.preview ?? '').slice(0, 160), hlTerm)}</span>
|
|
88
|
+
</span>
|
|
89
|
+
<span class="date">${escapeHtml(date)}</span>
|
|
90
|
+
</div>
|
|
91
|
+
`;
|
|
92
|
+
}).join('');
|
|
93
|
+
|
|
94
|
+
root.querySelectorAll('.list-row').forEach(el => {
|
|
95
|
+
el.addEventListener('click', (e) => {
|
|
96
|
+
if (e.target.closest('[data-action="star"]')) {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
toast('Starring not wired through API yet.');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const uid = Number(el.dataset.uid);
|
|
102
|
+
location.hash = `#/m/${uid}`;
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function clearSearch() {
|
|
108
|
+
const input = document.getElementById('search-input');
|
|
109
|
+
if (input) {
|
|
110
|
+
input.value = '';
|
|
111
|
+
input.classList.remove('has-query');
|
|
112
|
+
}
|
|
113
|
+
state.searchQuery = '';
|
|
114
|
+
document.getElementById('search-clear')?.classList.remove('show');
|
|
115
|
+
document.getElementById('search-hint')?.classList.remove('show');
|
|
116
|
+
renderList();
|
|
117
|
+
input?.focus();
|
|
118
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Small, sufficient markdown renderer for agent emails. Handles
|
|
2
|
+
// headings, lists (incl. tasks), tables, code fences, blockquotes,
|
|
3
|
+
// links/auto-links, and inline emphasis. Inputs are HTML-escaped
|
|
4
|
+
// before re-introducing safe tags, so this is XSS-safe.
|
|
5
|
+
import { escapeHtml } from './utils.js';
|
|
6
|
+
|
|
7
|
+
export function renderMarkdown(src) {
|
|
8
|
+
if (!src) return '<div class="empty">(no body)</div>';
|
|
9
|
+
const lines = src.split('\n');
|
|
10
|
+
let out = '';
|
|
11
|
+
let codeFence = false;
|
|
12
|
+
let codeBuf = [];
|
|
13
|
+
let listBuf = null;
|
|
14
|
+
let blockquoteDepth = 0;
|
|
15
|
+
function flushList() {
|
|
16
|
+
if (!listBuf) return;
|
|
17
|
+
out += `<${listBuf.type}>${listBuf.items.map(i => `<li>${i}</li>`).join('')}</${listBuf.type}>`;
|
|
18
|
+
listBuf = null;
|
|
19
|
+
}
|
|
20
|
+
function setBlockquote(depth) {
|
|
21
|
+
while (blockquoteDepth < depth) { out += '<blockquote>'; blockquoteDepth++; }
|
|
22
|
+
while (blockquoteDepth > depth) { out += '</blockquote>'; blockquoteDepth--; }
|
|
23
|
+
}
|
|
24
|
+
for (const rawLine of lines) {
|
|
25
|
+
const fence = rawLine.match(/^\s*```([\w+-]*)\s*$/);
|
|
26
|
+
if (fence) {
|
|
27
|
+
if (codeFence) {
|
|
28
|
+
out += `<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`;
|
|
29
|
+
codeBuf = []; codeFence = false;
|
|
30
|
+
} else {
|
|
31
|
+
flushList();
|
|
32
|
+
codeFence = true;
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (codeFence) { codeBuf.push(rawLine); continue; }
|
|
37
|
+
let line = rawLine, depth = 0;
|
|
38
|
+
while (/^>/.test(line)) { depth++; line = line.replace(/^>\s?/, ''); }
|
|
39
|
+
if (depth !== blockquoteDepth) { flushList(); setBlockquote(depth); }
|
|
40
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
41
|
+
if (heading) {
|
|
42
|
+
flushList();
|
|
43
|
+
const level = Math.min(heading[1].length, 6);
|
|
44
|
+
out += `<h${level}>${inlineMd(heading[2])}</h${level}>`;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (/^\s*(?:-{3,}|_{3,}|\*{3,})\s*$/.test(line)) { flushList(); out += '<hr>'; continue; }
|
|
48
|
+
const task = line.match(/^(\s*)([-*+])\s+\[([ xX])\]\s+(.*)$/);
|
|
49
|
+
if (task) {
|
|
50
|
+
const checked = task[3] !== ' ';
|
|
51
|
+
const item = `<input type="checkbox" disabled ${checked ? 'checked' : ''}> ${inlineMd(task[4])}`;
|
|
52
|
+
if (!listBuf || listBuf.type !== 'ul') { flushList(); listBuf = { type: 'ul', items: [] }; }
|
|
53
|
+
listBuf.items.push(item);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
|
|
57
|
+
if (bullet) {
|
|
58
|
+
if (!listBuf || listBuf.type !== 'ul') { flushList(); listBuf = { type: 'ul', items: [] }; }
|
|
59
|
+
listBuf.items.push(inlineMd(bullet[3]));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
|
|
63
|
+
if (numbered) {
|
|
64
|
+
if (!listBuf || listBuf.type !== 'ol') { flushList(); listBuf = { type: 'ol', items: [] }; }
|
|
65
|
+
listBuf.items.push(inlineMd(numbered[3]));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (/^\s*\|.*\|\s*$/.test(line)) {
|
|
69
|
+
flushList();
|
|
70
|
+
const cells = line.trim().slice(1, -1).split('|').map(c => inlineMd(c.trim()));
|
|
71
|
+
if (/^\s*\|?(\s*:?-{3,}:?\s*\|)+/.test(line)) continue;
|
|
72
|
+
out += `<table><tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr></table>`;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (line.trim() === '') { flushList(); out += '<br>'; continue; }
|
|
76
|
+
flushList();
|
|
77
|
+
out += `<div>${inlineMd(line)}</div>`;
|
|
78
|
+
}
|
|
79
|
+
flushList();
|
|
80
|
+
setBlockquote(0);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inlineMd(text) {
|
|
85
|
+
let s = escapeHtml(text);
|
|
86
|
+
s = s.replace(/`([^`\n]+)`/g, (_, c) => `<code>${c}</code>`);
|
|
87
|
+
s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
88
|
+
s = s.replace(/___([^_\n]+)___/g, '<strong><em>$1</em></strong>');
|
|
89
|
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
90
|
+
s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
|
|
91
|
+
s = s.replace(/(^|[^\w*])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
|
|
92
|
+
s = s.replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
|
|
93
|
+
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
|
|
94
|
+
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
95
|
+
s = s.replace(/<(https?:\/\/[^&\s]+)>/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
|
96
|
+
return s;
|
|
97
|
+
}
|