@agenticmail/api 0.7.19 → 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.19",
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
@@ -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
@@ -102,8 +102,10 @@ export async function openDraft(draftId) {
102
102
  wireAutosave();
103
103
  wireAttachmentPicker();
104
104
  try {
105
- const data = await apiGet('/drafts', { agentKey: state.selectedAgent.apiKey });
106
- const draft = (data?.drafts ?? []).find(d => d.id === draftId);
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 });
107
109
  if (!draft) throw new Error('Draft not found');
108
110
  document.getElementById('compose-to').value = draft.to_addr ?? '';
109
111
  document.getElementById('compose-cc').value = draft.cc ?? '';
@@ -111,6 +113,19 @@ export async function openDraft(draftId) {
111
113
  document.getElementById('compose-body').value = draft.text_body ?? '';
112
114
  document.getElementById('compose-title').textContent =
113
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
+ : [];
114
129
  renderAttachmentChips();
115
130
  setComposeStatus('Loaded from Drafts');
116
131
  setTimeout(() => document.getElementById('compose-body').focus(), 50);
@@ -174,12 +189,27 @@ function readComposeFields() {
174
189
  const subject = document.getElementById('compose-subject').value.trim();
175
190
  const text = document.getElementById('compose-body').value;
176
191
  const cc = document.getElementById('compose-cc').value.trim();
177
- 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
+ }));
178
203
  return {
179
204
  to: to || null,
180
205
  subject: subject || null,
181
206
  text: text || null,
182
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,
183
213
  };
184
214
  }
185
215
 
@@ -278,6 +308,11 @@ function wireAttachmentPicker() {
278
308
  }
279
309
  }
280
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();
281
316
  });
282
317
  }
283
318
 
@@ -310,6 +345,9 @@ function renderAttachmentChips() {
310
345
  el.addEventListener('click', () => {
311
346
  pendingAttachments.splice(Number(el.dataset.attRemove), 1);
312
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();
313
351
  });
314
352
  });
315
353
  }