@bobfrankston/mailx 1.0.215 → 1.0.217
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/client/.msger-window.json +1 -1
- package/client/components/message-list.js +9 -0
- package/client/compose/compose.js +38 -25
- package/client/lib/api-client.js +2 -2
- package/client/lib/mailxapi.js +2 -2
- package/client/styles/components.css +14 -0
- package/package.json +3 -3
- package/packages/mailx-api/index.js +3 -3
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-imap/index.d.ts +4 -2
- package/packages/mailx-imap/index.js +47 -15
- package/packages/mailx-service/index.d.ts +2 -2
- package/packages/mailx-service/index.js +4 -4
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-store/db.js +3 -1
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":243,"y":28}
|
|
@@ -335,6 +335,15 @@ function appendMessages(body, accountId, items) {
|
|
|
335
335
|
tag.title = msgAccountId;
|
|
336
336
|
from.prepend(tag);
|
|
337
337
|
}
|
|
338
|
+
// Search/cross-folder results carry folderName — show a tag so the user
|
|
339
|
+
// can tell which folder each hit lives in.
|
|
340
|
+
if (msg.folderName) {
|
|
341
|
+
const folderTag = document.createElement("span");
|
|
342
|
+
folderTag.className = "ml-folder-tag";
|
|
343
|
+
folderTag.textContent = msg.folderName;
|
|
344
|
+
folderTag.title = `In folder: ${msg.folderName}`;
|
|
345
|
+
from.prepend(folderTag);
|
|
346
|
+
}
|
|
338
347
|
const subject = document.createElement("span");
|
|
339
348
|
subject.className = "ml-subject";
|
|
340
349
|
subject.innerHTML = escapeHtml(msg.subject);
|
|
@@ -292,28 +292,10 @@ function applyInit(init) {
|
|
|
292
292
|
else
|
|
293
293
|
editor.focus();
|
|
294
294
|
}
|
|
295
|
-
// ──
|
|
296
|
-
const stored = sessionStorage.getItem("composeInit");
|
|
297
|
-
if (stored) {
|
|
298
|
-
sessionStorage.removeItem("composeInit");
|
|
299
|
-
applyInit(JSON.parse(stored));
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
// New compose — focus To
|
|
303
|
-
toInput.focus();
|
|
304
|
-
}
|
|
305
|
-
// If From dropdown is empty (new compose without init, or init had no accounts), fetch from API
|
|
306
|
-
if (fromSelect.options.length === 0) {
|
|
307
|
-
getAccounts()
|
|
308
|
-
.then((accounts) => {
|
|
309
|
-
populateFromSelect(accounts);
|
|
310
|
-
})
|
|
311
|
-
.catch((e) => console.error("Failed to load accounts:", e));
|
|
312
|
-
}
|
|
313
|
-
// ── Auto-save drafts every 5 seconds ──
|
|
295
|
+
// ── Compose state (declared before init so the async IIFE can reference them) ──
|
|
314
296
|
let draftUid = null;
|
|
315
297
|
let draftId = null; // stable ID for dedup when APPENDUID unavailable
|
|
316
|
-
let draftTimer;
|
|
298
|
+
let draftTimer = null;
|
|
317
299
|
let lastDraftContent = "";
|
|
318
300
|
let draftSaving = false; // prevent concurrent saves
|
|
319
301
|
async function saveDraft() {
|
|
@@ -347,7 +329,33 @@ async function saveDraft() {
|
|
|
347
329
|
draftSaving = false;
|
|
348
330
|
}
|
|
349
331
|
}
|
|
350
|
-
|
|
332
|
+
// ── Initialize: always fetch real accounts from the API before applying init, then
|
|
333
|
+
// start the auto-save timer. Callers like message-viewer's Edit Draft pass
|
|
334
|
+
// init.accounts=[], so we can't trust what's in the init blob. ──
|
|
335
|
+
(async () => {
|
|
336
|
+
let accounts = [];
|
|
337
|
+
try {
|
|
338
|
+
accounts = await getAccounts();
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
console.error("Failed to load accounts:", e);
|
|
342
|
+
}
|
|
343
|
+
const stored = sessionStorage.getItem("composeInit");
|
|
344
|
+
if (stored) {
|
|
345
|
+
sessionStorage.removeItem("composeInit");
|
|
346
|
+
const init = JSON.parse(stored);
|
|
347
|
+
if (!init.accounts || init.accounts.length === 0)
|
|
348
|
+
init.accounts = accounts;
|
|
349
|
+
applyInit(init);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
populateFromSelect(accounts);
|
|
353
|
+
toInput.focus();
|
|
354
|
+
}
|
|
355
|
+
// Start auto-save after init so draftUid/draftId from the edit-draft flow are
|
|
356
|
+
// already in place when the first save fires.
|
|
357
|
+
draftTimer = setInterval(saveDraft, 5000);
|
|
358
|
+
})();
|
|
351
359
|
// ── Send ──
|
|
352
360
|
document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
353
361
|
const btn = document.getElementById("btn-send");
|
|
@@ -365,10 +373,15 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
365
373
|
};
|
|
366
374
|
try {
|
|
367
375
|
await sendMessage(body);
|
|
368
|
-
// Delete draft after successful send
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
376
|
+
// Delete draft after successful send — stop auto-save first so it can't
|
|
377
|
+
// append a fresh copy after we delete. Use the stored draftId as a fallback
|
|
378
|
+
// so any orphaned drafts with the same stable ID are cleaned up too.
|
|
379
|
+
if (draftTimer) {
|
|
380
|
+
clearInterval(draftTimer);
|
|
381
|
+
draftTimer = null;
|
|
382
|
+
}
|
|
383
|
+
if (draftUid || draftId) {
|
|
384
|
+
deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
|
|
372
385
|
}
|
|
373
386
|
closeCompose();
|
|
374
387
|
}
|
package/client/lib/api-client.js
CHANGED
|
@@ -143,8 +143,8 @@ export function saveSettings(settings) {
|
|
|
143
143
|
export function repairAccounts() {
|
|
144
144
|
return ipc().repairAccounts?.();
|
|
145
145
|
}
|
|
146
|
-
export function deleteDraft(accountId, draftUid) {
|
|
147
|
-
return ipc().deleteDraft?.(accountId, draftUid);
|
|
146
|
+
export function deleteDraft(accountId, draftUid, draftId) {
|
|
147
|
+
return ipc().deleteDraft?.(accountId, draftUid, draftId);
|
|
148
148
|
}
|
|
149
149
|
export function setupAccount(name, email, password) {
|
|
150
150
|
return ipc().setupAccount?.(name, email, password);
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
// Compose
|
|
90
90
|
sendMessage: function(msg) { return callNode("sendMessage", msg); },
|
|
91
91
|
saveDraft: function(params) { return callNode("saveDraft", params); },
|
|
92
|
-
deleteDraft: function(accountId, draftUid) {
|
|
93
|
-
return callNode("deleteDraft", { accountId: accountId, draftUid: draftUid });
|
|
92
|
+
deleteDraft: function(accountId, draftUid, draftId) {
|
|
93
|
+
return callNode("deleteDraft", { accountId: accountId, draftUid: draftUid, draftId: draftId });
|
|
94
94
|
},
|
|
95
95
|
|
|
96
96
|
// Search
|
|
@@ -385,6 +385,20 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
385
385
|
}
|
|
386
386
|
.ml-account-tag[title="gmail"] { background: #d93025; }
|
|
387
387
|
.ml-account-tag[title="bobma"] { background: #1a73e8; }
|
|
388
|
+
.ml-folder-tag {
|
|
389
|
+
display: inline-block;
|
|
390
|
+
padding: 0 6px;
|
|
391
|
+
font-size: 0.72rem;
|
|
392
|
+
font-weight: 500;
|
|
393
|
+
border-radius: 3px;
|
|
394
|
+
margin-right: 4px;
|
|
395
|
+
color: var(--color-text);
|
|
396
|
+
background: color-mix(in oklch, var(--color-accent) 18%, transparent);
|
|
397
|
+
vertical-align: baseline;
|
|
398
|
+
max-width: 10em;
|
|
399
|
+
overflow: hidden;
|
|
400
|
+
text-overflow: ellipsis;
|
|
401
|
+
}
|
|
388
402
|
.ml-subject {
|
|
389
403
|
overflow: hidden;
|
|
390
404
|
text-overflow: ellipsis;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.217",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.279",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.279",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -274,7 +274,7 @@ export function createApiRouter(db, imapManager) {
|
|
|
274
274
|
try {
|
|
275
275
|
const { accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId } = req.body;
|
|
276
276
|
const result = await svc.saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId);
|
|
277
|
-
res.json({ ok: true, draftUid: result.
|
|
277
|
+
res.json({ ok: true, draftUid: result.draftUid, draftId: result.draftId });
|
|
278
278
|
}
|
|
279
279
|
catch (e) {
|
|
280
280
|
res.status(500).json({ error: e.message });
|
|
@@ -282,8 +282,8 @@ export function createApiRouter(db, imapManager) {
|
|
|
282
282
|
});
|
|
283
283
|
router.delete("/draft", async (req, res) => {
|
|
284
284
|
try {
|
|
285
|
-
if (req.body.accountId && req.body.draftUid)
|
|
286
|
-
await svc.deleteDraft(req.body.accountId, req.body.draftUid);
|
|
285
|
+
if (req.body.accountId && (req.body.draftUid || req.body.draftId))
|
|
286
|
+
await svc.deleteDraft(req.body.accountId, req.body.draftUid || 0, req.body.draftId);
|
|
287
287
|
res.json({ ok: true });
|
|
288
288
|
}
|
|
289
289
|
catch (e) {
|
|
@@ -169,8 +169,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
169
169
|
/** Save a draft to the Drafts folder via IMAP APPEND.
|
|
170
170
|
* Returns the UID of the saved draft (for replacing on next save). */
|
|
171
171
|
saveDraft(accountId: string, rawMessage: string | Buffer, previousDraftUid?: number, draftId?: string): Promise<number | null>;
|
|
172
|
-
/** Delete a draft after successful send
|
|
173
|
-
|
|
172
|
+
/** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
|
|
173
|
+
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
174
|
+
* from earlier failed autosaves are cleaned up at the same time. */
|
|
175
|
+
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
174
176
|
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
|
|
175
177
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
176
178
|
/** Guard against concurrent processSendActions for the same account */
|
|
@@ -1558,24 +1558,32 @@ export class ImapManager extends EventEmitter {
|
|
|
1558
1558
|
}
|
|
1559
1559
|
const client = this.createClient(accountId);
|
|
1560
1560
|
try {
|
|
1561
|
-
// Delete previous draft —
|
|
1561
|
+
// Delete previous draft — try UID first (fast path), and ALWAYS also try
|
|
1562
|
+
// searchByHeader(X-Mailx-Draft-ID) as a safety net. Running both catches
|
|
1563
|
+
// orphans from a crash-mid-save or a UID delete that failed silently.
|
|
1562
1564
|
if (previousDraftUid) {
|
|
1563
1565
|
try {
|
|
1564
1566
|
await client.deleteMessageByUid(drafts.path, previousDraftUid);
|
|
1565
1567
|
}
|
|
1566
|
-
catch {
|
|
1568
|
+
catch (e) {
|
|
1569
|
+
console.error(` [drafts] Delete prev UID ${previousDraftUid} failed: ${e.message}`);
|
|
1570
|
+
}
|
|
1567
1571
|
}
|
|
1568
|
-
|
|
1569
|
-
// Search Drafts for our draft ID and delete it
|
|
1572
|
+
if (draftId) {
|
|
1570
1573
|
try {
|
|
1571
1574
|
const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
|
|
1572
1575
|
for (const uid of uids) {
|
|
1573
|
-
|
|
1576
|
+
try {
|
|
1577
|
+
await client.deleteMessageByUid(drafts.path, uid);
|
|
1578
|
+
}
|
|
1579
|
+
catch { /* next */ }
|
|
1574
1580
|
}
|
|
1575
1581
|
if (uids.length > 0)
|
|
1576
|
-
console.log(` [drafts] Deleted ${uids.length}
|
|
1582
|
+
console.log(` [drafts] Deleted ${uids.length} stale draft(s) by ID ${draftId}`);
|
|
1583
|
+
}
|
|
1584
|
+
catch (e) {
|
|
1585
|
+
console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
|
|
1577
1586
|
}
|
|
1578
|
-
catch { /* search not supported or failed — tolerate duplicate */ }
|
|
1579
1587
|
}
|
|
1580
1588
|
// Append new draft
|
|
1581
1589
|
const result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
|
|
@@ -1590,18 +1598,42 @@ export class ImapManager extends EventEmitter {
|
|
|
1590
1598
|
catch { /* ignore */ }
|
|
1591
1599
|
}
|
|
1592
1600
|
}
|
|
1593
|
-
/** Delete a draft after successful send
|
|
1594
|
-
|
|
1601
|
+
/** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
|
|
1602
|
+
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
1603
|
+
* from earlier failed autosaves are cleaned up at the same time. */
|
|
1604
|
+
async deleteDraft(accountId, draftUid, draftId) {
|
|
1595
1605
|
const drafts = this.findFolder(accountId, "drafts");
|
|
1596
|
-
if (!drafts
|
|
1606
|
+
if (!drafts)
|
|
1607
|
+
return;
|
|
1608
|
+
if (!draftUid && !draftId)
|
|
1597
1609
|
return;
|
|
1598
1610
|
const client = this.createClient(accountId);
|
|
1599
1611
|
try {
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1612
|
+
if (draftUid) {
|
|
1613
|
+
try {
|
|
1614
|
+
await client.deleteMessageByUid(drafts.path, draftUid);
|
|
1615
|
+
console.log(` [drafts] Deleted draft UID ${draftUid}`);
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
console.error(` [drafts] Delete by UID ${draftUid} failed: ${e.message}`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (draftId) {
|
|
1622
|
+
try {
|
|
1623
|
+
const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
|
|
1624
|
+
for (const uid of uids) {
|
|
1625
|
+
try {
|
|
1626
|
+
await client.deleteMessageByUid(drafts.path, uid);
|
|
1627
|
+
}
|
|
1628
|
+
catch { /* next */ }
|
|
1629
|
+
}
|
|
1630
|
+
if (uids.length > 0)
|
|
1631
|
+
console.log(` [drafts] Deleted ${uids.length} draft(s) by ID ${draftId}`);
|
|
1632
|
+
}
|
|
1633
|
+
catch (e) {
|
|
1634
|
+
console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1605
1637
|
}
|
|
1606
1638
|
finally {
|
|
1607
1639
|
try {
|
|
@@ -48,10 +48,10 @@ export declare class MailxService {
|
|
|
48
48
|
filename: string;
|
|
49
49
|
}>;
|
|
50
50
|
saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string): Promise<{
|
|
51
|
-
|
|
51
|
+
draftUid: number | null;
|
|
52
52
|
draftId: string;
|
|
53
53
|
}>;
|
|
54
|
-
deleteDraft(accountId: string, draftUid: number): Promise<void>;
|
|
54
|
+
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
55
55
|
searchContacts(query: string): any[];
|
|
56
56
|
syncGoogleContacts(): Promise<void>;
|
|
57
57
|
seedContacts(): number;
|
|
@@ -572,11 +572,11 @@ export class MailxService {
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
catch { /* ignore */ }
|
|
575
|
-
const
|
|
576
|
-
return {
|
|
575
|
+
const draftUid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
|
|
576
|
+
return { draftUid, draftId: id };
|
|
577
577
|
}
|
|
578
|
-
async deleteDraft(accountId, draftUid) {
|
|
579
|
-
await this.imapManager.deleteDraft(accountId, draftUid);
|
|
578
|
+
async deleteDraft(accountId, draftUid, draftId) {
|
|
579
|
+
await this.imapManager.deleteDraft(accountId, draftUid, draftId);
|
|
580
580
|
}
|
|
581
581
|
// ── Contacts ──
|
|
582
582
|
searchContacts(query) {
|
|
@@ -76,7 +76,7 @@ async function dispatchAction(svc, action, p) {
|
|
|
76
76
|
case "saveDraft":
|
|
77
77
|
return svc.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId);
|
|
78
78
|
case "deleteDraft":
|
|
79
|
-
await svc.deleteDraft(p.accountId, p.draftUid);
|
|
79
|
+
await svc.deleteDraft(p.accountId, p.draftUid, p.draftId);
|
|
80
80
|
return { ok: true };
|
|
81
81
|
// Sync
|
|
82
82
|
case "syncAll":
|
|
@@ -448,8 +448,9 @@ export class MailxDB {
|
|
|
448
448
|
}
|
|
449
449
|
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
|
|
450
450
|
const total = countRow?.cnt || 0;
|
|
451
|
-
const rows = this.db.prepare(`SELECT m
|
|
451
|
+
const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
|
|
452
452
|
JOIN messages_fts fts ON m.id = fts.rowid
|
|
453
|
+
LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
|
|
453
454
|
WHERE messages_fts MATCH ?${scopeWhere}
|
|
454
455
|
ORDER BY m.date DESC
|
|
455
456
|
LIMIT ? OFFSET ?`).all(ftsQuery, ...scopeParams, pageSize, offset);
|
|
@@ -457,6 +458,7 @@ export class MailxDB {
|
|
|
457
458
|
id: r.id,
|
|
458
459
|
accountId: r.account_id,
|
|
459
460
|
folderId: r.folder_id,
|
|
461
|
+
folderName: r.folder_name || "",
|
|
460
462
|
uid: r.uid,
|
|
461
463
|
messageId: r.message_id || "",
|
|
462
464
|
inReplyTo: r.in_reply_to || "",
|
|
@@ -59,6 +59,7 @@ export interface MessageEnvelope {
|
|
|
59
59
|
id: number; /** Local store ID */
|
|
60
60
|
accountId: string;
|
|
61
61
|
folderId: number;
|
|
62
|
+
folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */
|
|
62
63
|
uid: number; /** IMAP UID */
|
|
63
64
|
messageId: string; /** RFC Message-ID header */
|
|
64
65
|
inReplyTo: string; /** For threading */
|