@bobfrankston/mailx 1.0.389 → 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/client/components/calendar-sidebar.js +26 -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/package.json +1 -1
- package/packages/mailx-imap/index.js +38 -6
- package/packages/mailx-service/index.d.ts +14 -0
- package/packages/mailx-service/index.js +55 -10
- package/packages/mailx-service/jsonrpc.js +2 -0
|
@@ -13,7 +13,7 @@
|
|
|
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
19
|
const SHOW_DONE_PREF = "mailx-task-show-done";
|
|
@@ -312,11 +312,34 @@ export function initCalendarSidebar() {
|
|
|
312
312
|
// Surface a visible hint right in the affected pane so the
|
|
313
313
|
// user doesn't stare at an empty list wondering why. Only
|
|
314
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.
|
|
315
321
|
const host = event.feature === "tasks"
|
|
316
322
|
? document.getElementById("cal-side-tasks")
|
|
317
323
|
: document.getElementById("cal-side-body");
|
|
318
|
-
if (host) {
|
|
319
|
-
|
|
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
|
+
});
|
|
320
343
|
}
|
|
321
344
|
}
|
|
322
345
|
});
|
|
@@ -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) {
|
package/package.json
CHANGED
|
@@ -3067,18 +3067,50 @@ export class ImapManager extends EventEmitter {
|
|
|
3067
3067
|
}
|
|
3068
3068
|
return;
|
|
3069
3069
|
}
|
|
3070
|
-
// 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.
|
|
3071
3079
|
try {
|
|
3072
3080
|
const outboxPath = await this.ensureOutbox(accountId);
|
|
3073
3081
|
const client = await this.createClientWithLimit(accountId);
|
|
3074
3082
|
try {
|
|
3075
3083
|
for (const { dir, file } of filesToSend) {
|
|
3076
3084
|
const filePath = path.join(dir, file);
|
|
3077
|
-
const
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
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
|
+
}
|
|
3082
3114
|
}
|
|
3083
3115
|
}
|
|
3084
3116
|
finally {
|
|
@@ -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)
|
|
@@ -476,10 +516,13 @@ export class MailxService {
|
|
|
476
516
|
const msg = String(e?.message || e);
|
|
477
517
|
console.error(`[calendar] refresh failed: ${msg}`);
|
|
478
518
|
if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
|
|
479
|
-
this.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
}
|
|
483
526
|
}
|
|
484
527
|
});
|
|
485
528
|
return this.db.getCalendarEvents(acct.id, fromMs, toMs);
|
|
@@ -577,12 +620,14 @@ export class MailxService {
|
|
|
577
620
|
const msg = String(e?.message || e);
|
|
578
621
|
console.error(`[tasks] refresh failed: ${msg}`);
|
|
579
622
|
if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
+
}
|
|
586
631
|
}
|
|
587
632
|
});
|
|
588
633
|
return this.db.getTasks(acct.id, includeCompleted);
|
|
@@ -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);
|