@agenticmail/api 0.7.17 → 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/dist/index.js +15 -1
- 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/js/message-view.js +14 -4
- package/public/styles.css +21 -0
package/dist/index.js
CHANGED
|
@@ -2167,9 +2167,23 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2167
2167
|
res.status(400).json({ error: "Invalid UID" });
|
|
2168
2168
|
return;
|
|
2169
2169
|
}
|
|
2170
|
+
const permanent = req.query.permanent === "true" || req.query.permanent === "1";
|
|
2171
|
+
const sourceFolder = req.query.folder || "INBOX";
|
|
2170
2172
|
const password = getAgentPassword(agent);
|
|
2171
2173
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2172
|
-
|
|
2174
|
+
if (permanent) {
|
|
2175
|
+
await receiver.expungeMessage(uid, sourceFolder);
|
|
2176
|
+
res.status(204).send();
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const folders = await receiver.listFolders();
|
|
2180
|
+
const trashRe = /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i;
|
|
2181
|
+
const trashFolder = folders.find((f) => trashRe.test(f.name) || trashRe.test(f.path))?.path ?? folders.find((f) => f.specialUse === "\\Trash")?.path;
|
|
2182
|
+
if (!trashFolder || trashFolder === sourceFolder) {
|
|
2183
|
+
await receiver.expungeMessage(uid, sourceFolder);
|
|
2184
|
+
} else {
|
|
2185
|
+
await receiver.moveToTrash(uid, sourceFolder, trashFolder);
|
|
2186
|
+
}
|
|
2173
2187
|
res.status(204).send();
|
|
2174
2188
|
} catch (err) {
|
|
2175
2189
|
next(err);
|
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
|
});
|
|
@@ -247,15 +247,25 @@ async function markSpam() {
|
|
|
247
247
|
async function deleteMessage() {
|
|
248
248
|
if (!state.currentMessage || !state.selectedAgent) return;
|
|
249
249
|
const subject = state.currentMessage.subject ?? '(no subject)';
|
|
250
|
+
// From Trash, delete is permanent (no further fallback). From
|
|
251
|
+
// every other folder it's a move-to-trash, recoverable.
|
|
252
|
+
const isTrash = state.selectedFolder === 'trash';
|
|
250
253
|
const ok = await confirmModal({
|
|
251
|
-
title: 'Delete this message?',
|
|
252
|
-
body:
|
|
253
|
-
|
|
254
|
+
title: isTrash ? 'Delete this message forever?' : 'Delete this message?',
|
|
255
|
+
body: isTrash
|
|
256
|
+
? `"${subject}" will be permanently removed. This can't be undone.`
|
|
257
|
+
: `"${subject}" will be moved to Trash. You can recover it from there.`,
|
|
258
|
+
confirm: isTrash ? 'Delete forever' : 'Move to Trash',
|
|
254
259
|
danger: true,
|
|
255
260
|
});
|
|
256
261
|
if (!ok) return;
|
|
257
262
|
try {
|
|
258
|
-
|
|
263
|
+
// Pass the real IMAP folder name + permanent flag. The API
|
|
264
|
+
// uses the folder for the IMAP source mailbox and decides
|
|
265
|
+
// move-to-trash vs expunge based on `permanent`.
|
|
266
|
+
const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
|
|
267
|
+
const qs = `?folder=${encodeURIComponent(imap)}${isTrash ? '&permanent=true' : ''}`;
|
|
268
|
+
await apiDelete(`/mail/messages/${state.selectedUid}${qs}`, { agentKey: state.selectedAgent.apiKey });
|
|
259
269
|
toast('Deleted.');
|
|
260
270
|
location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
|
|
261
271
|
await loadList(state.selectedAgent, state.selectedFolder);
|
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;
|