@bobfrankston/mailx 1.0.355 → 1.0.361
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/app.js +24 -0
- package/client/compose/editor.js +59 -9
- package/client/lib/api-client.js +69 -0
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +19 -0
- package/packages/mailx-service/index.js +15 -1
- package/packages/mailx-settings/index.js +4 -14
- package/packages/mailx-store/db.d.ts +14 -0
- package/packages/mailx-store/db.js +62 -0
package/client/app.js
CHANGED
|
@@ -1178,6 +1178,30 @@ window.addEventListener("message", (e) => {
|
|
|
1178
1178
|
})();
|
|
1179
1179
|
return;
|
|
1180
1180
|
}
|
|
1181
|
+
// Generic IPC relay: the iframe's api-client routes every IPC call through
|
|
1182
|
+
// postMessage when it's running in a child frame. Same reason as the
|
|
1183
|
+
// compose-send relay — sendMessage wasn't the only method the iframe's
|
|
1184
|
+
// bridge dropped; saveDraft hit the same wall ("Draft save failed: mailxapi
|
|
1185
|
+
// timeout"). This handler invokes the named method on THIS window's
|
|
1186
|
+
// mailxapi and posts the result back to the iframe.
|
|
1187
|
+
if (e.data?.type === "mailx-ipc" && e.data.id && e.data.method) {
|
|
1188
|
+
const src = e.source;
|
|
1189
|
+
const { id, method, args } = e.data;
|
|
1190
|
+
const bridge = window.mailxapi;
|
|
1191
|
+
const fn = bridge?.[method];
|
|
1192
|
+
if (typeof fn !== "function") {
|
|
1193
|
+
src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: `parent bridge has no method "${method}"` }, "*");
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
const result = fn.apply(bridge, args || []);
|
|
1198
|
+
Promise.resolve(result).then((value) => src?.postMessage({ type: "mailx-ipc-result", id, ok: true, result: value }, "*"), (err) => src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*"));
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*");
|
|
1202
|
+
}
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1181
1205
|
if (e.data?.type === "openLink" && e.data.url) {
|
|
1182
1206
|
window.open(e.data.url, "_blank", "noopener,noreferrer");
|
|
1183
1207
|
}
|
package/client/compose/editor.js
CHANGED
|
@@ -220,10 +220,24 @@ function createQuillEditor(container) {
|
|
|
220
220
|
document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
|
|
221
221
|
// Native spell-check: WebView2 / Chromium underlines misspellings and the
|
|
222
222
|
// right-click menu offers "Add to dictionary". Quill clears spellcheck on
|
|
223
|
-
// its root by default
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
// its root by default AND may re-clear it asynchronously as part of its
|
|
224
|
+
// setup (user-reported "spell-check broken again" with the one-shot set).
|
|
225
|
+
// Set once now, again after the frame flushes so post-init clears can't
|
|
226
|
+
// win, and install a MutationObserver to re-assert if anything later
|
|
227
|
+
// strips the attribute. Cheap — fires at most on each clear.
|
|
228
|
+
const applySpellcheck = () => {
|
|
229
|
+
if (q.root.getAttribute("spellcheck") !== "true")
|
|
230
|
+
q.root.setAttribute("spellcheck", "true");
|
|
231
|
+
if (q.root.getAttribute("autocorrect") !== "on")
|
|
232
|
+
q.root.setAttribute("autocorrect", "on");
|
|
233
|
+
if (q.root.getAttribute("autocapitalize") !== "on")
|
|
234
|
+
q.root.setAttribute("autocapitalize", "on");
|
|
235
|
+
};
|
|
236
|
+
applySpellcheck();
|
|
237
|
+
requestAnimationFrame(applySpellcheck);
|
|
238
|
+
setTimeout(applySpellcheck, 100);
|
|
239
|
+
const spellObs = new MutationObserver(() => applySpellcheck());
|
|
240
|
+
spellObs.observe(q.root, { attributes: true, attributeFilter: ["spellcheck", "autocorrect", "autocapitalize"] });
|
|
227
241
|
// Toolbar link button: open our modal instead of Quill's built-in URL prompt.
|
|
228
242
|
const toolbar = q.getModule("toolbar");
|
|
229
243
|
toolbar?.addHandler("link", function () {
|
|
@@ -288,17 +302,30 @@ function createQuillEditor(container) {
|
|
|
288
302
|
}
|
|
289
303
|
catch { /* fall through to native menu */ }
|
|
290
304
|
});
|
|
305
|
+
// IMPORTANT: register on the capture phase AND use stopImmediatePropagation
|
|
306
|
+
// when we handle the paste ourselves. Quill 2.x's clipboard module attaches
|
|
307
|
+
// its own listener on the same root element; preventDefault only stops the
|
|
308
|
+
// browser's default contenteditable insertion, NOT Quill's parallel listener.
|
|
309
|
+
// Without stopImmediatePropagation the two handlers fire independently and
|
|
310
|
+
// both insert — user sees the URL twice. Capture phase guarantees we run
|
|
311
|
+
// before Quill so stopImmediatePropagation actually blocks it.
|
|
291
312
|
q.root.addEventListener("paste", (e) => {
|
|
292
313
|
const cb = e.clipboardData;
|
|
293
314
|
if (!cb)
|
|
294
315
|
return;
|
|
316
|
+
// Helper: call when we've handled the paste ourselves. Stops Quill's
|
|
317
|
+
// own listener from also processing the same event.
|
|
318
|
+
const consume = () => {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
e.stopImmediatePropagation();
|
|
321
|
+
};
|
|
295
322
|
// Q3: image-on-clipboard → inline as data: URL.
|
|
296
323
|
for (const item of Array.from(cb.items)) {
|
|
297
324
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
|
298
325
|
const file = item.getAsFile();
|
|
299
326
|
if (!file)
|
|
300
327
|
continue;
|
|
301
|
-
|
|
328
|
+
consume();
|
|
302
329
|
const reader = new FileReader();
|
|
303
330
|
reader.onload = () => {
|
|
304
331
|
const dataUrl = String(reader.result || "");
|
|
@@ -337,7 +364,7 @@ function createQuillEditor(container) {
|
|
|
337
364
|
const href = a.getAttribute("href") || "";
|
|
338
365
|
const text = (a.textContent || "").trim();
|
|
339
366
|
if (href && text) {
|
|
340
|
-
|
|
367
|
+
consume();
|
|
341
368
|
const range = q.getSelection(true);
|
|
342
369
|
if (!range)
|
|
343
370
|
return;
|
|
@@ -350,12 +377,35 @@ function createQuillEditor(container) {
|
|
|
350
377
|
return;
|
|
351
378
|
}
|
|
352
379
|
}
|
|
380
|
+
// Single text-node wrapping the URL — common when copying from
|
|
381
|
+
// browser address bar (Chrome ships text/html as
|
|
382
|
+
// `<meta><span>URL</span>` alongside text/plain). Fall through
|
|
383
|
+
// to the plain-URL path below instead of letting Quill insert
|
|
384
|
+
// the bare URL text AND our handler insert it linked — which
|
|
385
|
+
// is exactly the double-paste the user reported.
|
|
386
|
+
const textOnly = (root.textContent || "").trim();
|
|
387
|
+
if (textOnly && looksLikeUrl(textOnly) && !root.querySelector("a")) {
|
|
388
|
+
consume();
|
|
389
|
+
const range = q.getSelection(true);
|
|
390
|
+
if (!range)
|
|
391
|
+
return;
|
|
392
|
+
const url = normalizeUrl(textOnly);
|
|
393
|
+
if (range.length > 0) {
|
|
394
|
+
q.formatText(range.index, range.length, "link", url);
|
|
395
|
+
q.setSelection(range.index + range.length, 0);
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
q.insertText(range.index, textOnly, { link: url });
|
|
399
|
+
q.setSelection(range.index + textOnly.length, 0);
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
353
403
|
}
|
|
354
404
|
catch { /* fall through to Quill default */ }
|
|
355
|
-
return; // Quill handles richer HTML clipboard
|
|
405
|
+
return; // Quill handles richer HTML clipboard (no consume → Quill runs)
|
|
356
406
|
}
|
|
357
407
|
if (plain && looksLikeUrl(plain)) {
|
|
358
|
-
|
|
408
|
+
consume();
|
|
359
409
|
const range = q.getSelection(true);
|
|
360
410
|
if (!range)
|
|
361
411
|
return;
|
|
@@ -370,7 +420,7 @@ function createQuillEditor(container) {
|
|
|
370
420
|
q.setSelection(range.index + plain.trim().length, 0);
|
|
371
421
|
}
|
|
372
422
|
}
|
|
373
|
-
});
|
|
423
|
+
}, true); // capture=true — run before Quill's own paste listener
|
|
374
424
|
// Hover preview: show the target URL in a floating tooltip when the
|
|
375
425
|
// pointer is over a link. Built on top of native mouseover/mouseout
|
|
376
426
|
// rather than Quill's ql-tooltip (which is keyboard-triggered).
|
package/client/lib/api-client.js
CHANGED
|
@@ -12,7 +12,76 @@ function getIpc() {
|
|
|
12
12
|
return window.parent.mailxapi;
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
|
+
/** Build a proxy bridge that forwards every method call to the parent window
|
|
16
|
+
* via postMessage. Used when the compose iframe's own attempt to reach
|
|
17
|
+
* msger's IPC silently drops messages — empirically, `sendMessage` and
|
|
18
|
+
* `saveDraft` both hit this (user-visible: "Sending…" spinner forever;
|
|
19
|
+
* "Draft save failed: mailxapi timeout"). The main window's bridge is
|
|
20
|
+
* provably fine, so the iframe routes through it. */
|
|
21
|
+
function buildRelayBridge() {
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
window.addEventListener("message", (ev) => {
|
|
24
|
+
if (!ev.data || ev.data.type !== "mailx-ipc-result" || !ev.data.id)
|
|
25
|
+
return;
|
|
26
|
+
const entry = pending.get(ev.data.id);
|
|
27
|
+
if (!entry)
|
|
28
|
+
return;
|
|
29
|
+
pending.delete(ev.data.id);
|
|
30
|
+
clearTimeout(entry.timer);
|
|
31
|
+
if (ev.data.ok)
|
|
32
|
+
entry.resolve(ev.data.result);
|
|
33
|
+
else
|
|
34
|
+
entry.reject(new Error(ev.data.error || "parent-relay ipc error"));
|
|
35
|
+
});
|
|
36
|
+
const call = (method, args) => {
|
|
37
|
+
const id = `ipc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
pending.delete(id);
|
|
41
|
+
reject(new Error(`parent-relay timeout: ${method}`));
|
|
42
|
+
}, 120000);
|
|
43
|
+
pending.set(id, { resolve, reject, timer });
|
|
44
|
+
try {
|
|
45
|
+
window.parent.postMessage({ type: "mailx-ipc", id, method, args }, "*");
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
pending.delete(id);
|
|
50
|
+
reject(e);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
// Proxy: any property access returns a function that forwards to parent.
|
|
55
|
+
// `isApp` / `platform` / other non-function reads return sensible defaults
|
|
56
|
+
// so existing getIpc-style checks still work.
|
|
57
|
+
return new Proxy({}, {
|
|
58
|
+
get(_t, prop) {
|
|
59
|
+
if (prop === "isApp")
|
|
60
|
+
return true;
|
|
61
|
+
if (prop === "platform")
|
|
62
|
+
return window.parent?.mailxapi?.platform || "webview2";
|
|
63
|
+
if (prop === "onEvent") {
|
|
64
|
+
// Event subscription can't be relayed simply — iframes that need
|
|
65
|
+
// events are rare. Fall back to direct parent bridge for onEvent
|
|
66
|
+
// since the subscription path doesn't hit the broken send path.
|
|
67
|
+
return (handler) => window.parent?.mailxapi?.onEvent?.(handler);
|
|
68
|
+
}
|
|
69
|
+
return (...args) => call(prop, args);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
let cachedRelayBridge = null;
|
|
15
74
|
function ipc() {
|
|
75
|
+
// Direct bridge is fine for the top window (main mailx app). The iframe
|
|
76
|
+
// (compose) can't trust its own bridge resolution because msger-routed
|
|
77
|
+
// sendMessage / saveDraft IPCs disappear without trace. So when we're in
|
|
78
|
+
// a child frame with a parent bridge, go through the parent.
|
|
79
|
+
const inIframe = window.parent && window.parent !== window;
|
|
80
|
+
if (inIframe && window.parent?.mailxapi?.isApp) {
|
|
81
|
+
if (!cachedRelayBridge)
|
|
82
|
+
cachedRelayBridge = buildRelayBridge();
|
|
83
|
+
return cachedRelayBridge;
|
|
84
|
+
}
|
|
16
85
|
const bridge = getIpc();
|
|
17
86
|
if (!bridge)
|
|
18
87
|
throw new Error("IPC bridge not available");
|
package/package.json
CHANGED
|
@@ -689,6 +689,14 @@ export class ImapManager extends EventEmitter {
|
|
|
689
689
|
}
|
|
690
690
|
if (msg.uid <= highestUid)
|
|
691
691
|
continue; // already have it
|
|
692
|
+
// Tombstone check: if the user locally deleted this Message-ID,
|
|
693
|
+
// don't re-import it. Server-side EXPUNGE may lag, or reconcile
|
|
694
|
+
// may find the message in an old list snapshot. Without this,
|
|
695
|
+
// deleted messages reappear on the next sync pass.
|
|
696
|
+
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
697
|
+
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
692
700
|
const source = msg.source || "";
|
|
693
701
|
let bodyPath = "";
|
|
694
702
|
let preview = "";
|
|
@@ -1581,6 +1589,17 @@ export class ImapManager extends EventEmitter {
|
|
|
1581
1589
|
this.syncIntervals.set("prefetch", prefetchInterval);
|
|
1582
1590
|
console.log(` [periodic] body prefetch every 60s (independent of sync)`);
|
|
1583
1591
|
}
|
|
1592
|
+
// Tombstone prune: age out local-delete records older than 30 days.
|
|
1593
|
+
// Runs hourly — cheap (one indexed DELETE).
|
|
1594
|
+
const TOMBSTONE_RETENTION_DAYS = 30;
|
|
1595
|
+
const pruneTombstones = () => {
|
|
1596
|
+
const cutoff = Date.now() - TOMBSTONE_RETENTION_DAYS * 86400_000;
|
|
1597
|
+
const n = this.db.pruneTombstones(cutoff);
|
|
1598
|
+
if (n > 0)
|
|
1599
|
+
console.log(` [tombstones] pruned ${n} older than ${TOMBSTONE_RETENTION_DAYS} days`);
|
|
1600
|
+
};
|
|
1601
|
+
setTimeout(pruneTombstones, 30_000); // first run after startup settles
|
|
1602
|
+
this.syncIntervals.set("tombstone-prune", setInterval(pruneTombstones, 3600_000));
|
|
1584
1603
|
// Full sync (all folders + IDLE restart) at configured interval
|
|
1585
1604
|
const fullInterval = setInterval(async () => {
|
|
1586
1605
|
console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
|
|
@@ -102,7 +102,7 @@ export class MailxService {
|
|
|
102
102
|
for (const cfg of cfgs) {
|
|
103
103
|
const a = dbAccounts.find(d => d.id === cfg.id);
|
|
104
104
|
if (a)
|
|
105
|
-
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || []
|
|
105
|
+
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [] });
|
|
106
106
|
}
|
|
107
107
|
// Append any DB accounts not in settings
|
|
108
108
|
for (const a of dbAccounts) {
|
|
@@ -646,6 +646,11 @@ export class MailxService {
|
|
|
646
646
|
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
647
647
|
if (!envelope)
|
|
648
648
|
throw new Error("Message not found");
|
|
649
|
+
// Tombstone the Message-ID so a subsequent sync pass can't resurrect
|
|
650
|
+
// the row if the server's EXPUNGE hasn't propagated yet. `undelete`
|
|
651
|
+
// removes the tombstone.
|
|
652
|
+
if (envelope.messageId)
|
|
653
|
+
this.db.addTombstone(accountId, envelope.messageId, envelope.subject || "");
|
|
649
654
|
await this.imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
|
|
650
655
|
}
|
|
651
656
|
async deleteMessages(accountId, uids) {
|
|
@@ -653,6 +658,9 @@ export class MailxService {
|
|
|
653
658
|
const env = this.db.getMessageByUid(accountId, uid);
|
|
654
659
|
if (!env)
|
|
655
660
|
return null;
|
|
661
|
+
// Tombstone each — same reason as single-delete above.
|
|
662
|
+
if (env.messageId)
|
|
663
|
+
this.db.addTombstone(accountId, env.messageId, env.subject || "");
|
|
656
664
|
return { uid: env.uid, folderId: env.folderId };
|
|
657
665
|
}).filter(m => m !== null);
|
|
658
666
|
await this.imapManager.trashMessages(accountId, messages);
|
|
@@ -711,6 +719,12 @@ export class MailxService {
|
|
|
711
719
|
return { targetFolderId: target.id, moved: uids.length };
|
|
712
720
|
}
|
|
713
721
|
async undeleteMessage(accountId, uid, folderId) {
|
|
722
|
+
// Clear the tombstone first so a subsequent sync can re-import if
|
|
723
|
+
// the server still has the row. Messages with no Message-ID just
|
|
724
|
+
// didn't get a tombstone — this is a no-op for them.
|
|
725
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
726
|
+
if (envelope?.messageId)
|
|
727
|
+
this.db.removeTombstone(accountId, envelope.messageId);
|
|
714
728
|
await this.imapManager.undeleteMessage(accountId, uid, folderId);
|
|
715
729
|
}
|
|
716
730
|
async deleteOnServer(accountId, folderPath, uid) {
|
|
@@ -307,43 +307,36 @@ const PROVIDERS = {
|
|
|
307
307
|
label: "Gmail",
|
|
308
308
|
imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
|
|
309
309
|
smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
|
|
310
|
-
spam: "SPAM", // Gmail labels, mailx tree shows as "SPAM"
|
|
311
310
|
},
|
|
312
311
|
"googlemail.com": {
|
|
313
312
|
label: "Gmail",
|
|
314
313
|
imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
|
|
315
314
|
smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
|
|
316
|
-
spam: "SPAM",
|
|
317
315
|
},
|
|
318
316
|
"outlook.com": {
|
|
319
317
|
label: "Outlook",
|
|
320
318
|
imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
|
|
321
319
|
smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
|
|
322
|
-
spam: "Junk Email",
|
|
323
320
|
},
|
|
324
321
|
"hotmail.com": {
|
|
325
322
|
label: "Hotmail",
|
|
326
323
|
imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
|
|
327
324
|
smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
|
|
328
|
-
spam: "Junk Email",
|
|
329
325
|
},
|
|
330
326
|
"yahoo.com": {
|
|
331
327
|
label: "Yahoo",
|
|
332
328
|
imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
|
|
333
329
|
smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
|
|
334
|
-
spam: "Bulk Mail",
|
|
335
330
|
},
|
|
336
331
|
"aol.com": {
|
|
337
332
|
label: "AOL",
|
|
338
333
|
imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
|
|
339
334
|
smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
|
|
340
|
-
spam: "Bulk Mail",
|
|
341
335
|
},
|
|
342
336
|
"icloud.com": {
|
|
343
337
|
label: "iCloud",
|
|
344
338
|
imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
|
|
345
339
|
smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
|
|
346
|
-
spam: "Junk",
|
|
347
340
|
},
|
|
348
341
|
};
|
|
349
342
|
/** Fill in provider defaults for an account based on email domain */
|
|
@@ -388,13 +381,10 @@ function normalizeAccount(acct, globalName) {
|
|
|
388
381
|
relayDomains: acct.relayDomains,
|
|
389
382
|
deliveredToPrefix: acct.deliveredToPrefix,
|
|
390
383
|
identityDomains: acct.identityDomains,
|
|
391
|
-
//
|
|
392
|
-
// the
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
// that had it configured. `acct.spam` first so a user-set value on
|
|
396
|
-
// a recognized provider still overrides the default.
|
|
397
|
-
spam: acct.spam !== undefined ? acct.spam : provider?.spam,
|
|
384
|
+
// `spam` passthrough retired 2026-04-22 — markAsSpamMessages now finds
|
|
385
|
+
// the junk folder via `specialUse === "junk"` on the DB folder record
|
|
386
|
+
// (populated by mailx-imap from iflow's getSpecialFolders()). Authoritative
|
|
387
|
+
// per-server info beats per-domain guesses.
|
|
398
388
|
// `signature` is on AccountConfig in mailx-types but the workspace
|
|
399
389
|
// build order sometimes leaves a stale .d.ts for type-check; using
|
|
400
390
|
// `as any` is the minimum-blast-radius way to add the field without
|
|
@@ -12,6 +12,20 @@ export declare class MailxDB {
|
|
|
12
12
|
hasSentMessage(messageId: string): boolean;
|
|
13
13
|
/** Record a successfully sent message so future attempts are skipped. */
|
|
14
14
|
recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
|
|
15
|
+
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
16
|
+
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
17
|
+
* can't check against future sync results anyway. */
|
|
18
|
+
addTombstone(accountId: string, messageId: string, subject?: string): void;
|
|
19
|
+
/** Is this Message-ID tombstoned for this account? */
|
|
20
|
+
hasTombstone(accountId: string, messageId: string): boolean;
|
|
21
|
+
/** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
|
|
22
|
+
* re-imports the message as normal. Also lets the user recover from a
|
|
23
|
+
* mistaken local delete. */
|
|
24
|
+
removeTombstone(accountId: string, messageId: string): void;
|
|
25
|
+
/** Age-out tombstones older than the given cutoff. Keeps the table from
|
|
26
|
+
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
27
|
+
* actual cutoff in ms since epoch. */
|
|
28
|
+
pruneTombstones(olderThanMs: number): number;
|
|
15
29
|
/** Idempotently add a column to a table if it's missing. */
|
|
16
30
|
private addColumnIfMissing;
|
|
17
31
|
/** Compute a thread id for an incoming message. Strategy:
|
|
@@ -126,6 +126,21 @@ const SCHEMA = `
|
|
|
126
126
|
last_error TEXT,
|
|
127
127
|
UNIQUE(account_id, action, uid, folder_id)
|
|
128
128
|
);
|
|
129
|
+
|
|
130
|
+
-- Tombstones: messages the user deleted locally. Sync checks this table
|
|
131
|
+
-- before inserting a new row so a server-side delete that hasn't yet
|
|
132
|
+
-- propagated (or a stale server listing during the EXPUNGE race) can't
|
|
133
|
+
-- resurrect a message the user already removed. Keyed by Message-ID
|
|
134
|
+
-- because that's the only identifier stable across UID renumbers,
|
|
135
|
+
-- UIDVALIDITY bumps, and cross-folder moves.
|
|
136
|
+
CREATE TABLE IF NOT EXISTS tombstones (
|
|
137
|
+
account_id TEXT NOT NULL,
|
|
138
|
+
message_id TEXT NOT NULL,
|
|
139
|
+
deleted_at INTEGER NOT NULL,
|
|
140
|
+
subject TEXT DEFAULT '',
|
|
141
|
+
PRIMARY KEY (account_id, message_id)
|
|
142
|
+
);
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
|
|
129
144
|
`;
|
|
130
145
|
export class MailxDB {
|
|
131
146
|
db;
|
|
@@ -172,6 +187,53 @@ export class MailxDB {
|
|
|
172
187
|
console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
|
|
173
188
|
}
|
|
174
189
|
}
|
|
190
|
+
// ── Tombstones (local-delete record so server echo can't resurrect) ──
|
|
191
|
+
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
192
|
+
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
193
|
+
* can't check against future sync results anyway. */
|
|
194
|
+
addTombstone(accountId, messageId, subject = "") {
|
|
195
|
+
if (!messageId)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
this.db.prepare("INSERT INTO tombstones (account_id, message_id, deleted_at, subject) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at").run(accountId, messageId, Date.now(), subject || "");
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Is this Message-ID tombstoned for this account? */
|
|
205
|
+
hasTombstone(accountId, messageId) {
|
|
206
|
+
if (!messageId)
|
|
207
|
+
return false;
|
|
208
|
+
const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
|
|
209
|
+
return !!row;
|
|
210
|
+
}
|
|
211
|
+
/** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
|
|
212
|
+
* re-imports the message as normal. Also lets the user recover from a
|
|
213
|
+
* mistaken local delete. */
|
|
214
|
+
removeTombstone(accountId, messageId) {
|
|
215
|
+
if (!messageId)
|
|
216
|
+
return;
|
|
217
|
+
try {
|
|
218
|
+
this.db.prepare("DELETE FROM tombstones WHERE account_id = ? AND message_id = ?").run(accountId, messageId);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
console.error(` [tombstones] failed to remove ${messageId}: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Age-out tombstones older than the given cutoff. Keeps the table from
|
|
225
|
+
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
226
|
+
* actual cutoff in ms since epoch. */
|
|
227
|
+
pruneTombstones(olderThanMs) {
|
|
228
|
+
try {
|
|
229
|
+
const res = this.db.prepare("DELETE FROM tombstones WHERE deleted_at < ?").run(olderThanMs);
|
|
230
|
+
return Number(res.changes || 0);
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
console.error(` [tombstones] prune failed: ${e.message}`);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
175
237
|
/** Idempotently add a column to a table if it's missing. */
|
|
176
238
|
addColumnIfMissing(table, column, sqlType) {
|
|
177
239
|
try {
|