@agenticmail/api 0.7.18 → 0.7.20

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
@@ -751,20 +751,75 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
751
751
  next(err);
752
752
  }
753
753
  });
754
+ const MAX_DRAFT_ATTACHMENTS_BYTES = 25 * 1024 * 1024;
755
+ function normaliseDraftAttachments(raw) {
756
+ if (!Array.isArray(raw) || raw.length === 0) return null;
757
+ let totalBytes = 0;
758
+ const cleaned = raw.map((a) => {
759
+ const content = typeof a?.content === "string" ? a.content : "";
760
+ totalBytes += Math.ceil(content.length * 0.75);
761
+ return {
762
+ filename: typeof a?.filename === "string" ? a.filename : "attachment",
763
+ contentType: typeof a?.contentType === "string" ? a.contentType : "application/octet-stream",
764
+ content,
765
+ encoding: "base64",
766
+ size: typeof a?.size === "number" ? a.size : Math.ceil(content.length * 0.75)
767
+ };
768
+ });
769
+ if (totalBytes > MAX_DRAFT_ATTACHMENTS_BYTES) {
770
+ throw Object.assign(new Error("attachments exceed 25 MB total"), { status: 413 });
771
+ }
772
+ return JSON.stringify(cleaned);
773
+ }
754
774
  router.get("/drafts", requireAgent, async (req, res, next) => {
755
775
  try {
756
776
  const rows = db.prepare("SELECT * FROM drafts WHERE agent_id = ? ORDER BY updated_at DESC").all(req.agent.id);
757
- res.json({ drafts: rows });
777
+ const stripped = rows.map((r) => {
778
+ let metaOnly;
779
+ if (r.attachments) {
780
+ try {
781
+ metaOnly = JSON.parse(r.attachments).map((a) => ({
782
+ filename: a.filename,
783
+ contentType: a.contentType,
784
+ size: a.size
785
+ }));
786
+ } catch {
787
+ metaOnly = void 0;
788
+ }
789
+ }
790
+ return { ...r, attachments: metaOnly };
791
+ });
792
+ res.json({ drafts: stripped });
793
+ } catch (err) {
794
+ next(err);
795
+ }
796
+ });
797
+ router.get("/drafts/:id", requireAgent, async (req, res, next) => {
798
+ try {
799
+ const row = db.prepare("SELECT * FROM drafts WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
800
+ if (!row) {
801
+ res.status(404).json({ error: "Draft not found" });
802
+ return;
803
+ }
804
+ if (row.attachments) {
805
+ try {
806
+ row.attachments = JSON.parse(row.attachments);
807
+ } catch {
808
+ row.attachments = [];
809
+ }
810
+ }
811
+ res.json(row);
758
812
  } catch (err) {
759
813
  next(err);
760
814
  }
761
815
  });
762
816
  router.post("/drafts", requireAgent, async (req, res, next) => {
763
817
  try {
764
- const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
818
+ const { to, subject, text, html, cc, bcc, inReplyTo, references, attachments } = req.body || {};
819
+ const attachmentsJson = normaliseDraftAttachments(attachments);
765
820
  const id = uuidv4();
766
- db.prepare(`INSERT INTO drafts (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, in_reply_to, refs)
767
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
821
+ db.prepare(`INSERT INTO drafts (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, in_reply_to, refs, attachments)
822
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
768
823
  id,
769
824
  req.agent.id,
770
825
  to || null,
@@ -774,19 +829,30 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
774
829
  cc || null,
775
830
  bcc || null,
776
831
  inReplyTo || null,
777
- references ? JSON.stringify(references) : null
832
+ references ? JSON.stringify(references) : null,
833
+ attachmentsJson
778
834
  );
779
835
  res.json({ ok: true, id });
780
836
  } catch (err) {
837
+ const status = err.status;
838
+ if (status) {
839
+ res.status(status).json({ error: err.message });
840
+ return;
841
+ }
781
842
  next(err);
782
843
  }
783
844
  });
784
845
  router.put("/drafts/:id", requireAgent, async (req, res, next) => {
785
846
  try {
786
- const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
787
- const result = db.prepare(`UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
788
- cc=?, bcc=?, in_reply_to=?, refs=?, updated_at=datetime('now')
789
- WHERE id=? AND agent_id=?`).run(
847
+ const { to, subject, text, html, cc, bcc, inReplyTo, references, attachments } = req.body || {};
848
+ const includeAttachments = Object.prototype.hasOwnProperty.call(req.body || {}, "attachments");
849
+ const attachmentsJson = includeAttachments ? normaliseDraftAttachments(attachments) : void 0;
850
+ const sql = includeAttachments ? `UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
851
+ cc=?, bcc=?, in_reply_to=?, refs=?, attachments=?, updated_at=datetime('now')
852
+ WHERE id=? AND agent_id=?` : `UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
853
+ cc=?, bcc=?, in_reply_to=?, refs=?, updated_at=datetime('now')
854
+ WHERE id=? AND agent_id=?`;
855
+ const params = [
790
856
  to || null,
791
857
  subject || null,
792
858
  text || null,
@@ -794,16 +860,22 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
794
860
  cc || null,
795
861
  bcc || null,
796
862
  inReplyTo || null,
797
- references ? JSON.stringify(references) : null,
798
- req.params.id,
799
- req.agent.id
800
- );
863
+ references ? JSON.stringify(references) : null
864
+ ];
865
+ if (includeAttachments) params.push(attachmentsJson);
866
+ params.push(req.params.id, req.agent.id);
867
+ const result = db.prepare(sql).run(...params);
801
868
  if (result.changes === 0) {
802
869
  res.status(404).json({ error: "Draft not found" });
803
870
  return;
804
871
  }
805
872
  res.json({ ok: true });
806
873
  } catch (err) {
874
+ const status = err.status;
875
+ if (status) {
876
+ res.status(status).json({ error: err.message });
877
+ return;
878
+ }
807
879
  next(err);
808
880
  }
809
881
  });
@@ -833,6 +905,19 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
833
905
  const agent = req.agent;
834
906
  const wakeList = normalizeWakeList(req.body?.wake);
835
907
  const customHeaders = wakeHeaders(wakeList);
908
+ let persistedAttachments;
909
+ if (draft.attachments) {
910
+ try {
911
+ const parsed = JSON.parse(draft.attachments);
912
+ persistedAttachments = parsed.map((a) => ({
913
+ filename: a.filename,
914
+ contentType: a.contentType,
915
+ content: a.content,
916
+ encoding: "base64"
917
+ }));
918
+ } catch {
919
+ }
920
+ }
836
921
  const mailOpts = {
837
922
  to: draft.to_addr,
838
923
  subject: draft.subject || "(no subject)",
@@ -842,6 +927,7 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
842
927
  bcc: draft.bcc || void 0,
843
928
  inReplyTo: draft.in_reply_to || void 0,
844
929
  references: draft.refs ? JSON.parse(draft.refs) : void 0,
930
+ ...persistedAttachments && persistedAttachments.length > 0 ? { attachments: persistedAttachments } : {},
845
931
  ...Object.keys(customHeaders).length > 0 ? { headers: customHeaders } : {}
846
932
  };
847
933
  if (gatewayManager) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
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
 
@@ -55,9 +55,16 @@ function showAuthErr(msg) {
55
55
  }
56
56
  function signOut() {
57
57
  localStorage.removeItem('agenticmail.masterKey');
58
+ localStorage.removeItem('agenticmail.selectedAgentId');
58
59
  location.reload();
59
60
  }
60
61
 
62
+ // localStorage key for the inbox the user was last viewing.
63
+ // Persisted on every successful agent switch and consulted on
64
+ // bootstrap so a refresh / reopen lands on the same account
65
+ // instead of bouncing back to the bridge.
66
+ const STORAGE_LAST_AGENT = 'agenticmail.selectedAgentId';
67
+
61
68
  document.getElementById('auth-submit').addEventListener('click', signIn);
62
69
  document.getElementById('auth-key').addEventListener('keydown', e => {
63
70
  if (e.key === 'Enter') signIn();
@@ -77,7 +84,15 @@ async function bootstrap() {
77
84
  return a.name.localeCompare(b.name);
78
85
  });
79
86
  state.agents = all;
80
- const initial = state.agents.find(isBridgeAgent) ?? state.agents[0];
87
+ // Prefer the inbox the user was last viewing (persisted in
88
+ // localStorage on every selectAgent call). Falls back to the
89
+ // bridge if the stored id is gone (agent was deleted) or the
90
+ // user never switched. Fixes the "refresh always bounces me
91
+ // back to the host account" bug.
92
+ const lastId = localStorage.getItem(STORAGE_LAST_AGENT);
93
+ const initial = (lastId && state.agents.find(a => a.id === lastId))
94
+ ?? state.agents.find(isBridgeAgent)
95
+ ?? state.agents[0];
81
96
  if (initial) await selectAgent(initial);
82
97
  renderProfile();
83
98
  populateComposeFrom();
@@ -96,6 +111,11 @@ async function selectAgent(agent) {
96
111
  state.selectedAgent = agent;
97
112
  state.selectedUid = null;
98
113
  state.currentMessage = null;
114
+ // Persist the selection so a page refresh lands back on this
115
+ // inbox rather than bouncing to the bridge. Stored under a
116
+ // separate key from the master key so signing out clears it
117
+ // cleanly without affecting auth.
118
+ try { localStorage.setItem(STORAGE_LAST_AGENT, agent.id); } catch { /* private mode etc. */ }
99
119
  // Reset the per-agent folder cache so a fresh discovery runs
100
120
  // against the new agent's IMAP. Otherwise switching to an
101
121
  // account that uses different folder names (e.g. Gmail relay
@@ -137,6 +157,14 @@ function route() {
137
157
  openMessage(Number(msgMatch[1]));
138
158
  return;
139
159
  }
160
+ // Drafts use UUIDs as ids and open the compose modal pre-
161
+ // populated rather than the read-only message view. The list
162
+ // row click handler emits #/d/<uuid> for draft rows.
163
+ const draftMatch = hash.match(/^#\/d\/([a-zA-Z0-9-]+)$/);
164
+ if (draftMatch) {
165
+ openDraft(draftMatch[1]);
166
+ return;
167
+ }
140
168
  const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
141
169
  const folder = folderMatch ? folderMatch[1] : 'inbox';
142
170
  if (state.selectedFolder !== folder) {
@@ -185,7 +213,7 @@ document.addEventListener('click', e => {
185
213
 
186
214
  // ─── Compose modal wiring ────────────────────────────────────────────
187
215
  document.getElementById('compose-close').addEventListener('click', closeCompose);
188
- document.getElementById('compose-cancel').addEventListener('click', closeCompose);
216
+ document.getElementById('compose-cancel').addEventListener('click', discardCompose);
189
217
  document.getElementById('compose-send').addEventListener('click', sendCompose);
190
218
  document.getElementById('compose-bg').addEventListener('click', e => {
191
219
  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,59 @@ 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
+ // Use the single-draft endpoint, which returns attachment
106
+ // content in full (the list endpoint only sends metadata to
107
+ // keep the sidebar payload small).
108
+ const draft = await apiGet(`/drafts/${encodeURIComponent(draftId)}`, { agentKey: state.selectedAgent.apiKey });
109
+ if (!draft) throw new Error('Draft not found');
110
+ document.getElementById('compose-to').value = draft.to_addr ?? '';
111
+ document.getElementById('compose-cc').value = draft.cc ?? '';
112
+ document.getElementById('compose-subject').value = draft.subject ?? '';
113
+ document.getElementById('compose-body').value = draft.text_body ?? '';
114
+ document.getElementById('compose-title').textContent =
115
+ `Draft: ${draft.subject || '(no subject)'}`;
116
+ // Rehydrate attachment chips with the persisted blobs. Map
117
+ // the server-side `size` field back into the in-memory
118
+ // `sizeBytes` alias the rest of the compose code uses for
119
+ // the UI-side 20 MB cap.
120
+ pendingAttachments = Array.isArray(draft.attachments)
121
+ ? draft.attachments.map(a => ({
122
+ filename: a.filename,
123
+ contentType: a.contentType,
124
+ content: a.content,
125
+ encoding: 'base64',
126
+ sizeBytes: typeof a.size === 'number' ? a.size : 0,
127
+ }))
128
+ : [];
129
+ renderAttachmentChips();
130
+ setComposeStatus('Loaded from Drafts');
131
+ setTimeout(() => document.getElementById('compose-body').focus(), 50);
132
+ } catch (err) {
133
+ setComposeStatus(`Couldn't load draft: ${err.message}`);
134
+ }
135
+ }
136
+
84
137
  export function closeCompose() {
85
138
  document.getElementById('compose-bg').style.display = 'none';
86
139
  // Flush a final save synchronously-ish on close so a quick
@@ -94,6 +147,34 @@ export function closeCompose() {
94
147
  }
95
148
  }
96
149
 
150
+ /**
151
+ * Discard the in-progress compose — delete the autosaved draft
152
+ * (if any) and close the modal. Distinct from `closeCompose`
153
+ * which just hides the modal and lets the draft persist for
154
+ * later resumption from the Drafts folder. Bound to the
155
+ * "Discard" button in the compose footer.
156
+ */
157
+ export async function discardCompose() {
158
+ // Cancel any pending autosave so it doesn't race the delete.
159
+ if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
160
+ const draftId = state.composeDraftId;
161
+ const agent = state.agents.find(a => a.id === document.getElementById('compose-from').value) ?? state.selectedAgent;
162
+ // Close UI first so the user gets immediate feedback even if
163
+ // the delete is slow / fails.
164
+ document.getElementById('compose-bg').style.display = 'none';
165
+ state.composeDraftId = null;
166
+ pendingAttachments = [];
167
+ if (draftId && agent) {
168
+ try { await apiDelete(`/drafts/${draftId}`, { agentKey: agent.apiKey }); }
169
+ catch { /* draft already gone or transient failure — fine */ }
170
+ // If the user is currently looking at the Drafts list, refresh
171
+ // so the deleted draft disappears from the visible rows.
172
+ if (state.selectedAgent && state.selectedFolder === 'drafts') {
173
+ try { await loadList(state.selectedAgent, 'drafts'); } catch { /* ignore */ }
174
+ }
175
+ }
176
+ }
177
+
97
178
  function showModal() {
98
179
  document.getElementById('compose-bg').style.display = 'flex';
99
180
  }
@@ -108,12 +189,27 @@ function readComposeFields() {
108
189
  const subject = document.getElementById('compose-subject').value.trim();
109
190
  const text = document.getElementById('compose-body').value;
110
191
  const cc = document.getElementById('compose-cc').value.trim();
111
- if (!to && !subject && !text.trim() && !cc) return null;
192
+ if (!to && !subject && !text.trim() && !cc && pendingAttachments.length === 0) return null;
193
+ // The API expects `{ filename, contentType, content (base64),
194
+ // size }` per attachment. Drop the local-only sizeBytes alias
195
+ // and the redundant encoding field — the server defaults to
196
+ // base64 anyway.
197
+ const attachments = pendingAttachments.map(a => ({
198
+ filename: a.filename,
199
+ contentType: a.contentType,
200
+ content: a.content,
201
+ size: a.sizeBytes,
202
+ }));
112
203
  return {
113
204
  to: to || null,
114
205
  subject: subject || null,
115
206
  text: text || null,
116
207
  cc: cc || null,
208
+ // Always send `attachments` (even empty) so the server clears
209
+ // the stored blob when the user removes every chip. The PUT
210
+ // route uses `hasOwnProperty('attachments')` to distinguish
211
+ // "leave alone" from "set to empty".
212
+ attachments,
117
213
  };
118
214
  }
119
215
 
@@ -212,6 +308,11 @@ function wireAttachmentPicker() {
212
308
  }
213
309
  }
214
310
  renderAttachmentChips();
311
+ // Persist the new attachments to the draft so a "close and
312
+ // reopen" round-trip keeps them. Without this, attachments
313
+ // only ever lived in memory until the user typed in another
314
+ // field and triggered autosave organically.
315
+ scheduleAutosave();
215
316
  });
216
317
  }
217
318
 
@@ -244,6 +345,9 @@ function renderAttachmentChips() {
244
345
  el.addEventListener('click', () => {
245
346
  pendingAttachments.splice(Number(el.dataset.attRemove), 1);
246
347
  renderAttachmentChips();
348
+ // Same reason as the picker: removing a chip needs to
349
+ // persist or the draft round-trip will resurrect the file.
350
+ scheduleAutosave();
247
351
  });
248
352
  });
249
353
  }
@@ -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
  });
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;