@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 +99 -13
- package/package.json +1 -1
- package/public/js/app.js +21 -1
- package/public/js/compose.js +41 -3
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
|
-
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
799
|
-
|
|
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
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
|
-
|
|
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
|
package/public/js/compose.js
CHANGED
|
@@ -102,8 +102,10 @@ export async function openDraft(draftId) {
|
|
|
102
102
|
wireAutosave();
|
|
103
103
|
wireAttachmentPicker();
|
|
104
104
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
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
|
}
|