@agenticmail/api 0.7.19 → 0.7.21
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 +202 -21
- package/package.json +1 -1
- package/public/js/app.js +21 -1
- package/public/js/compose.js +41 -3
- package/public/js/icons.js +2 -0
- package/public/js/list-view.js +154 -4
- package/public/js/message-view.js +20 -0
- package/public/js/sidebar.js +1 -0
- package/public/styles.css +28 -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) {
|
|
@@ -1794,15 +1880,21 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
|
|
|
1794
1880
|
});
|
|
1795
1881
|
}
|
|
1796
1882
|
}
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1883
|
+
var sentFolderCache = /* @__PURE__ */ new Map();
|
|
1884
|
+
async function saveSentCopy(authUser, password, config, raw) {
|
|
1885
|
+
try {
|
|
1886
|
+
const receiver = await getReceiver(authUser, password, config);
|
|
1887
|
+
let folder = sentFolderCache.get(authUser);
|
|
1888
|
+
if (!folder) {
|
|
1889
|
+
const folders = await receiver.listFolders();
|
|
1890
|
+
const sentRe = /^sent\b|sent items|sent mail|sent messages|\[gmail\]\/sent/i;
|
|
1891
|
+
folder = folders.find((f) => f.specialUse === "\\Sent")?.path ?? folders.find((f) => sentRe.test(f.name) || sentRe.test(f.path))?.path ?? "Sent Items";
|
|
1892
|
+
sentFolderCache.set(authUser, folder);
|
|
1804
1893
|
}
|
|
1805
|
-
|
|
1894
|
+
await receiver.appendMessage(raw, folder, ["\\Seen"]);
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
|
|
1897
|
+
}
|
|
1806
1898
|
}
|
|
1807
1899
|
function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
1808
1900
|
const router = Router6();
|
|
@@ -2416,6 +2508,95 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2416
2508
|
next(err);
|
|
2417
2509
|
}
|
|
2418
2510
|
});
|
|
2511
|
+
router.post("/mail/messages/:uid/archive", requireAgent, async (req, res, next) => {
|
|
2512
|
+
try {
|
|
2513
|
+
const agent = req.agent;
|
|
2514
|
+
const uid = parseInt(req.params.uid);
|
|
2515
|
+
if (isNaN(uid) || uid < 1) {
|
|
2516
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
const sourceFolder = req.body?.folder || "INBOX";
|
|
2520
|
+
const password = getAgentPassword(agent);
|
|
2521
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2522
|
+
const folders = await receiver.listFolders();
|
|
2523
|
+
const archiveRe = /^archives?\b|^all archive\b/i;
|
|
2524
|
+
let archiveFolder = folders.find((f) => f.specialUse === "\\Archive")?.path ?? folders.find((f) => archiveRe.test(f.name) || archiveRe.test(f.path))?.path;
|
|
2525
|
+
if (!archiveFolder) {
|
|
2526
|
+
try {
|
|
2527
|
+
await receiver.createFolder("Archive");
|
|
2528
|
+
} catch {
|
|
2529
|
+
}
|
|
2530
|
+
archiveFolder = "Archive";
|
|
2531
|
+
}
|
|
2532
|
+
if (archiveFolder === sourceFolder) {
|
|
2533
|
+
res.status(400).json({ error: "Message already in archive" });
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
await receiver.moveMessage(uid, sourceFolder, archiveFolder);
|
|
2537
|
+
res.json({ ok: true, archive: archiveFolder });
|
|
2538
|
+
} catch (err) {
|
|
2539
|
+
next(err);
|
|
2540
|
+
}
|
|
2541
|
+
});
|
|
2542
|
+
router.post("/mail/batch/archive", requireAgent, async (req, res, next) => {
|
|
2543
|
+
try {
|
|
2544
|
+
const agent = req.agent;
|
|
2545
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2546
|
+
const uids = validateUids(rawUids);
|
|
2547
|
+
if (!uids) {
|
|
2548
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const sourceFolder = folder || "INBOX";
|
|
2552
|
+
const password = getAgentPassword(agent);
|
|
2553
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2554
|
+
const folders = await receiver.listFolders();
|
|
2555
|
+
const archiveRe = /^archives?\b|^all archive\b/i;
|
|
2556
|
+
let archiveFolder = folders.find((f) => f.specialUse === "\\Archive")?.path ?? folders.find((f) => archiveRe.test(f.name) || archiveRe.test(f.path))?.path;
|
|
2557
|
+
if (!archiveFolder) {
|
|
2558
|
+
try {
|
|
2559
|
+
await receiver.createFolder("Archive");
|
|
2560
|
+
} catch {
|
|
2561
|
+
}
|
|
2562
|
+
archiveFolder = "Archive";
|
|
2563
|
+
}
|
|
2564
|
+
if (archiveFolder === sourceFolder) {
|
|
2565
|
+
res.status(400).json({ error: "Messages already in archive" });
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
await receiver.batchMove(uids, sourceFolder, archiveFolder);
|
|
2569
|
+
res.json({ ok: true, archived: uids.length, archive: archiveFolder });
|
|
2570
|
+
} catch (err) {
|
|
2571
|
+
next(err);
|
|
2572
|
+
}
|
|
2573
|
+
});
|
|
2574
|
+
router.post("/mail/batch/trash", requireAgent, async (req, res, next) => {
|
|
2575
|
+
try {
|
|
2576
|
+
const agent = req.agent;
|
|
2577
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2578
|
+
const uids = validateUids(rawUids);
|
|
2579
|
+
if (!uids) {
|
|
2580
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
const sourceFolder = folder || "INBOX";
|
|
2584
|
+
const password = getAgentPassword(agent);
|
|
2585
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2586
|
+
const folders = await receiver.listFolders();
|
|
2587
|
+
const trashRe = /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i;
|
|
2588
|
+
const trashFolder = folders.find((f) => f.specialUse === "\\Trash")?.path ?? folders.find((f) => trashRe.test(f.name) || trashRe.test(f.path))?.path;
|
|
2589
|
+
if (!trashFolder || trashFolder === sourceFolder) {
|
|
2590
|
+
await receiver.batchDelete(uids, sourceFolder);
|
|
2591
|
+
res.json({ ok: true, deleted: uids.length });
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
await receiver.batchMove(uids, sourceFolder, trashFolder);
|
|
2595
|
+
res.json({ ok: true, trashed: uids.length, trash: trashFolder });
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
next(err);
|
|
2598
|
+
}
|
|
2599
|
+
});
|
|
2419
2600
|
router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
|
|
2420
2601
|
try {
|
|
2421
2602
|
const agent = req.agent;
|
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
|
}
|
package/public/js/icons.js
CHANGED
|
@@ -32,6 +32,8 @@ const PATHS = {
|
|
|
32
32
|
|
|
33
33
|
// ─── Sidebar folders ────────────────────────────────────────────
|
|
34
34
|
inbox: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H5V5h14v10z',
|
|
35
|
+
// Material-style archive: lidded box with horizontal slot.
|
|
36
|
+
archive: 'M20.54 5.23l-1.39-1.68A1.45 1.45 0 0 0 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23A2 2 0 0 0 3 6.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.5c0-.5-.18-.96-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.82-1h12l.93 1H5.12z',
|
|
35
37
|
sent: 'M2.01 21 23 12 2.01 3 2 10l15 2-15 2z',
|
|
36
38
|
drafts: 'M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2v-7l-8 5-8-5V5l8 5 8-5v2h2V5a2 2 0 0 0-2-2z',
|
|
37
39
|
allMail: 'M22 4h-2v9.38l-2.79-2.79L16 12l4 4 4-4-1.21-1.21L22 13.38V4zM4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8h-2v10H4V6h12V4H4z',
|
package/public/js/list-view.js
CHANGED
|
@@ -38,6 +38,11 @@ const FOLDER_MATCHERS = {
|
|
|
38
38
|
drafts: /^drafts?\b|\[gmail\]\/drafts/i,
|
|
39
39
|
spam: /^junk\b|junk mail|^spam\b|\[gmail\]\/spam/i,
|
|
40
40
|
trash: /^trash\b|deleted items|deleted messages|\[gmail\]\/trash|\[gmail\]\/bin/i,
|
|
41
|
+
// Archive is a Gmail/Outlook concept — most servers don't ship
|
|
42
|
+
// with one by default. We auto-create on demand (see the API's
|
|
43
|
+
// archive endpoint) so this matcher only needs to recognise
|
|
44
|
+
// existing folders.
|
|
45
|
+
archive: /^archives?\b|^all archive\b/i,
|
|
41
46
|
all: /^all mail\b|\[gmail\]\/all/i,
|
|
42
47
|
};
|
|
43
48
|
|
|
@@ -72,10 +77,11 @@ export async function ensureFolderCache(agent) {
|
|
|
72
77
|
} catch {
|
|
73
78
|
// Discovery failed — fall back to the most common defaults so
|
|
74
79
|
// at least Inbox + Sent work for vanilla Stalwart.
|
|
75
|
-
state.folderNames.sent
|
|
76
|
-
state.folderNames.drafts
|
|
77
|
-
state.folderNames.spam
|
|
78
|
-
state.folderNames.trash
|
|
80
|
+
state.folderNames.sent = 'Sent Items';
|
|
81
|
+
state.folderNames.drafts = 'Drafts';
|
|
82
|
+
state.folderNames.spam = 'Junk Mail';
|
|
83
|
+
state.folderNames.trash = 'Trash';
|
|
84
|
+
state.folderNames.archive = 'Archive';
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
|
|
@@ -91,6 +97,14 @@ export async function loadList(agent, folder) {
|
|
|
91
97
|
<input type="checkbox" id="list-select-all-input" />
|
|
92
98
|
</label>
|
|
93
99
|
<button class="icon-btn list-refresh" title="Refresh" id="list-refresh-btn">${icon('refresh', { size: 18 })}</button>
|
|
100
|
+
<div class="bulk-actions" id="bulk-actions" hidden>
|
|
101
|
+
<button class="icon-btn bulk-btn" id="bulk-archive" title="Archive selected">${icon('archive', { size: 18 })}</button>
|
|
102
|
+
<button class="icon-btn bulk-btn" id="bulk-delete" title="Delete selected">${icon('trash', { size: 18 })}</button>
|
|
103
|
+
<button class="icon-btn bulk-btn" id="bulk-spam" title="Report as spam">${icon('spam', { size: 18 })}</button>
|
|
104
|
+
<button class="icon-btn bulk-btn" id="bulk-mark-read" title="Mark as read">${icon('check', { size: 18 })}</button>
|
|
105
|
+
<button class="icon-btn bulk-btn" id="bulk-mark-unread" title="Mark as unread">${icon('mailUnread', { size: 18 })}</button>
|
|
106
|
+
<span class="bulk-count" id="bulk-count"></span>
|
|
107
|
+
</div>
|
|
94
108
|
<div class="list-toolbar-spacer"></div>
|
|
95
109
|
<span class="count-text" id="list-count"></span>
|
|
96
110
|
</div>
|
|
@@ -101,7 +115,17 @@ export async function loadList(agent, folder) {
|
|
|
101
115
|
const checked = e.target.checked;
|
|
102
116
|
document.querySelectorAll('#list-rows .row-check input[type=checkbox]')
|
|
103
117
|
.forEach(cb => { cb.checked = checked; });
|
|
118
|
+
updateBulkActions();
|
|
104
119
|
});
|
|
120
|
+
// Wire bulk-action handlers — each gathers the selected UIDs,
|
|
121
|
+
// calls the matching batch endpoint, and reloads the list. The
|
|
122
|
+
// toolbar visibility is driven by `updateBulkActions` which is
|
|
123
|
+
// called every time a checkbox flips.
|
|
124
|
+
document.getElementById('bulk-archive')?.addEventListener('click', () => runBulkAction(agent, folder, 'archive'));
|
|
125
|
+
document.getElementById('bulk-delete')?.addEventListener('click', () => runBulkAction(agent, folder, 'delete'));
|
|
126
|
+
document.getElementById('bulk-spam')?.addEventListener('click', () => runBulkAction(agent, folder, 'spam'));
|
|
127
|
+
document.getElementById('bulk-mark-read')?.addEventListener('click', () => runBulkAction(agent, folder, 'mark-read'));
|
|
128
|
+
document.getElementById('bulk-mark-unread')?.addEventListener('click', () => runBulkAction(agent, folder, 'mark-unread'));
|
|
105
129
|
|
|
106
130
|
// Drafts are a SQL-backed app primitive, not an IMAP mailbox.
|
|
107
131
|
// The autosave path writes to /drafts (sqlite) and the agent
|
|
@@ -204,6 +228,20 @@ export function renderList() {
|
|
|
204
228
|
if (state.selectedFolder === 'starred') {
|
|
205
229
|
filtered = filtered.filter(m => flagsHas(m.flags, '\\Flagged'));
|
|
206
230
|
}
|
|
231
|
+
// Defensive Sent-folder filter. The API serves the IMAP Sent
|
|
232
|
+
// mailbox directly, but some Stalwart configurations (or
|
|
233
|
+
// misconfigured saveSentCopy targets) can land messages whose
|
|
234
|
+
// sender ISN'T the active agent in Sent. Filter client-side
|
|
235
|
+
// so the user only ever sees messages they actually sent.
|
|
236
|
+
// This is a safety net — the server-side fix lives in
|
|
237
|
+
// saveSentCopy and the dispatcher's send path.
|
|
238
|
+
if (state.selectedFolder === 'sent' && state.selectedAgent?.email) {
|
|
239
|
+
const me = state.selectedAgent.email.toLowerCase();
|
|
240
|
+
filtered = filtered.filter(m => {
|
|
241
|
+
const fromAddr = (m.from?.[0]?.address ?? '').toLowerCase();
|
|
242
|
+
return fromAddr === me;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
207
245
|
|
|
208
246
|
const hlTerm = filters?.subject || filters?.from || filters?.text || '';
|
|
209
247
|
|
|
@@ -270,6 +308,12 @@ export function renderList() {
|
|
|
270
308
|
}).join('');
|
|
271
309
|
|
|
272
310
|
root.querySelectorAll('.list-row').forEach(el => {
|
|
311
|
+
// Checkbox change on individual rows — drives the bulk-action
|
|
312
|
+
// toolbar visibility. Attached separately from the row click
|
|
313
|
+
// handler so clicking the box doesn't propagate to "open
|
|
314
|
+
// message".
|
|
315
|
+
const cb = el.querySelector('.row-check input[type=checkbox]');
|
|
316
|
+
cb?.addEventListener('change', updateBulkActions);
|
|
273
317
|
el.addEventListener('click', (e) => {
|
|
274
318
|
// Star click — toggle via API and optimistically update the
|
|
275
319
|
// local flags so the icon flips without a reload.
|
|
@@ -298,6 +342,112 @@ export function renderList() {
|
|
|
298
342
|
});
|
|
299
343
|
}
|
|
300
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Read every checked row's UID. Empty array when nothing is
|
|
347
|
+
* selected. Used by the bulk-action handlers and toolbar
|
|
348
|
+
* visibility logic.
|
|
349
|
+
*/
|
|
350
|
+
function getSelectedUids() {
|
|
351
|
+
const uids = [];
|
|
352
|
+
document.querySelectorAll('#list-rows .list-row').forEach(row => {
|
|
353
|
+
const cb = row.querySelector('.row-check input[type=checkbox]');
|
|
354
|
+
if (cb?.checked) {
|
|
355
|
+
const uid = Number(row.dataset.uid);
|
|
356
|
+
if (Number.isFinite(uid)) uids.push(uid);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return uids;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Toggle the visibility of the bulk-action toolbar based on
|
|
364
|
+
* current selection. Also updates the count label so the user
|
|
365
|
+
* sees "3 selected" etc. Called on every checkbox change +
|
|
366
|
+
* after each successful bulk action.
|
|
367
|
+
*/
|
|
368
|
+
function updateBulkActions() {
|
|
369
|
+
const uids = getSelectedUids();
|
|
370
|
+
const bar = document.getElementById('bulk-actions');
|
|
371
|
+
const count = document.getElementById('bulk-count');
|
|
372
|
+
if (!bar || !count) return;
|
|
373
|
+
if (uids.length === 0) {
|
|
374
|
+
bar.hidden = true;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
bar.hidden = false;
|
|
378
|
+
count.textContent = `${uids.length} selected`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Execute a bulk action against every currently-selected row.
|
|
383
|
+
* Maps the action name to the matching batch endpoint, fires
|
|
384
|
+
* one request, then reloads the list so the rows disappear /
|
|
385
|
+
* change visibly. Confirm dialogs only on destructive actions
|
|
386
|
+
* (delete, spam) — archive + mark-read/unread are silent.
|
|
387
|
+
*/
|
|
388
|
+
async function runBulkAction(agent, folder, action) {
|
|
389
|
+
const uids = getSelectedUids();
|
|
390
|
+
if (uids.length === 0) return;
|
|
391
|
+
const imap = state.folderNames?.[folder] ?? 'INBOX';
|
|
392
|
+
let confirmTitle = '';
|
|
393
|
+
let confirmBody = '';
|
|
394
|
+
let confirmLabel = '';
|
|
395
|
+
let endpoint = '';
|
|
396
|
+
let body = { uids, folder: imap };
|
|
397
|
+
let danger = false;
|
|
398
|
+
switch (action) {
|
|
399
|
+
case 'archive':
|
|
400
|
+
endpoint = '/mail/batch/archive';
|
|
401
|
+
break;
|
|
402
|
+
case 'delete':
|
|
403
|
+
// From Trash, batch/trash falls through to permanent
|
|
404
|
+
// expunge; everywhere else it's a move-to-trash.
|
|
405
|
+
endpoint = '/mail/batch/trash';
|
|
406
|
+
danger = true;
|
|
407
|
+
confirmTitle = folder === 'trash' ? `Delete ${uids.length} message${uids.length === 1 ? '' : 's'} forever?` : `Move ${uids.length} message${uids.length === 1 ? '' : 's'} to Trash?`;
|
|
408
|
+
confirmBody = folder === 'trash' ? "This can't be undone." : 'You can recover them from Trash.';
|
|
409
|
+
confirmLabel = folder === 'trash' ? 'Delete forever' : 'Move to Trash';
|
|
410
|
+
break;
|
|
411
|
+
case 'spam':
|
|
412
|
+
// No batch/spam route yet — fall back to batch/move with
|
|
413
|
+
// the auto-discovered Spam folder.
|
|
414
|
+
endpoint = '/mail/batch/move';
|
|
415
|
+
body.toFolder = state.folderNames?.spam ?? 'Junk Mail';
|
|
416
|
+
danger = true;
|
|
417
|
+
confirmTitle = `Report ${uids.length} message${uids.length === 1 ? '' : 's'} as spam?`;
|
|
418
|
+
confirmBody = 'They will be moved to the Junk folder.';
|
|
419
|
+
confirmLabel = 'Report spam';
|
|
420
|
+
break;
|
|
421
|
+
case 'mark-read':
|
|
422
|
+
endpoint = '/mail/batch/seen';
|
|
423
|
+
break;
|
|
424
|
+
case 'mark-unread':
|
|
425
|
+
endpoint = '/mail/batch/unseen';
|
|
426
|
+
break;
|
|
427
|
+
default:
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (confirmTitle) {
|
|
431
|
+
const { confirmModal } = await import('./modal.js');
|
|
432
|
+
const ok = await confirmModal({ title: confirmTitle, body: confirmBody, confirm: confirmLabel, danger });
|
|
433
|
+
if (!ok) return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
await apiPost(endpoint, body, { agentKey: agent.apiKey });
|
|
437
|
+
toast(`${uids.length} message${uids.length === 1 ? '' : 's'} ${
|
|
438
|
+
action === 'archive' ? 'archived' :
|
|
439
|
+
action === 'delete' ? (folder === 'trash' ? 'deleted' : 'moved to Trash') :
|
|
440
|
+
action === 'spam' ? 'reported as spam' :
|
|
441
|
+
action === 'mark-read' ? 'marked as read' :
|
|
442
|
+
'marked as unread'
|
|
443
|
+
}.`);
|
|
444
|
+
// Reload so the rows that moved/changed visibly update.
|
|
445
|
+
await loadList(agent, folder);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
toast(`Bulk ${action} failed: ${err.message}`, true);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
301
451
|
/**
|
|
302
452
|
* Toggle the IMAP \Flagged flag on a message via the API. Updates
|
|
303
453
|
* the in-memory message object on success so renderList reflects
|
|
@@ -19,6 +19,7 @@ export async function openMessage(uid) {
|
|
|
19
19
|
<button class="icon-btn" id="msg-back" title="Back to list">${icon('back')}</button>
|
|
20
20
|
<button class="icon-btn" id="msg-reply" title="Reply">${icon('reply')}</button>
|
|
21
21
|
<button class="icon-btn" id="msg-reply-all" title="Reply all">${icon('replyAll')}</button>
|
|
22
|
+
<button class="icon-btn" id="msg-archive" title="Archive">${icon('archive')}</button>
|
|
22
23
|
<button class="icon-btn" id="msg-unread" title="Mark unread">${icon('mailUnread')}</button>
|
|
23
24
|
<button class="icon-btn" id="msg-spam" title="Report spam">${icon('spam')}</button>
|
|
24
25
|
<button class="icon-btn" id="msg-delete" title="Delete">${icon('trash')}</button>
|
|
@@ -29,6 +30,7 @@ export async function openMessage(uid) {
|
|
|
29
30
|
document.getElementById('msg-back').addEventListener('click', () => { location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`; });
|
|
30
31
|
document.getElementById('msg-reply').addEventListener('click', () => openReply(false));
|
|
31
32
|
document.getElementById('msg-reply-all').addEventListener('click', () => openReply(true));
|
|
33
|
+
document.getElementById('msg-archive').addEventListener('click', () => archiveMessage());
|
|
32
34
|
document.getElementById('msg-unread').addEventListener('click', () => markUnread());
|
|
33
35
|
document.getElementById('msg-spam').addEventListener('click', () => markSpam());
|
|
34
36
|
document.getElementById('msg-delete').addEventListener('click', () => deleteMessage());
|
|
@@ -219,6 +221,24 @@ async function markUnread() {
|
|
|
219
221
|
* route is POST /mail/messages/:uid/spam — it does the move +
|
|
220
222
|
* flags the message so future scans treat it as known spam.
|
|
221
223
|
*/
|
|
224
|
+
/**
|
|
225
|
+
* Archive the open message — move it to the Archive folder.
|
|
226
|
+
* No confirm dialog; archive is non-destructive (Gmail UX) so
|
|
227
|
+
* the user can always go to Archive and move things back.
|
|
228
|
+
*/
|
|
229
|
+
async function archiveMessage() {
|
|
230
|
+
if (!state.currentMessage || !state.selectedAgent) return;
|
|
231
|
+
try {
|
|
232
|
+
const imap = state.folderNames?.[state.selectedFolder] ?? 'INBOX';
|
|
233
|
+
await apiPost(`/mail/messages/${state.selectedUid}/archive`, { folder: imap }, { agentKey: state.selectedAgent.apiKey });
|
|
234
|
+
toast('Archived.');
|
|
235
|
+
location.hash = `#/folder/${state.selectedFolder ?? 'inbox'}`;
|
|
236
|
+
await loadList(state.selectedAgent, state.selectedFolder);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
toast(`Archive failed: ${err.message}`, true);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
222
242
|
async function markSpam() {
|
|
223
243
|
if (!state.currentMessage || !state.selectedAgent) return;
|
|
224
244
|
const ok = await confirmModal({
|
package/public/js/sidebar.js
CHANGED
|
@@ -19,6 +19,7 @@ export const FOLDERS = [
|
|
|
19
19
|
{ id: 'starred', label: 'Starred', icon: 'starOutline' },
|
|
20
20
|
{ id: 'sent', label: 'Sent', icon: 'sent' },
|
|
21
21
|
{ id: 'drafts', label: 'Drafts', icon: 'drafts' },
|
|
22
|
+
{ id: 'archive', label: 'Archive', icon: 'archive' },
|
|
22
23
|
{ id: 'all', label: 'All Mail', icon: 'allMail', requiresDiscovery: true },
|
|
23
24
|
{ id: 'spam', label: 'Spam', icon: 'spam' },
|
|
24
25
|
{ id: 'trash', label: 'Trash', icon: 'trash' },
|
package/public/styles.css
CHANGED
|
@@ -419,6 +419,27 @@ a { color: var(--accent-strong); }
|
|
|
419
419
|
font-size: 12px; color: var(--muted);
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/* Bulk-action toolbar — appears between select-all and refresh
|
|
423
|
+
when one or more rows are checked. Replaces the visual idle
|
|
424
|
+
state of the row (toolbar) with action buttons + a "N selected"
|
|
425
|
+
indicator on the right. */
|
|
426
|
+
.bulk-actions {
|
|
427
|
+
display: flex; align-items: center; gap: 4px;
|
|
428
|
+
margin-left: 8px;
|
|
429
|
+
padding-left: 12px;
|
|
430
|
+
border-left: 1px solid var(--line);
|
|
431
|
+
}
|
|
432
|
+
.bulk-actions[hidden] { display: none; }
|
|
433
|
+
.bulk-actions .bulk-btn {
|
|
434
|
+
width: 36px; height: 36px;
|
|
435
|
+
color: var(--ink-soft);
|
|
436
|
+
}
|
|
437
|
+
.bulk-actions .bulk-btn:hover { background: var(--bg-hover); color: var(--ink); }
|
|
438
|
+
.bulk-count {
|
|
439
|
+
font-size: 12px; font-weight: 500; color: var(--accent-strong);
|
|
440
|
+
margin-left: 8px;
|
|
441
|
+
}
|
|
442
|
+
|
|
422
443
|
/* Gmail-style compact rows.
|
|
423
444
|
Single line per message; subject + preview share one truncated
|
|
424
445
|
cell so longer previews tail off with ellipsis instead of
|
|
@@ -576,6 +597,13 @@ mark.search-hl {
|
|
|
576
597
|
max-width: 840px;
|
|
577
598
|
margin: 0 auto;
|
|
578
599
|
width: 100%;
|
|
600
|
+
/* Clear visual end-of-message marker. The reply / quoted-thread
|
|
601
|
+
chrome above made the body's bottom ambiguous; a hairline rule
|
|
602
|
+
gives the reader a definite stop and separates from the
|
|
603
|
+
attachments / next message in stack views. */
|
|
604
|
+
border-bottom: 1px solid var(--line);
|
|
605
|
+
padding-bottom: 28px;
|
|
606
|
+
margin-bottom: 8px;
|
|
579
607
|
}
|
|
580
608
|
.message-body h1, .message-body h2, .message-body h3 {
|
|
581
609
|
color: var(--pink); margin: 1.2em 0 .4em;
|