@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 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
- await receiver.deleteMessage(uid);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
4
4
  "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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', closeCompose);
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();
@@ -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
  }
@@ -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
- <span class="star ${starred ? 'starred' : ''}" data-action="star" data-uid="${m.uid}">${starIcon}</span>
202
- <span class="from" title="${escapeHtml(fromAddr)}">${highlightTerm(fromName, hlTerm)}</span>
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: `"${subject}" will be moved to Trash. This can't be undone from the web UI.`,
253
- confirm: 'Delete',
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
- await apiDelete(`/mail/messages/${state.selectedUid}`, { agentKey: state.selectedAgent.apiKey });
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;