@bobfrankston/mailx 1.0.386 → 1.0.391
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 +50 -3
- package/client/components/folder-tree.js +35 -0
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +11 -0
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +72 -15
- package/packages/mailx-service/index.d.ts +14 -0
- package/packages/mailx-service/index.js +100 -4
- package/packages/mailx-service/jsonrpc.js +2 -0
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(() => {
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* All storage goes through the service-side two-way cache (calendar_events
|
|
14
14
|
* and tasks tables); this file does not use localStorage for data.
|
|
15
15
|
*/
|
|
16
|
-
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
|
|
16
|
+
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, reauthGoogleScopes, } 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,40 @@ 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
|
+
//
|
|
316
|
+
// Idempotent: if the banner is already shown for this
|
|
317
|
+
// feature (class `.cal-side-auth-error` present), don't
|
|
318
|
+
// re-write the DOM — stops the "flashing on and off
|
|
319
|
+
// continually" effect when the service re-emits on every
|
|
320
|
+
// 5-min poll or sidebar-nav click.
|
|
321
|
+
const host = event.feature === "tasks"
|
|
322
|
+
? document.getElementById("cal-side-tasks")
|
|
323
|
+
: document.getElementById("cal-side-body");
|
|
324
|
+
if (host && !host.querySelector(".cal-side-auth-error")) {
|
|
325
|
+
const msg = event.message || "Google access needs re-consent.";
|
|
326
|
+
host.innerHTML = `<div class="cal-side-empty cal-side-auth-error">
|
|
327
|
+
<div style="margin-bottom:0.6em">${escapeHtml(msg)}</div>
|
|
328
|
+
<button type="button" class="cal-side-reauth-btn" style="padding:0.3em 0.8em;border-radius:4px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer;font-size:0.9em">Re-authenticate Now</button>
|
|
329
|
+
</div>`;
|
|
330
|
+
const btn = host.querySelector(".cal-side-reauth-btn");
|
|
331
|
+
btn?.addEventListener("click", async () => {
|
|
332
|
+
btn.disabled = true;
|
|
333
|
+
btn.textContent = "Opening browser…";
|
|
334
|
+
try {
|
|
335
|
+
await reauthGoogleScopes();
|
|
336
|
+
btn.textContent = "Consent opened — complete it in the browser";
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
btn.disabled = false;
|
|
340
|
+
btn.textContent = `Failed: ${err?.message || err}. Click to retry.`;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
298
345
|
});
|
|
299
346
|
}
|
|
300
347
|
}
|
|
@@ -467,6 +467,9 @@ async function loadFolderTree(container) {
|
|
|
467
467
|
Email address
|
|
468
468
|
<input id="setup-email" type="email" placeholder="you@gmail.com" required style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
|
|
469
469
|
</label>
|
|
470
|
+
<div id="setup-provider-preview" style="display:none;margin-bottom:0.5rem;padding:0.4rem 0.6rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem">
|
|
471
|
+
<span id="setup-provider-icon" style="display:inline-block;width:1.2em;text-align:center;margin-right:0.4em"></span><span id="setup-provider-label"></span>
|
|
472
|
+
</div>
|
|
470
473
|
<label id="setup-name-row" style="display:none;margin-bottom:0.5rem">
|
|
471
474
|
Your name <span style="color:var(--color-text-muted);font-size:0.85rem">(optional — auto-detected from Google)</span>
|
|
472
475
|
<input id="setup-name" type="text" placeholder="Your Name (leave blank to use Google profile)" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
|
|
@@ -496,6 +499,23 @@ async function loadFolderTree(container) {
|
|
|
496
499
|
"aol.com": "Use an app password: AOL Settings → Account Security → Generate app password",
|
|
497
500
|
"icloud.com": "Use an app-specific password: appleid.apple.com → Sign-In and Security → App-Specific Passwords",
|
|
498
501
|
};
|
|
502
|
+
// Q67: describe the detected provider so the user knows which
|
|
503
|
+
// auto-config path we're about to take BEFORE they hit Next.
|
|
504
|
+
// Gmail / Google Workspace domains auto-detect via MX in the
|
|
505
|
+
// service; here we can only name the known ones up front and
|
|
506
|
+
// say "will auto-detect" for everything else.
|
|
507
|
+
const PROVIDER_PREVIEW = {
|
|
508
|
+
"gmail.com": { icon: "✉", label: "Gmail — OAuth (no password needed)" },
|
|
509
|
+
"googlemail.com": { icon: "✉", label: "Gmail — OAuth (no password needed)" },
|
|
510
|
+
"outlook.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
|
|
511
|
+
"hotmail.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
|
|
512
|
+
"live.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
|
|
513
|
+
"yahoo.com": { icon: "✉", label: "Yahoo Mail — IMAP (needs app password)" },
|
|
514
|
+
"aol.com": { icon: "✉", label: "AOL Mail — IMAP (needs app password)" },
|
|
515
|
+
"icloud.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
|
|
516
|
+
"me.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
|
|
517
|
+
"mac.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
|
|
518
|
+
};
|
|
499
519
|
let oauthAutoFired = false;
|
|
500
520
|
emailInput?.addEventListener("input", () => {
|
|
501
521
|
const email = emailInput.value.trim();
|
|
@@ -503,6 +523,21 @@ async function loadFolderTree(container) {
|
|
|
503
523
|
const hasAt = email.includes("@") && domain.length > 0;
|
|
504
524
|
const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
|
|
505
525
|
const isGmailLike = ["gmail.com", "googlemail.com"].includes(domain);
|
|
526
|
+
// Provider preview row
|
|
527
|
+
const preview = document.getElementById("setup-provider-preview");
|
|
528
|
+
const icon = document.getElementById("setup-provider-icon");
|
|
529
|
+
const label = document.getElementById("setup-provider-label");
|
|
530
|
+
if (preview && icon && label) {
|
|
531
|
+
if (hasAt) {
|
|
532
|
+
const hit = PROVIDER_PREVIEW[domain];
|
|
533
|
+
icon.textContent = hit ? hit.icon : "❓";
|
|
534
|
+
label.textContent = hit ? hit.label : `${domain} — will auto-detect via MX records`;
|
|
535
|
+
preview.style.display = "block";
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
preview.style.display = "none";
|
|
539
|
+
}
|
|
540
|
+
}
|
|
506
541
|
// OAuth providers: auto-fire setup immediately once domain
|
|
507
542
|
// is recognized — don't show name/password (name is auto-
|
|
508
543
|
// detected from Google profile, no password needed). This
|
package/client/lib/api-client.js
CHANGED
|
@@ -128,6 +128,9 @@ export function syncAccount(accountId) {
|
|
|
128
128
|
export function reauthenticate(accountId) {
|
|
129
129
|
return ipc().reauthenticate(accountId);
|
|
130
130
|
}
|
|
131
|
+
export function reauthGoogleScopes() {
|
|
132
|
+
return ipc().reauthGoogleScopes();
|
|
133
|
+
}
|
|
131
134
|
export function getSyncPending() {
|
|
132
135
|
return ipc().getSyncPending();
|
|
133
136
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -180,6 +180,7 @@
|
|
|
180
180
|
listQueuedOutgoing: function() { return callNode("listQueuedOutgoing"); },
|
|
181
181
|
cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
|
|
182
182
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
183
|
+
reauthGoogleScopes: function() { return callNode("reauthGoogleScopes"); },
|
|
183
184
|
|
|
184
185
|
// Bulk operations
|
|
185
186
|
deleteMessages: function(accountId, uids) {
|
|
@@ -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
|
@@ -1020,23 +1020,41 @@ export class ImapManager extends EventEmitter {
|
|
|
1020
1020
|
}
|
|
1021
1021
|
if (newCount > 0)
|
|
1022
1022
|
console.log(` stored ${newCount} new messages`);
|
|
1023
|
-
// 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).
|
|
1024
1033
|
let deletedCount = 0;
|
|
1025
1034
|
if (!firstSync) {
|
|
1026
1035
|
try {
|
|
1027
|
-
|
|
1028
|
-
const serverUids = new Set(
|
|
1036
|
+
const serverUidsArr = await client.getUids(folder.path);
|
|
1037
|
+
const serverUids = new Set(serverUidsArr);
|
|
1029
1038
|
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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}`);
|
|
1033
1051
|
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
1034
1052
|
this.db.deleteMessage(accountId, uid);
|
|
1035
1053
|
deletedCount++;
|
|
1036
1054
|
}
|
|
1055
|
+
if (deletedCount > 0)
|
|
1056
|
+
console.log(` removed ${deletedCount} deleted messages`);
|
|
1037
1057
|
}
|
|
1038
|
-
if (deletedCount > 0)
|
|
1039
|
-
console.log(` removed ${deletedCount} deleted messages`);
|
|
1040
1058
|
}
|
|
1041
1059
|
catch (e) {
|
|
1042
1060
|
console.error(` deletion sync error: ${e.message}`);
|
|
@@ -1394,6 +1412,9 @@ export class ImapManager extends EventEmitter {
|
|
|
1394
1412
|
}
|
|
1395
1413
|
else {
|
|
1396
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}`);
|
|
1397
1418
|
this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
|
|
1398
1419
|
this.db.deleteMessage(accountId, uid);
|
|
1399
1420
|
}
|
|
@@ -3046,18 +3067,50 @@ export class ImapManager extends EventEmitter {
|
|
|
3046
3067
|
}
|
|
3047
3068
|
return;
|
|
3048
3069
|
}
|
|
3049
|
-
// IMAP accounts: append to IMAP Outbox for multi-machine interlock
|
|
3070
|
+
// IMAP accounts: append to IMAP Outbox for multi-machine interlock.
|
|
3071
|
+
//
|
|
3072
|
+
// Atomic claim (same pattern as the Gmail path above): rename the file
|
|
3073
|
+
// to <file>.sending-<host>-<pid> BEFORE reading it, so two concurrent
|
|
3074
|
+
// mailx instances scanning the same dir can't both APPEND the same
|
|
3075
|
+
// message to IMAP Outbox and end up with a duplicate. Filesystem rename
|
|
3076
|
+
// is atomic; the loser sees ENOENT and skips. On APPEND success, move
|
|
3077
|
+
// the claimed file to sending/sent/; on APPEND failure, release the
|
|
3078
|
+
// claim so the recovery sweeper picks it up next tick.
|
|
3050
3079
|
try {
|
|
3051
3080
|
const outboxPath = await this.ensureOutbox(accountId);
|
|
3052
3081
|
const client = await this.createClientWithLimit(accountId);
|
|
3053
3082
|
try {
|
|
3054
3083
|
for (const { dir, file } of filesToSend) {
|
|
3055
3084
|
const filePath = path.join(dir, file);
|
|
3056
|
-
const
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3085
|
+
const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
|
|
3086
|
+
const claimedPath = filePath + claimSuffix;
|
|
3087
|
+
try {
|
|
3088
|
+
fs.renameSync(filePath, claimedPath);
|
|
3089
|
+
}
|
|
3090
|
+
catch (e) {
|
|
3091
|
+
if (e.code === "ENOENT")
|
|
3092
|
+
continue; // sibling claimed first
|
|
3093
|
+
throw e;
|
|
3094
|
+
}
|
|
3095
|
+
try {
|
|
3096
|
+
const raw = fs.readFileSync(claimedPath, "utf-8");
|
|
3097
|
+
await client.appendMessage(outboxPath, raw, ["\\Seen"]);
|
|
3098
|
+
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
3099
|
+
console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
|
|
3100
|
+
}
|
|
3101
|
+
catch (e) {
|
|
3102
|
+
// APPEND failed (connection dropped mid-send, server
|
|
3103
|
+
// busy, etc.) — release the claim so next tick can
|
|
3104
|
+
// retry. Don't swallow: rethrow after release so the
|
|
3105
|
+
// outer catch ("IMAP still unreachable") bails out of
|
|
3106
|
+
// the remaining files too — whatever broke will break
|
|
3107
|
+
// the next file the same way.
|
|
3108
|
+
try {
|
|
3109
|
+
fs.renameSync(claimedPath, filePath);
|
|
3110
|
+
}
|
|
3111
|
+
catch { /* recovery sweeper will handle */ }
|
|
3112
|
+
throw e;
|
|
3113
|
+
}
|
|
3061
3114
|
}
|
|
3062
3115
|
}
|
|
3063
3116
|
finally {
|
|
@@ -3323,7 +3376,11 @@ export class ImapManager extends EventEmitter {
|
|
|
3323
3376
|
// After each full tick, refresh the UI indicator.
|
|
3324
3377
|
this.emitOutboxStatus();
|
|
3325
3378
|
};
|
|
3326
|
-
|
|
3379
|
+
// First tick at 500ms so any stale .sending-HOST-DEAD_PID claim file
|
|
3380
|
+
// left behind by a prior crash gets recovered (renamed back to .ltr)
|
|
3381
|
+
// within half a second of startup — otherwise the status-queue pill
|
|
3382
|
+
// shows a red "1 queued" to the user until the first 10s tick passes.
|
|
3383
|
+
setTimeout(() => processAll(), 500);
|
|
3327
3384
|
this.outboxInterval = setInterval(processAll, 10000);
|
|
3328
3385
|
}
|
|
3329
3386
|
/** Stop Outbox worker */
|
|
@@ -47,6 +47,20 @@ export declare class MailxService {
|
|
|
47
47
|
* Called without `feature` it returns the catch-all primary — same
|
|
48
48
|
* semantics as the original single-flag version for back-compat. */
|
|
49
49
|
getPrimaryAccount(feature?: string): any;
|
|
50
|
+
/** Feature names that have already emitted authScopeError this session.
|
|
51
|
+
* Stops the "banner flashing on and off continually" loop where every
|
|
52
|
+
* 5-min poll / sidebar nav re-fired the event and the client re-rendered
|
|
53
|
+
* the red banner. Cleared when the user hits Re-authenticate. */
|
|
54
|
+
private scopeErrorEmitted;
|
|
55
|
+
/** Delete the cached Google OAuth token (the one used for Calendar / Tasks
|
|
56
|
+
* / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
|
|
57
|
+
* and clear the sticky auth-error state so a subsequent refresh can
|
|
58
|
+
* re-trigger browser consent with the current scope set. Equivalent of
|
|
59
|
+
* `mailx -reauth` but callable from the UI. Returns `{ cleared: N }` so
|
|
60
|
+
* the caller can tell the user what happened. */
|
|
61
|
+
reauthGoogleScopes(): {
|
|
62
|
+
cleared: number;
|
|
63
|
+
};
|
|
50
64
|
private primaryTokenProvider;
|
|
51
65
|
/** Return cal events visible in [fromMs..toMs), refreshing from Google
|
|
52
66
|
* in the background. Caller displays local results immediately; after
|
|
@@ -447,6 +447,46 @@ export class MailxService {
|
|
|
447
447
|
return all.find((a) => a.primary) || all[0] || null;
|
|
448
448
|
}
|
|
449
449
|
// ── Calendar / Tasks / Contacts: two-way cache (2026-04-23) ──
|
|
450
|
+
/** Feature names that have already emitted authScopeError this session.
|
|
451
|
+
* Stops the "banner flashing on and off continually" loop where every
|
|
452
|
+
* 5-min poll / sidebar nav re-fired the event and the client re-rendered
|
|
453
|
+
* the red banner. Cleared when the user hits Re-authenticate. */
|
|
454
|
+
scopeErrorEmitted = new Set();
|
|
455
|
+
/** Delete the cached Google OAuth token (the one used for Calendar / Tasks
|
|
456
|
+
* / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
|
|
457
|
+
* and clear the sticky auth-error state so a subsequent refresh can
|
|
458
|
+
* re-trigger browser consent with the current scope set. Equivalent of
|
|
459
|
+
* `mailx -reauth` but callable from the UI. Returns `{ cleared: N }` so
|
|
460
|
+
* the caller can tell the user what happened. */
|
|
461
|
+
reauthGoogleScopes() {
|
|
462
|
+
const tokensDir = path.join(getConfigDir(), "tokens");
|
|
463
|
+
let cleared = 0;
|
|
464
|
+
if (fs.existsSync(tokensDir)) {
|
|
465
|
+
for (const entry of fs.readdirSync(tokensDir)) {
|
|
466
|
+
const userDir = path.join(tokensDir, entry);
|
|
467
|
+
try {
|
|
468
|
+
if (!fs.statSync(userDir).isDirectory())
|
|
469
|
+
continue;
|
|
470
|
+
const tokenFile = path.join(userDir, "oauth-token.json");
|
|
471
|
+
if (fs.existsSync(tokenFile)) {
|
|
472
|
+
fs.unlinkSync(tokenFile);
|
|
473
|
+
console.log(` [reauth-google] cleared ${tokenFile}`);
|
|
474
|
+
cleared++;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch { /* skip */ }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Reset the sticky set so the next failure (if re-consent didn't take)
|
|
481
|
+
// can fire a fresh banner. Also trigger a kickoff refresh so the
|
|
482
|
+
// browser consent pops open now instead of on next sidebar nav.
|
|
483
|
+
this.scopeErrorEmitted.clear();
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
const horizonMs = 90 * 86400_000;
|
|
486
|
+
this.getCalendarEvents(now, now + horizonMs); // fire-and-forget — triggers consent via primaryTokenProvider
|
|
487
|
+
this.getTasks(false); // same path, `tasks` scope
|
|
488
|
+
return { cleared };
|
|
489
|
+
}
|
|
450
490
|
async primaryTokenProvider(feature) {
|
|
451
491
|
const acct = this.getPrimaryAccount(feature);
|
|
452
492
|
if (!acct)
|
|
@@ -472,7 +512,19 @@ export class MailxService {
|
|
|
472
512
|
if (changed)
|
|
473
513
|
this.imapManager.emit("calendarUpdated", { accountId: acct.id });
|
|
474
514
|
})
|
|
475
|
-
.catch(e =>
|
|
515
|
+
.catch(e => {
|
|
516
|
+
const msg = String(e?.message || e);
|
|
517
|
+
console.error(`[calendar] refresh failed: ${msg}`);
|
|
518
|
+
if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
|
|
519
|
+
if (!this.scopeErrorEmitted.has("calendar")) {
|
|
520
|
+
this.scopeErrorEmitted.add("calendar");
|
|
521
|
+
this.imapManager.emit("authScopeError", {
|
|
522
|
+
feature: "calendar",
|
|
523
|
+
message: "Google Calendar access needs re-consent.",
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
});
|
|
476
528
|
return this.db.getCalendarEvents(acct.id, fromMs, toMs);
|
|
477
529
|
}
|
|
478
530
|
/** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
|
|
@@ -481,6 +533,7 @@ export class MailxService {
|
|
|
481
533
|
async refreshCalendarEvents(accountId, fromMs, toMs) {
|
|
482
534
|
const tp = await this.primaryTokenProvider("calendar");
|
|
483
535
|
const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
|
|
536
|
+
console.log(` [calendar] pulled ${events.length} events from ${new Date(fromMs).toISOString().slice(0, 10)} to ${new Date(toMs).toISOString().slice(0, 10)}`);
|
|
484
537
|
let changed = false;
|
|
485
538
|
// Upsert by provider_id — dedup globally, not just within the window,
|
|
486
539
|
// so an event whose start moves outside the prior query range doesn't
|
|
@@ -563,12 +616,26 @@ export class MailxService {
|
|
|
563
616
|
if (changed)
|
|
564
617
|
this.imapManager.emit("tasksUpdated", { accountId: acct.id });
|
|
565
618
|
})
|
|
566
|
-
.catch(e =>
|
|
619
|
+
.catch(e => {
|
|
620
|
+
const msg = String(e?.message || e);
|
|
621
|
+
console.error(`[tasks] refresh failed: ${msg}`);
|
|
622
|
+
if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
|
|
623
|
+
if (!this.scopeErrorEmitted.has("tasks")) {
|
|
624
|
+
this.scopeErrorEmitted.add("tasks");
|
|
625
|
+
console.error(`[tasks] Your cached OAuth token doesn't include the 'tasks' scope.`);
|
|
626
|
+
this.imapManager.emit("authScopeError", {
|
|
627
|
+
feature: "tasks",
|
|
628
|
+
message: "Google Tasks access needs re-consent.",
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
});
|
|
567
633
|
return this.db.getTasks(acct.id, includeCompleted);
|
|
568
634
|
}
|
|
569
635
|
async refreshTasks(accountId, includeCompleted) {
|
|
570
636
|
const tp = await this.primaryTokenProvider("tasks");
|
|
571
637
|
const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
|
|
638
|
+
console.log(` [tasks] pulled ${tasks.length} tasks`);
|
|
572
639
|
const existing = this.db.getTasks(accountId, true);
|
|
573
640
|
let changed = false;
|
|
574
641
|
const seen = new Set();
|
|
@@ -730,11 +797,40 @@ export class MailxService {
|
|
|
730
797
|
for (const f of fs.readdirSync(dir)) {
|
|
731
798
|
if (!f.endsWith(".ltr") && !f.endsWith(".eml") && !/\.sending-/.test(f))
|
|
732
799
|
continue;
|
|
800
|
+
const fp = path.join(dir, f);
|
|
733
801
|
try {
|
|
734
|
-
const raw = fs.readFileSync(
|
|
802
|
+
const raw = fs.readFileSync(fp, "utf-8");
|
|
735
803
|
out.push(parseEnv(raw, f, dir, accountId));
|
|
736
804
|
}
|
|
737
|
-
catch {
|
|
805
|
+
catch (err) {
|
|
806
|
+
// Unreadable file — still show it so the user can cancel.
|
|
807
|
+
// Previously silently skipped, which produced the user-reported
|
|
808
|
+
// "outbox badge shows 1 but the modal is empty" symptom:
|
|
809
|
+
// getOutboxStatus counted the file, listQueuedOutgoing dropped it.
|
|
810
|
+
const st = (() => { try {
|
|
811
|
+
return fs.statSync(fp);
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
return null;
|
|
815
|
+
} })();
|
|
816
|
+
out.push({
|
|
817
|
+
accountId,
|
|
818
|
+
file: f,
|
|
819
|
+
path: fp,
|
|
820
|
+
dir,
|
|
821
|
+
from: "",
|
|
822
|
+
to: "",
|
|
823
|
+
cc: "",
|
|
824
|
+
bcc: "",
|
|
825
|
+
subject: `[unreadable: ${err?.code || err?.message || "read failed"}]`,
|
|
826
|
+
date: "",
|
|
827
|
+
messageId: "",
|
|
828
|
+
attempts: 0,
|
|
829
|
+
sizeBytes: st?.size || 0,
|
|
830
|
+
createdAt: st?.mtimeMs || 0,
|
|
831
|
+
claimed: /\.sending-[^-]+-\d+$/.test(f),
|
|
832
|
+
});
|
|
833
|
+
}
|
|
738
834
|
}
|
|
739
835
|
};
|
|
740
836
|
try {
|
|
@@ -133,6 +133,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
133
133
|
return svc.cancelQueuedOutgoing(p.path);
|
|
134
134
|
case "reauthenticate":
|
|
135
135
|
return { ok: await svc.reauthenticate(p.accountId) };
|
|
136
|
+
case "reauthGoogleScopes":
|
|
137
|
+
return svc.reauthGoogleScopes();
|
|
136
138
|
// Search & contacts
|
|
137
139
|
case "searchMessages":
|
|
138
140
|
return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
|