@agenticmail/api 0.7.18 → 0.7.19
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/package.json +1 -1
- package/public/js/app.js +10 -2
- package/public/js/compose.js +67 -1
- package/public/js/list-view.js +75 -7
- package/public/styles.css +21 -0
package/package.json
CHANGED
package/public/js/app.js
CHANGED
|
@@ -12,7 +12,7 @@ import { renderProfile, toggleProfileMenu, closeProfileMenu } from './profile.js
|
|
|
12
12
|
import { renderSidebar } from './sidebar.js';
|
|
13
13
|
import { loadList, renderList, clearSearch, ensureFolderCache } from './list-view.js';
|
|
14
14
|
import { openMessage } from './message-view.js';
|
|
15
|
-
import { populateComposeFrom, openCompose, closeCompose, sendCompose } from './compose.js';
|
|
15
|
+
import { populateComposeFrom, openCompose, openDraft, closeCompose, discardCompose, sendCompose } from './compose.js';
|
|
16
16
|
import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
|
|
17
17
|
import { icon } from './icons.js';
|
|
18
18
|
|
|
@@ -137,6 +137,14 @@ function route() {
|
|
|
137
137
|
openMessage(Number(msgMatch[1]));
|
|
138
138
|
return;
|
|
139
139
|
}
|
|
140
|
+
// Drafts use UUIDs as ids and open the compose modal pre-
|
|
141
|
+
// populated rather than the read-only message view. The list
|
|
142
|
+
// row click handler emits #/d/<uuid> for draft rows.
|
|
143
|
+
const draftMatch = hash.match(/^#\/d\/([a-zA-Z0-9-]+)$/);
|
|
144
|
+
if (draftMatch) {
|
|
145
|
+
openDraft(draftMatch[1]);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
140
148
|
const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
|
|
141
149
|
const folder = folderMatch ? folderMatch[1] : 'inbox';
|
|
142
150
|
if (state.selectedFolder !== folder) {
|
|
@@ -185,7 +193,7 @@ document.addEventListener('click', e => {
|
|
|
185
193
|
|
|
186
194
|
// ─── Compose modal wiring ────────────────────────────────────────────
|
|
187
195
|
document.getElementById('compose-close').addEventListener('click', closeCompose);
|
|
188
|
-
document.getElementById('compose-cancel').addEventListener('click',
|
|
196
|
+
document.getElementById('compose-cancel').addEventListener('click', discardCompose);
|
|
189
197
|
document.getElementById('compose-send').addEventListener('click', sendCompose);
|
|
190
198
|
document.getElementById('compose-bg').addEventListener('click', e => {
|
|
191
199
|
if (e.target.id === 'compose-bg') closeCompose();
|
package/public/js/compose.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// in the Drafts folder.
|
|
10
10
|
import { state } from './state.js';
|
|
11
11
|
import { escapeHtml, toast } from './utils.js';
|
|
12
|
-
import { apiPost, apiPut, apiDelete } from './api.js';
|
|
12
|
+
import { apiGet, apiPost, apiPut, apiDelete } from './api.js';
|
|
13
13
|
import { loadList } from './list-view.js';
|
|
14
14
|
|
|
15
15
|
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
|
@@ -81,6 +81,44 @@ export function openReply(replyAll) {
|
|
|
81
81
|
setTimeout(() => document.getElementById('compose-body').focus(), 50);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Open an existing autosaved draft for further editing. Pulls the
|
|
86
|
+
* SQL row, populates every field, and arms `composeDraftId` so
|
|
87
|
+
* subsequent autosaves PUT to the same row instead of creating a
|
|
88
|
+
* second draft. The user can resume right where they left off.
|
|
89
|
+
*/
|
|
90
|
+
export async function openDraft(draftId) {
|
|
91
|
+
state.composeReplyContext = null;
|
|
92
|
+
state.composeDraftId = draftId;
|
|
93
|
+
pendingAttachments = [];
|
|
94
|
+
document.getElementById('compose-title').textContent = 'Draft';
|
|
95
|
+
if (state.selectedAgent) document.getElementById('compose-from').value = state.selectedAgent.id;
|
|
96
|
+
// Clear first so we don't leak data from a previous compose if
|
|
97
|
+
// the fetch fails halfway.
|
|
98
|
+
['compose-to', 'compose-cc', 'compose-wake', 'compose-subject', 'compose-body']
|
|
99
|
+
.forEach(id => { document.getElementById(id).value = ''; });
|
|
100
|
+
setComposeStatus('Loading…');
|
|
101
|
+
showModal();
|
|
102
|
+
wireAutosave();
|
|
103
|
+
wireAttachmentPicker();
|
|
104
|
+
try {
|
|
105
|
+
const data = await apiGet('/drafts', { agentKey: state.selectedAgent.apiKey });
|
|
106
|
+
const draft = (data?.drafts ?? []).find(d => d.id === draftId);
|
|
107
|
+
if (!draft) throw new Error('Draft not found');
|
|
108
|
+
document.getElementById('compose-to').value = draft.to_addr ?? '';
|
|
109
|
+
document.getElementById('compose-cc').value = draft.cc ?? '';
|
|
110
|
+
document.getElementById('compose-subject').value = draft.subject ?? '';
|
|
111
|
+
document.getElementById('compose-body').value = draft.text_body ?? '';
|
|
112
|
+
document.getElementById('compose-title').textContent =
|
|
113
|
+
`Draft: ${draft.subject || '(no subject)'}`;
|
|
114
|
+
renderAttachmentChips();
|
|
115
|
+
setComposeStatus('Loaded from Drafts');
|
|
116
|
+
setTimeout(() => document.getElementById('compose-body').focus(), 50);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setComposeStatus(`Couldn't load draft: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
84
122
|
export function closeCompose() {
|
|
85
123
|
document.getElementById('compose-bg').style.display = 'none';
|
|
86
124
|
// Flush a final save synchronously-ish on close so a quick
|
|
@@ -94,6 +132,34 @@ export function closeCompose() {
|
|
|
94
132
|
}
|
|
95
133
|
}
|
|
96
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Discard the in-progress compose — delete the autosaved draft
|
|
137
|
+
* (if any) and close the modal. Distinct from `closeCompose`
|
|
138
|
+
* which just hides the modal and lets the draft persist for
|
|
139
|
+
* later resumption from the Drafts folder. Bound to the
|
|
140
|
+
* "Discard" button in the compose footer.
|
|
141
|
+
*/
|
|
142
|
+
export async function discardCompose() {
|
|
143
|
+
// Cancel any pending autosave so it doesn't race the delete.
|
|
144
|
+
if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
|
|
145
|
+
const draftId = state.composeDraftId;
|
|
146
|
+
const agent = state.agents.find(a => a.id === document.getElementById('compose-from').value) ?? state.selectedAgent;
|
|
147
|
+
// Close UI first so the user gets immediate feedback even if
|
|
148
|
+
// the delete is slow / fails.
|
|
149
|
+
document.getElementById('compose-bg').style.display = 'none';
|
|
150
|
+
state.composeDraftId = null;
|
|
151
|
+
pendingAttachments = [];
|
|
152
|
+
if (draftId && agent) {
|
|
153
|
+
try { await apiDelete(`/drafts/${draftId}`, { agentKey: agent.apiKey }); }
|
|
154
|
+
catch { /* draft already gone or transient failure — fine */ }
|
|
155
|
+
// If the user is currently looking at the Drafts list, refresh
|
|
156
|
+
// so the deleted draft disappears from the visible rows.
|
|
157
|
+
if (state.selectedAgent && state.selectedFolder === 'drafts') {
|
|
158
|
+
try { await loadList(state.selectedAgent, 'drafts'); } catch { /* ignore */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
97
163
|
function showModal() {
|
|
98
164
|
document.getElementById('compose-bg').style.display = 'flex';
|
|
99
165
|
}
|
package/public/js/list-view.js
CHANGED
|
@@ -97,15 +97,18 @@ export async function loadList(agent, folder) {
|
|
|
97
97
|
<div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
|
|
98
98
|
`;
|
|
99
99
|
document.getElementById('list-refresh-btn')?.addEventListener('click', () => loadList(agent, folder));
|
|
100
|
-
// Select-all toggles every visible row checkbox. We don't currently
|
|
101
|
-
// expose a bulk-action toolbar (delete/archive/move) yet, so this
|
|
102
|
-
// is purely a visual selection state for now — but wiring it
|
|
103
|
-
// means it works the moment a bulk-action surface lands.
|
|
104
100
|
document.getElementById('list-select-all-input')?.addEventListener('change', (e) => {
|
|
105
101
|
const checked = e.target.checked;
|
|
106
102
|
document.querySelectorAll('#list-rows .row-check input[type=checkbox]')
|
|
107
103
|
.forEach(cb => { cb.checked = checked; });
|
|
108
104
|
});
|
|
105
|
+
|
|
106
|
+
// Drafts are a SQL-backed app primitive, not an IMAP mailbox.
|
|
107
|
+
// The autosave path writes to /drafts (sqlite) and the agent
|
|
108
|
+
// MCP tools operate on the same table — so the list must come
|
|
109
|
+
// from there, not from /mail/digest?folder=Drafts (which would
|
|
110
|
+
// miss everything autosaved by the web UI).
|
|
111
|
+
if (folder === 'drafts') return loadDraftsList(agent);
|
|
109
112
|
await ensureFolderCache(agent);
|
|
110
113
|
|
|
111
114
|
// Resolve the real IMAP folder. Starred reuses INBOX + a client-
|
|
@@ -142,6 +145,51 @@ function folderTitle(folder) {
|
|
|
142
145
|
return f ? f.label : 'Inbox';
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Drafts list — sourced from the SQL drafts table via `/drafts`,
|
|
150
|
+
* not from the IMAP Drafts mailbox.
|
|
151
|
+
*
|
|
152
|
+
* The autosave path (compose.js) writes here, and the MCP
|
|
153
|
+
* `manage_drafts` tool operates on the same rows, so this is
|
|
154
|
+
* the single source of truth for app-level drafts.
|
|
155
|
+
*
|
|
156
|
+
* We normalise each row into the same envelope shape `renderList`
|
|
157
|
+
* expects (uid → draft id, subject, from = agent itself, date =
|
|
158
|
+
* updated_at, preview = first 240 chars of text_body) so the
|
|
159
|
+
* row markup stays identical across folders. Click handling
|
|
160
|
+
* branches on `state.selectedFolder === 'drafts'` to open the
|
|
161
|
+
* compose modal pre-populated instead of the read-only message
|
|
162
|
+
* view.
|
|
163
|
+
*/
|
|
164
|
+
async function loadDraftsList(agent) {
|
|
165
|
+
try {
|
|
166
|
+
const data = await apiGet('/drafts', { agentKey: agent.apiKey });
|
|
167
|
+
const rows = Array.isArray(data?.drafts) ? data.drafts : [];
|
|
168
|
+
state.messages = rows.map(r => ({
|
|
169
|
+
// We store the draft id under `uid` so renderList +
|
|
170
|
+
// click handlers can use the same field. Drafts also
|
|
171
|
+
// get a `__draftId` marker so the click handler can
|
|
172
|
+
// route differently.
|
|
173
|
+
uid: r.id,
|
|
174
|
+
__draftId: r.id,
|
|
175
|
+
subject: r.subject || '(no subject)',
|
|
176
|
+
from: [{ name: agent.name, address: agent.email }],
|
|
177
|
+
// SQLite returns updated_at as a UTC string without an
|
|
178
|
+
// explicit Z. Date() parses it as local; force UTC
|
|
179
|
+
// interpretation by appending Z so the formatter shows
|
|
180
|
+
// the actual save time.
|
|
181
|
+
date: r.updated_at ? `${r.updated_at}Z`.replace('ZZ', 'Z') : null,
|
|
182
|
+
preview: (r.text_body || '').slice(0, 240),
|
|
183
|
+
flags: [],
|
|
184
|
+
__recipient: r.to_addr || '(no recipient)',
|
|
185
|
+
}));
|
|
186
|
+
renderList();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
document.getElementById('list-rows').innerHTML =
|
|
189
|
+
`<div class="empty">Failed to load drafts: ${escapeHtml(err.message ?? err)}</div>`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
145
193
|
export function renderList() {
|
|
146
194
|
const root = document.getElementById('list-rows');
|
|
147
195
|
if (!root) return;
|
|
@@ -183,6 +231,7 @@ export function renderList() {
|
|
|
183
231
|
// ellipsis so longer preview lines never wrap. Identical markup
|
|
184
232
|
// for every folder so Sent / Drafts / Spam etc render the same
|
|
185
233
|
// way Inbox does.
|
|
234
|
+
const isDrafts = state.selectedFolder === 'drafts';
|
|
186
235
|
root.innerHTML = filtered.map(m => {
|
|
187
236
|
const unread = !flagsHas(m.flags, '\\Seen');
|
|
188
237
|
const starred = flagsHas(m.flags, '\\Flagged');
|
|
@@ -195,11 +244,22 @@ export function renderList() {
|
|
|
195
244
|
.replace(/^>+ ?/gm, '')
|
|
196
245
|
.replace(/\s+/g, ' ')
|
|
197
246
|
.trim();
|
|
247
|
+
// In Drafts the "from" column reads naturally as the recipient
|
|
248
|
+
// ("To: alice@…") since the user is always the sender. Add a
|
|
249
|
+
// small "Draft" tag in red so the row is unmistakeable.
|
|
250
|
+
const leadingCell = isDrafts
|
|
251
|
+
? `<span class="from drafts-recipient" title="${escapeHtml(m.__recipient ?? '')}"><span class="drafts-tag">Draft</span> ${escapeHtml(m.__recipient ?? '(no recipient)')}</span>`
|
|
252
|
+
: `<span class="from" title="${escapeHtml(fromAddr)}">${highlightTerm(fromName, hlTerm)}</span>`;
|
|
253
|
+
// Drafts can't be starred; suppress the star icon to keep the
|
|
254
|
+
// row visually quiet for the user.
|
|
255
|
+
const starCell = isDrafts
|
|
256
|
+
? `<span class="star drafts-star-placeholder"></span>`
|
|
257
|
+
: `<span class="star ${starred ? 'starred' : ''}" data-action="star" data-uid="${m.uid}">${starIcon}</span>`;
|
|
198
258
|
return `
|
|
199
|
-
<div class="list-row ${unread ? 'unread' : ''}" data-uid="${m.uid}">
|
|
259
|
+
<div class="list-row ${unread ? 'unread' : ''}${isDrafts ? ' draft-row' : ''}" data-uid="${m.uid}">
|
|
200
260
|
<label class="row-check" data-action="select"><input type="checkbox" /></label>
|
|
201
|
-
|
|
202
|
-
|
|
261
|
+
${starCell}
|
|
262
|
+
${leadingCell}
|
|
203
263
|
<span class="subject-cell">
|
|
204
264
|
<span class="subject">${highlightTerm(subject, hlTerm)}</span>
|
|
205
265
|
${cleanPreview ? `<span class="preview-sep"> — </span><span class="preview">${highlightTerm(cleanPreview, hlTerm)}</span>` : ''}
|
|
@@ -224,6 +284,14 @@ export function renderList() {
|
|
|
224
284
|
e.stopPropagation();
|
|
225
285
|
return;
|
|
226
286
|
}
|
|
287
|
+
// Drafts open the compose modal pre-populated with the
|
|
288
|
+
// saved draft, NOT the read-only message view. The UID
|
|
289
|
+
// we put on the row is actually a draft UUID; route as
|
|
290
|
+
// #/d/<id> so the router knows to call openDraft().
|
|
291
|
+
if (isDrafts) {
|
|
292
|
+
location.hash = `#/d/${el.dataset.uid}`;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
227
295
|
const uid = Number(el.dataset.uid);
|
|
228
296
|
location.hash = `#/m/${uid}`;
|
|
229
297
|
});
|
package/public/styles.css
CHANGED
|
@@ -482,6 +482,27 @@ a { color: var(--accent-strong); }
|
|
|
482
482
|
}
|
|
483
483
|
.list-row.unread .date { color: var(--unread-bold); font-weight: 700; }
|
|
484
484
|
|
|
485
|
+
/* ─── Drafts folder list ───────────────────────────────────────────
|
|
486
|
+
In the Drafts list, the leading column reads as the recipient
|
|
487
|
+
("To: alice@…") since the user is always the sender; we tag it
|
|
488
|
+
with a small red "Draft" pill so the row is unmistakable.
|
|
489
|
+
Stars don't apply to drafts — the star slot is left empty. */
|
|
490
|
+
.list-row.draft-row .drafts-tag {
|
|
491
|
+
display: inline-block;
|
|
492
|
+
background: #ea4335;
|
|
493
|
+
color: white;
|
|
494
|
+
font-size: 11px; font-weight: 600;
|
|
495
|
+
padding: 1px 6px; border-radius: 4px;
|
|
496
|
+
margin-right: 6px;
|
|
497
|
+
letter-spacing: .02em;
|
|
498
|
+
}
|
|
499
|
+
.list-row.draft-row .drafts-recipient .drafts-tag + * { vertical-align: middle; }
|
|
500
|
+
.list-row.draft-row .drafts-star-placeholder {
|
|
501
|
+
/* Reserve grid space but render nothing — keeps every row's
|
|
502
|
+
subject column aligned with non-draft folder rows. */
|
|
503
|
+
width: 32px; height: 32px;
|
|
504
|
+
}
|
|
505
|
+
|
|
485
506
|
mark.search-hl {
|
|
486
507
|
background: #fff475; color: inherit;
|
|
487
508
|
padding: 0 1px;
|