@bobfrankston/mailx 1.0.385 → 1.0.389
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/bin/mailx.js +51 -1
- package/client/components/calendar-sidebar.js +26 -2
- package/client/styles/components.css +11 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +5 -0
- package/packages/mailx-imap/index.js +99 -11
- package/packages/mailx-service/index.js +55 -4
- package/packages/mailx-store/db.js +32 -23
package/bin/mailx.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* mailx -test Test IMAP/SMTP connectivity
|
|
16
16
|
* mailx -rebuild Wipe local cache, re-sync from IMAP
|
|
17
17
|
* mailx -repair Re-sync metadata (fix corrupt subjects) keeping .eml files
|
|
18
|
+
* mailx -reauth Clear cached OAuth tokens; next start re-consents
|
|
19
|
+
* (use when new Google scopes have been added)
|
|
18
20
|
*/
|
|
19
21
|
import fs from "node:fs";
|
|
20
22
|
import path from "node:path";
|
|
@@ -87,7 +89,7 @@ function pidAlive(pid) {
|
|
|
87
89
|
// on an old UI with no indication that the install has been upgraded.
|
|
88
90
|
// Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
|
|
89
91
|
// the internal --daemon respawn.
|
|
90
|
-
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log"];
|
|
92
|
+
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
|
|
91
93
|
const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
|
|
92
94
|
if (!isDaemon && !__isCommandInvocation) {
|
|
93
95
|
const inst = readInstanceFile();
|
|
@@ -278,6 +280,39 @@ if (hasFlag("kill")) {
|
|
|
278
280
|
console.log("No mailx processes found");
|
|
279
281
|
process.exit(0);
|
|
280
282
|
}
|
|
283
|
+
// Re-auth: clear cached OAuth tokens so the next start forces a fresh
|
|
284
|
+
// consent flow. Needed when scopes change (e.g. Google Tasks was added
|
|
285
|
+
// 2026-04-23 but existing tokens were issued against the older scope
|
|
286
|
+
// set, so tasks API calls 403ed with "insufficient authentication
|
|
287
|
+
// scopes"). Safe — tokens are only a cache; fresh consent re-issues.
|
|
288
|
+
if (hasFlag("reauth")) {
|
|
289
|
+
const { getConfigDir } = await import("@bobfrankston/mailx-settings");
|
|
290
|
+
const tokensDir = path.join(getConfigDir(), "tokens");
|
|
291
|
+
if (!fs.existsSync(tokensDir)) {
|
|
292
|
+
console.log("No tokens directory — nothing to clear.");
|
|
293
|
+
process.exit(0);
|
|
294
|
+
}
|
|
295
|
+
let cleared = 0;
|
|
296
|
+
for (const entry of fs.readdirSync(tokensDir)) {
|
|
297
|
+
const userDir = path.join(tokensDir, entry);
|
|
298
|
+
try {
|
|
299
|
+
const stat = fs.statSync(userDir);
|
|
300
|
+
if (!stat.isDirectory())
|
|
301
|
+
continue;
|
|
302
|
+
const tokenFile = path.join(userDir, "oauth-token.json");
|
|
303
|
+
if (fs.existsSync(tokenFile)) {
|
|
304
|
+
fs.unlinkSync(tokenFile);
|
|
305
|
+
console.log(` Cleared token for ${entry}`);
|
|
306
|
+
cleared++;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch { /* skip */ }
|
|
310
|
+
}
|
|
311
|
+
console.log(cleared === 0
|
|
312
|
+
? "No cached tokens found."
|
|
313
|
+
: `Cleared ${cleared} cached token(s). Next 'mailx' start will open a browser OAuth consent so the new scopes (tasks, full contacts) get granted.`);
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
281
316
|
// Rebuild: wipe DB + message store, keep accounts/settings
|
|
282
317
|
if (rebuildMode) {
|
|
283
318
|
const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
|
|
@@ -1222,6 +1257,9 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1222
1257
|
imapManager.on("tasksUpdated", (payload) => {
|
|
1223
1258
|
handle.send({ _event: "tasksUpdated", type: "tasksUpdated", ...payload });
|
|
1224
1259
|
});
|
|
1260
|
+
imapManager.on("authScopeError", (payload) => {
|
|
1261
|
+
handle.send({ _event: "authScopeError", type: "authScopeError", ...payload });
|
|
1262
|
+
});
|
|
1225
1263
|
imapManager.on("bodyCached", (accountId, uid) => {
|
|
1226
1264
|
pendingCached.push({ accountId, uid });
|
|
1227
1265
|
if (!cachedTimer) {
|
|
@@ -1303,6 +1341,18 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1303
1341
|
// initial sync finishes so IMAP accounts get instant-push new-mail (the
|
|
1304
1342
|
// 5-min STATUS poll is only a safety net).
|
|
1305
1343
|
if (settings.accounts.some(a => a.enabled)) {
|
|
1344
|
+
// Fast-path: fire a quick INBOX check on every account IMMEDIATELY,
|
|
1345
|
+
// parallel to the full syncAll. quickInboxCheckAccount uses a fresh
|
|
1346
|
+
// client + a cached folder list from the DB, so it skips the
|
|
1347
|
+
// folder-list fetch that syncAll's step 1 does. On a cold Dovecot
|
|
1348
|
+
// session that folder LIST can take several seconds on big trees
|
|
1349
|
+
// (bobma = ~105 folders) — no reason the user should wait for it
|
|
1350
|
+
// before seeing mail that arrived overnight in INBOX.
|
|
1351
|
+
for (const acct of settings.accounts) {
|
|
1352
|
+
if (!acct.enabled)
|
|
1353
|
+
continue;
|
|
1354
|
+
imapManager.quickInboxCheckAccount(acct.id).catch(e => console.error(` [startup-check] ${acct.id}: ${e?.message || e}`));
|
|
1355
|
+
}
|
|
1306
1356
|
imapManager.syncAll()
|
|
1307
1357
|
.then(() => imapManager.startWatching())
|
|
1308
1358
|
.then(() => {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
|
|
17
17
|
const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
|
|
18
18
|
const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
|
|
19
|
+
const SHOW_DONE_PREF = "mailx-task-show-done";
|
|
19
20
|
const HORIZON_DAYS_PREF = "mailx-cal-horizon-days";
|
|
20
21
|
const HORIZON_DEFAULT_DAYS = 30;
|
|
21
22
|
let viewYear = new Date().getFullYear();
|
|
@@ -131,7 +132,8 @@ function renderEvents(events) {
|
|
|
131
132
|
});
|
|
132
133
|
}
|
|
133
134
|
async function renderTasks() {
|
|
134
|
-
const
|
|
135
|
+
const cb = document.getElementById("cal-side-show-done");
|
|
136
|
+
const showDone = cb?.checked ?? false;
|
|
135
137
|
const tasks = await getTasks(showDone);
|
|
136
138
|
const host = document.getElementById("cal-side-tasks");
|
|
137
139
|
if (!host)
|
|
@@ -248,7 +250,18 @@ export function initCalendarSidebar() {
|
|
|
248
250
|
const showDoneCb = document.getElementById("cal-side-show-done");
|
|
249
251
|
if (showDoneCb && !showDoneCb.__wired) {
|
|
250
252
|
showDoneCb.__wired = true;
|
|
251
|
-
|
|
253
|
+
// Sticky: restore prior state and persist on change. Default off.
|
|
254
|
+
try {
|
|
255
|
+
showDoneCb.checked = localStorage.getItem(SHOW_DONE_PREF) === "true";
|
|
256
|
+
}
|
|
257
|
+
catch { /* */ }
|
|
258
|
+
showDoneCb.addEventListener("change", () => {
|
|
259
|
+
try {
|
|
260
|
+
localStorage.setItem(SHOW_DONE_PREF, String(showDoneCb.checked));
|
|
261
|
+
}
|
|
262
|
+
catch { /* */ }
|
|
263
|
+
renderTasks();
|
|
264
|
+
});
|
|
252
265
|
}
|
|
253
266
|
// Recurring-events filter toggle — hides expanded recurring-series
|
|
254
267
|
// instances when unchecked. Default on so new users see everything.
|
|
@@ -295,6 +308,17 @@ export function initCalendarSidebar() {
|
|
|
295
308
|
refresh();
|
|
296
309
|
else if (event?.type === "tasksUpdated")
|
|
297
310
|
renderTasks();
|
|
311
|
+
else if (event?.type === "authScopeError") {
|
|
312
|
+
// Surface a visible hint right in the affected pane so the
|
|
313
|
+
// user doesn't stare at an empty list wondering why. Only
|
|
314
|
+
// writes to the matching pane; other panes keep rendering.
|
|
315
|
+
const host = event.feature === "tasks"
|
|
316
|
+
? document.getElementById("cal-side-tasks")
|
|
317
|
+
: document.getElementById("cal-side-body");
|
|
318
|
+
if (host) {
|
|
319
|
+
host.innerHTML = `<div class="cal-side-empty cal-side-auth-error">${escapeHtml(event.message || "Google access needs re-consent.")}</div>`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
298
322
|
});
|
|
299
323
|
}
|
|
300
324
|
}
|
|
@@ -1024,6 +1024,17 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
|
|
|
1024
1024
|
}
|
|
1025
1025
|
.cal-side-opt input[type=number] { margin-left: 4px; }
|
|
1026
1026
|
.cal-side-event { cursor: pointer; }
|
|
1027
|
+
.cal-side-auth-error {
|
|
1028
|
+
color: oklch(0.55 0.18 25);
|
|
1029
|
+
font-weight: 600;
|
|
1030
|
+
padding: var(--gap-sm);
|
|
1031
|
+
background: oklch(0.96 0.04 25);
|
|
1032
|
+
border: 1px solid oklch(0.80 0.12 25);
|
|
1033
|
+
border-radius: var(--radius-sm);
|
|
1034
|
+
font-style: normal !important;
|
|
1035
|
+
line-height: 1.4;
|
|
1036
|
+
text-align: left !important;
|
|
1037
|
+
}
|
|
1027
1038
|
.cal-side-empty {
|
|
1028
1039
|
padding: var(--gap-md) var(--gap-sm);
|
|
1029
1040
|
color: var(--color-text-muted);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.389",
|
|
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.7",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.349",
|
|
28
28
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
29
29
|
"@capacitor/android": "^8.3.0",
|
|
30
30
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"@bobfrankston/iflow-node": "^0.1.7",
|
|
89
89
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
90
90
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
91
|
-
"@bobfrankston/msger": "^0.1.
|
|
91
|
+
"@bobfrankston/msger": "^0.1.349",
|
|
92
92
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -133,6 +133,11 @@ export declare class ImapManager extends EventEmitter {
|
|
|
133
133
|
private getBodyClient;
|
|
134
134
|
/** Drop the body-fetch connection (e.g. after a socket error). */
|
|
135
135
|
private dropBodyClient;
|
|
136
|
+
/** Force-close every pooled client for an account — ops, body, any
|
|
137
|
+
* lingering ones in openClients. Used when the server reports its
|
|
138
|
+
* connection cap is hit so our slot count drops to zero on the
|
|
139
|
+
* server side before backoff expires. */
|
|
140
|
+
closeAllClients(accountId: string): Promise<void>;
|
|
136
141
|
/** Disconnect the persistent operational connection for an account */
|
|
137
142
|
disconnectOps(accountId: string): Promise<void>;
|
|
138
143
|
/** Legacy API — callers that still create/destroy connections.
|
|
@@ -498,6 +498,50 @@ export class ImapManager extends EventEmitter {
|
|
|
498
498
|
}
|
|
499
499
|
catch { /* */ }
|
|
500
500
|
}
|
|
501
|
+
/** Force-close every pooled client for an account — ops, body, any
|
|
502
|
+
* lingering ones in openClients. Used when the server reports its
|
|
503
|
+
* connection cap is hit so our slot count drops to zero on the
|
|
504
|
+
* server side before backoff expires. */
|
|
505
|
+
async closeAllClients(accountId) {
|
|
506
|
+
const ops = this.opsClients.get(accountId);
|
|
507
|
+
this.opsClients.delete(accountId);
|
|
508
|
+
if (ops) {
|
|
509
|
+
try {
|
|
510
|
+
await (ops._realLogout || ops.logout)();
|
|
511
|
+
}
|
|
512
|
+
catch { /* */ }
|
|
513
|
+
try {
|
|
514
|
+
ops.destroy?.();
|
|
515
|
+
}
|
|
516
|
+
catch { /* */ }
|
|
517
|
+
}
|
|
518
|
+
const body = this.bodyClients.get(accountId);
|
|
519
|
+
this.bodyClients.delete(accountId);
|
|
520
|
+
if (body) {
|
|
521
|
+
try {
|
|
522
|
+
await (body._realLogout || body.logout)();
|
|
523
|
+
}
|
|
524
|
+
catch { /* */ }
|
|
525
|
+
try {
|
|
526
|
+
body.destroy?.();
|
|
527
|
+
}
|
|
528
|
+
catch { /* */ }
|
|
529
|
+
}
|
|
530
|
+
const open = this.openClients.get(accountId);
|
|
531
|
+
if (open) {
|
|
532
|
+
for (const c of Array.from(open)) {
|
|
533
|
+
try {
|
|
534
|
+
await (c._realLogout || c.logout)?.();
|
|
535
|
+
}
|
|
536
|
+
catch { /* */ }
|
|
537
|
+
try {
|
|
538
|
+
c.destroy?.();
|
|
539
|
+
}
|
|
540
|
+
catch { /* */ }
|
|
541
|
+
}
|
|
542
|
+
open.clear();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
501
545
|
/** Disconnect the persistent operational connection for an account */
|
|
502
546
|
async disconnectOps(accountId) {
|
|
503
547
|
const client = this.opsClients.get(accountId);
|
|
@@ -976,23 +1020,41 @@ export class ImapManager extends EventEmitter {
|
|
|
976
1020
|
}
|
|
977
1021
|
if (newCount > 0)
|
|
978
1022
|
console.log(` stored ${newCount} new messages`);
|
|
979
|
-
// Remove messages deleted on the server (skip on first sync — nothing to reconcile)
|
|
1023
|
+
// Remove messages deleted on the server (skip on first sync — nothing to reconcile).
|
|
1024
|
+
//
|
|
1025
|
+
// SAFETY (same three guards the Gmail API path uses, see ~line 1388):
|
|
1026
|
+
// 1. Skip if server returned an empty list but we have local messages
|
|
1027
|
+
// (transient Dovecot error / connection hiccup returning empty UID SEARCH
|
|
1028
|
+
// must not wipe the folder).
|
|
1029
|
+
// 2. Refuse to delete more than 50% in one pass — indicates a sync bug,
|
|
1030
|
+
// never a real user action. User can fix with `mailx -rebuild` if real.
|
|
1031
|
+
// 3. Log every deletion with Message-ID + subject so future reports have
|
|
1032
|
+
// data (the "ubiquiti letter disappeared after reply" case had no trace).
|
|
980
1033
|
let deletedCount = 0;
|
|
981
1034
|
if (!firstSync) {
|
|
982
1035
|
try {
|
|
983
|
-
|
|
984
|
-
const serverUids = new Set(
|
|
1036
|
+
const serverUidsArr = await client.getUids(folder.path);
|
|
1037
|
+
const serverUids = new Set(serverUidsArr);
|
|
985
1038
|
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1039
|
+
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
1040
|
+
if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
1041
|
+
console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUids.length} (treating as transient)`);
|
|
1042
|
+
}
|
|
1043
|
+
else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
|
|
1044
|
+
console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
for (const uid of toDelete) {
|
|
1048
|
+
const env = this.db.getMessageByUid(accountId, uid);
|
|
1049
|
+
const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
|
|
1050
|
+
console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
|
|
989
1051
|
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
990
1052
|
this.db.deleteMessage(accountId, uid);
|
|
991
1053
|
deletedCount++;
|
|
992
1054
|
}
|
|
1055
|
+
if (deletedCount > 0)
|
|
1056
|
+
console.log(` removed ${deletedCount} deleted messages`);
|
|
993
1057
|
}
|
|
994
|
-
if (deletedCount > 0)
|
|
995
|
-
console.log(` removed ${deletedCount} deleted messages`);
|
|
996
1058
|
}
|
|
997
1059
|
catch (e) {
|
|
998
1060
|
console.error(` deletion sync error: ${e.message}`);
|
|
@@ -1350,6 +1412,9 @@ export class ImapManager extends EventEmitter {
|
|
|
1350
1412
|
}
|
|
1351
1413
|
else {
|
|
1352
1414
|
for (const uid of toDelete) {
|
|
1415
|
+
const env = this.db.getMessageByUid(accountId, uid);
|
|
1416
|
+
const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
|
|
1417
|
+
console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
|
|
1353
1418
|
this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
|
|
1354
1419
|
this.db.deleteMessage(accountId, uid);
|
|
1355
1420
|
}
|
|
@@ -1441,7 +1506,16 @@ export class ImapManager extends EventEmitter {
|
|
|
1441
1506
|
/** Handle sync errors — classify and emit appropriate UI events */
|
|
1442
1507
|
handleSyncError(accountId, errMsg) {
|
|
1443
1508
|
if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
|
|
1444
|
-
|
|
1509
|
+
// Dovecot connection cap hit. 60s was too short — the server
|
|
1510
|
+
// tracks slots with a decay window, and mailx was racing right
|
|
1511
|
+
// back into the cap every time. Extend to 5 min AND close all
|
|
1512
|
+
// pooled clients so the server's count drops to zero. Also
|
|
1513
|
+
// mark all of this account's folder-cooldowns so prefetch
|
|
1514
|
+
// doesn't try to reopen during the backoff.
|
|
1515
|
+
const BACKOFF_MS = 5 * 60_000;
|
|
1516
|
+
this.connectionBackoff.set(accountId, Date.now() + BACKOFF_MS);
|
|
1517
|
+
this.closeAllClients(accountId).catch(() => { });
|
|
1518
|
+
console.warn(` [conn] ${accountId}: server connection cap hit — closing all clients + ${BACKOFF_MS / 1000}s backoff`);
|
|
1445
1519
|
}
|
|
1446
1520
|
const config = this.configs.get(accountId);
|
|
1447
1521
|
const isOAuth = !!config?.tokenProvider;
|
|
@@ -2139,9 +2213,19 @@ export class ImapManager extends EventEmitter {
|
|
|
2139
2213
|
this.clearFolderErrors(accountId, folder.path);
|
|
2140
2214
|
}
|
|
2141
2215
|
catch (e) {
|
|
2142
|
-
|
|
2216
|
+
const msg = String(e?.message || "");
|
|
2217
|
+
console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${msg}`);
|
|
2143
2218
|
counters.errors++;
|
|
2144
2219
|
this.recordFolderError(accountId, folder.path);
|
|
2220
|
+
// Server connection cap hit during prefetch — this is why
|
|
2221
|
+
// bobma log shows "100+ bodies to fetch" with no follow-up
|
|
2222
|
+
// "done": subsequent folders also hit the cap, burn the
|
|
2223
|
+
// budget, and nothing progresses. Route through the
|
|
2224
|
+
// sync-error handler so backoff + closeAllClients kick in.
|
|
2225
|
+
if (/max_userip_connections|Too many simultaneous/i.test(msg)) {
|
|
2226
|
+
this.handleSyncError(accountId, msg);
|
|
2227
|
+
break;
|
|
2228
|
+
}
|
|
2145
2229
|
if (counters.errors >= ERROR_BUDGET)
|
|
2146
2230
|
break;
|
|
2147
2231
|
}
|
|
@@ -3260,7 +3344,11 @@ export class ImapManager extends EventEmitter {
|
|
|
3260
3344
|
// After each full tick, refresh the UI indicator.
|
|
3261
3345
|
this.emitOutboxStatus();
|
|
3262
3346
|
};
|
|
3263
|
-
|
|
3347
|
+
// First tick at 500ms so any stale .sending-HOST-DEAD_PID claim file
|
|
3348
|
+
// left behind by a prior crash gets recovered (renamed back to .ltr)
|
|
3349
|
+
// within half a second of startup — otherwise the status-queue pill
|
|
3350
|
+
// shows a red "1 queued" to the user until the first 10s tick passes.
|
|
3351
|
+
setTimeout(() => processAll(), 500);
|
|
3264
3352
|
this.outboxInterval = setInterval(processAll, 10000);
|
|
3265
3353
|
}
|
|
3266
3354
|
/** Stop Outbox worker */
|
|
@@ -472,7 +472,16 @@ export class MailxService {
|
|
|
472
472
|
if (changed)
|
|
473
473
|
this.imapManager.emit("calendarUpdated", { accountId: acct.id });
|
|
474
474
|
})
|
|
475
|
-
.catch(e =>
|
|
475
|
+
.catch(e => {
|
|
476
|
+
const msg = String(e?.message || e);
|
|
477
|
+
console.error(`[calendar] refresh failed: ${msg}`);
|
|
478
|
+
if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
|
|
479
|
+
this.imapManager.emit("authScopeError", {
|
|
480
|
+
feature: "calendar",
|
|
481
|
+
message: "Google Calendar access needs re-consent. Run `mailx -reauth` then restart.",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
});
|
|
476
485
|
return this.db.getCalendarEvents(acct.id, fromMs, toMs);
|
|
477
486
|
}
|
|
478
487
|
/** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
|
|
@@ -481,6 +490,7 @@ export class MailxService {
|
|
|
481
490
|
async refreshCalendarEvents(accountId, fromMs, toMs) {
|
|
482
491
|
const tp = await this.primaryTokenProvider("calendar");
|
|
483
492
|
const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
|
|
493
|
+
console.log(` [calendar] pulled ${events.length} events from ${new Date(fromMs).toISOString().slice(0, 10)} to ${new Date(toMs).toISOString().slice(0, 10)}`);
|
|
484
494
|
let changed = false;
|
|
485
495
|
// Upsert by provider_id — dedup globally, not just within the window,
|
|
486
496
|
// so an event whose start moves outside the prior query range doesn't
|
|
@@ -563,12 +573,24 @@ export class MailxService {
|
|
|
563
573
|
if (changed)
|
|
564
574
|
this.imapManager.emit("tasksUpdated", { accountId: acct.id });
|
|
565
575
|
})
|
|
566
|
-
.catch(e =>
|
|
576
|
+
.catch(e => {
|
|
577
|
+
const msg = String(e?.message || e);
|
|
578
|
+
console.error(`[tasks] refresh failed: ${msg}`);
|
|
579
|
+
if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
|
|
580
|
+
console.error(`[tasks] Your cached OAuth token doesn't include the 'tasks' scope.`);
|
|
581
|
+
console.error(`[tasks] Run 'mailx -reauth' then 'mailx' to re-consent and pick up the new scope.`);
|
|
582
|
+
this.imapManager.emit("authScopeError", {
|
|
583
|
+
feature: "tasks",
|
|
584
|
+
message: "Google Tasks access needs re-consent. Run `mailx -reauth` then restart.",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
});
|
|
567
588
|
return this.db.getTasks(acct.id, includeCompleted);
|
|
568
589
|
}
|
|
569
590
|
async refreshTasks(accountId, includeCompleted) {
|
|
570
591
|
const tp = await this.primaryTokenProvider("tasks");
|
|
571
592
|
const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
|
|
593
|
+
console.log(` [tasks] pulled ${tasks.length} tasks`);
|
|
572
594
|
const existing = this.db.getTasks(accountId, true);
|
|
573
595
|
let changed = false;
|
|
574
596
|
const seen = new Set();
|
|
@@ -730,11 +752,40 @@ export class MailxService {
|
|
|
730
752
|
for (const f of fs.readdirSync(dir)) {
|
|
731
753
|
if (!f.endsWith(".ltr") && !f.endsWith(".eml") && !/\.sending-/.test(f))
|
|
732
754
|
continue;
|
|
755
|
+
const fp = path.join(dir, f);
|
|
733
756
|
try {
|
|
734
|
-
const raw = fs.readFileSync(
|
|
757
|
+
const raw = fs.readFileSync(fp, "utf-8");
|
|
735
758
|
out.push(parseEnv(raw, f, dir, accountId));
|
|
736
759
|
}
|
|
737
|
-
catch {
|
|
760
|
+
catch (err) {
|
|
761
|
+
// Unreadable file — still show it so the user can cancel.
|
|
762
|
+
// Previously silently skipped, which produced the user-reported
|
|
763
|
+
// "outbox badge shows 1 but the modal is empty" symptom:
|
|
764
|
+
// getOutboxStatus counted the file, listQueuedOutgoing dropped it.
|
|
765
|
+
const st = (() => { try {
|
|
766
|
+
return fs.statSync(fp);
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
return null;
|
|
770
|
+
} })();
|
|
771
|
+
out.push({
|
|
772
|
+
accountId,
|
|
773
|
+
file: f,
|
|
774
|
+
path: fp,
|
|
775
|
+
dir,
|
|
776
|
+
from: "",
|
|
777
|
+
to: "",
|
|
778
|
+
cc: "",
|
|
779
|
+
bcc: "",
|
|
780
|
+
subject: `[unreadable: ${err?.code || err?.message || "read failed"}]`,
|
|
781
|
+
date: "",
|
|
782
|
+
messageId: "",
|
|
783
|
+
attempts: 0,
|
|
784
|
+
sizeBytes: st?.size || 0,
|
|
785
|
+
createdAt: st?.mtimeMs || 0,
|
|
786
|
+
claimed: /\.sending-[^-]+-\d+$/.test(f),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
738
789
|
}
|
|
739
790
|
};
|
|
740
791
|
try {
|
|
@@ -1023,32 +1023,41 @@ export class MailxDB {
|
|
|
1023
1023
|
}
|
|
1024
1024
|
/** Search contacts by name or email prefix */
|
|
1025
1025
|
searchContacts(query, limit = 10) {
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1026
|
+
// Ranking: prefix matches beat substring matches, then recency-weighted
|
|
1027
|
+
// use_count within a tier. Recency decay: half-life of 30 days, so a
|
|
1028
|
+
// contact used today edges out one from months ago even with a lower
|
|
1029
|
+
// raw use_count. Computed in JS since SQLite lacks exp/log.
|
|
1030
|
+
//
|
|
1031
|
+
// Wrapped in try/catch + simple-query fallback so a (hypothetical) SQL
|
|
1032
|
+
// edge case on exotic input can never leave autocomplete showing blank.
|
|
1033
|
+
// The rank-0 baseline is identical behavior to the original query.
|
|
1034
1034
|
const substr = `%${query}%`;
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1035
|
+
let rows;
|
|
1036
|
+
try {
|
|
1037
|
+
const prefixQ = `${query}%`;
|
|
1038
|
+
rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
|
|
1039
|
+
(CASE
|
|
1040
|
+
WHEN lower(name) LIKE lower(?) THEN 3
|
|
1041
|
+
WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
|
|
1042
|
+
WHEN email LIKE ? OR name LIKE ? THEN 1
|
|
1043
|
+
ELSE 0
|
|
1044
|
+
END) AS match_rank
|
|
1045
|
+
FROM contacts
|
|
1046
|
+
WHERE email LIKE ? OR name LIKE ?
|
|
1047
|
+
ORDER BY match_rank DESC, use_count DESC, last_used DESC
|
|
1048
|
+
LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
|
|
1049
|
+
}
|
|
1050
|
+
catch (e) {
|
|
1051
|
+
console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
|
|
1052
|
+
rows = this.db.prepare(`SELECT name, email, source, use_count, last_used, 0 AS match_rank
|
|
1053
|
+
FROM contacts
|
|
1054
|
+
WHERE email LIKE ? OR name LIKE ?
|
|
1055
|
+
ORDER BY use_count DESC, last_used DESC
|
|
1056
|
+
LIMIT ?`).all(substr, substr, limit);
|
|
1057
|
+
}
|
|
1049
1058
|
const now = Date.now();
|
|
1050
1059
|
const HALF_LIFE_MS = 30 * 86400_000;
|
|
1051
|
-
const score = (r) => r.match_rank * 10_000
|
|
1060
|
+
const score = (r) => (r.match_rank || 0) * 10_000
|
|
1052
1061
|
+ (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
|
|
1053
1062
|
rows.sort((a, b) => score(b) - score(a));
|
|
1054
1063
|
return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
|