@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 +99 -13
- package/package.json +1 -1
- package/public/js/app.js +31 -3
- package/public/js/compose.js +106 -2
- package/public/js/list-view.js +75 -7
- package/public/styles.css +21 -0
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
|
@@ -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
|
-
|
|
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',
|
|
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();
|
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,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
|
}
|
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
|
});
|
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;
|