@bobfrankston/mailx 1.0.265 → 1.0.283
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 +146 -46
- package/client/.msger-window.json +1 -1
- package/client/android.html +1 -0
- package/client/app.js +126 -15
- package/client/components/folder-tree.js +9 -5
- package/client/components/message-list.js +43 -3
- package/client/components/message-viewer.js +47 -14
- package/client/index.html +1 -0
- package/client/lib/api-client.js +5 -2
- package/client/lib/mailxapi.js +5 -2
- package/client/styles/components.css +65 -0
- package/package.json +15 -15
- package/packages/mailx-api/index.js +10 -0
- package/packages/mailx-core/index.d.ts +2 -0
- package/packages/mailx-core/index.js +1 -0
- package/packages/mailx-imap/index.d.ts +22 -1
- package/packages/mailx-imap/index.js +251 -44
- package/packages/mailx-service/index.d.ts +7 -1
- package/packages/mailx-service/index.js +72 -10
- package/packages/mailx-service/jsonrpc.js +3 -1
- package/packages/mailx-settings/cloud.d.ts +11 -2
- package/packages/mailx-settings/cloud.js +66 -39
- package/packages/mailx-settings/index.d.ts +11 -2
- package/packages/mailx-settings/index.js +64 -15
- package/packages/mailx-store/db.d.ts +1 -0
- package/packages/mailx-store/db.js +22 -5
- package/packages/mailx-store-web/web-service.js +13 -2
- package/packages/mailx-types/index.d.ts +6 -0
- package/tsconfig.base.json +1 -0
package/bin/mailx.js
CHANGED
|
@@ -463,16 +463,18 @@ async function promptForAccount(intro) {
|
|
|
463
463
|
smtp: { host: smtpHost },
|
|
464
464
|
};
|
|
465
465
|
}
|
|
466
|
-
/** Interactive first-time setup — GDrive API for cloud storage
|
|
466
|
+
/** Interactive first-time setup — GDrive API for cloud storage.
|
|
467
|
+
* Returns true if an account was added (caller should proceed to launch UI),
|
|
468
|
+
* false if the user skipped (UI's in-browser setup form will take over). */
|
|
467
469
|
async function runSetup(providedEmail) {
|
|
468
470
|
console.log("\nmailx — first-time setup\n");
|
|
469
471
|
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
470
472
|
const mailxDir = path.join(home, ".mailx");
|
|
473
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
471
474
|
// Use --email flag or prompt interactively
|
|
472
475
|
const email = providedEmail || await prompt("Email address (Gmail recommended): ");
|
|
473
476
|
if (!email || !email.includes("@")) {
|
|
474
477
|
console.log(`\nNo account added. The UI will show a setup form.`);
|
|
475
|
-
fs.mkdirSync(mailxDir, { recursive: true });
|
|
476
478
|
return false;
|
|
477
479
|
}
|
|
478
480
|
if (providedEmail)
|
|
@@ -493,15 +495,15 @@ async function runSetup(providedEmail) {
|
|
|
493
495
|
catch { /* DNS lookup failed */ }
|
|
494
496
|
}
|
|
495
497
|
// For Google-hosted accounts, check Drive for existing settings first
|
|
498
|
+
let driveFolderId = null;
|
|
496
499
|
if (isGoogle) {
|
|
497
|
-
fs.mkdirSync(mailxDir, { recursive: true });
|
|
498
500
|
console.log("\nChecking Google Drive for existing mailx settings...");
|
|
499
501
|
try {
|
|
500
502
|
const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
501
|
-
|
|
502
|
-
if (
|
|
503
|
-
console.log(` Drive folder: My Drive/mailx/ (${
|
|
504
|
-
const gdrive = getCloudProvider("gdrive",
|
|
503
|
+
driveFolderId = await gDriveFindOrCreateFolder();
|
|
504
|
+
if (driveFolderId) {
|
|
505
|
+
console.log(` Drive folder: My Drive/mailx/ (${driveFolderId})`);
|
|
506
|
+
const gdrive = getCloudProvider("gdrive", driveFolderId);
|
|
505
507
|
if (gdrive) {
|
|
506
508
|
// Read accounts.jsonc (canonical) — ignore legacy settings.jsonc
|
|
507
509
|
const existing = await gdrive.read("accounts.jsonc");
|
|
@@ -514,7 +516,7 @@ async function runSetup(providedEmail) {
|
|
|
514
516
|
for (const a of accts)
|
|
515
517
|
console.log(` • ${a.label || a.name || a.email}`);
|
|
516
518
|
// Save config pointing to Drive — no prompts needed
|
|
517
|
-
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
|
|
519
|
+
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId: driveFolderId } };
|
|
518
520
|
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
519
521
|
console.log("Local config created. Starting mailx...\n");
|
|
520
522
|
return true;
|
|
@@ -522,12 +524,16 @@ async function runSetup(providedEmail) {
|
|
|
522
524
|
}
|
|
523
525
|
}
|
|
524
526
|
// No existing accounts — save Drive config for later
|
|
525
|
-
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
|
|
527
|
+
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId: driveFolderId } };
|
|
526
528
|
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
527
529
|
}
|
|
530
|
+
else {
|
|
531
|
+
console.log(" Could not access Google Drive (OAuth not granted or token expired).");
|
|
532
|
+
console.log(" Account will be saved locally; the UI will retry the cloud sync when you fix Drive access.");
|
|
533
|
+
}
|
|
528
534
|
}
|
|
529
535
|
catch (e) {
|
|
530
|
-
console.log(` Drive check failed: ${e.message} —
|
|
536
|
+
console.log(` Drive check failed: ${e.message} — will save locally and retry from UI.`);
|
|
531
537
|
}
|
|
532
538
|
}
|
|
533
539
|
// No existing accounts found — build a new account
|
|
@@ -536,42 +542,35 @@ async function runSetup(providedEmail) {
|
|
|
536
542
|
if (!isOAuth) {
|
|
537
543
|
account.password = await prompt("Password (app password for Yahoo/AOL/iCloud): ");
|
|
538
544
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
545
|
+
// Display name: leave empty for Google accounts so MailxService.setupAccount
|
|
546
|
+
// (or the next launch's IMAP auth) can resolve it from the People API once
|
|
547
|
+
// the Gmail OAuth token exists. The Drive token alone doesn't have the
|
|
548
|
+
// contacts.readonly scope, so we can't fetch it here at CLI-prompt time.
|
|
549
|
+
const defaultName = email.split("@")[0];
|
|
550
|
+
const name = await prompt(`Your name (for From: header) [auto-detect from Google, or '${defaultName}']: `) || (isGoogle ? "" : defaultName);
|
|
551
|
+
// ALWAYS write a local copy first so the data is never lost. The cloud
|
|
552
|
+
// write below is the sync, not the source of truth on this machine.
|
|
553
|
+
const accountsData = { name, accounts: [account] };
|
|
554
|
+
const localAccountsPath = path.join(mailxDir, "accounts.jsonc");
|
|
555
|
+
fs.writeFileSync(localAccountsPath, JSON.stringify(accountsData, null, 2));
|
|
556
|
+
if (isGoogle && driveFolderId) {
|
|
542
557
|
// Save to Google Drive via API
|
|
543
558
|
console.log("\nSaving account to Google Drive...");
|
|
544
559
|
try {
|
|
545
|
-
const {
|
|
546
|
-
const
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const ok = await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
|
|
552
|
-
if (ok) {
|
|
553
|
-
console.log("Account saved to Google Drive.");
|
|
554
|
-
// config.jsonc may already exist from the Drive check above
|
|
555
|
-
if (!fs.existsSync(path.join(mailxDir, "config.jsonc"))) {
|
|
556
|
-
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
|
|
557
|
-
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
else {
|
|
561
|
-
console.log("Drive write failed — saving locally.");
|
|
562
|
-
fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
560
|
+
const { getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
561
|
+
const gdrive = getCloudProvider("gdrive", driveFolderId);
|
|
562
|
+
if (!gdrive)
|
|
563
|
+
throw new Error("getCloudProvider returned null");
|
|
564
|
+
await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
|
|
565
|
+
console.log(" Account saved to Google Drive.");
|
|
566
566
|
}
|
|
567
567
|
catch (e) {
|
|
568
|
-
console.log(`Drive
|
|
569
|
-
|
|
568
|
+
console.log(` Drive write failed: ${e.message}`);
|
|
569
|
+
console.log(` Local copy saved at ${localAccountsPath} — UI will retry cloud sync.`);
|
|
570
570
|
}
|
|
571
571
|
}
|
|
572
|
-
else {
|
|
573
|
-
|
|
574
|
-
fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
|
|
572
|
+
else if (isGoogle && !driveFolderId) {
|
|
573
|
+
console.log(` Skipping Drive sync (no folder ID). Local copy at ${localAccountsPath}.`);
|
|
575
574
|
}
|
|
576
575
|
console.log("Setup complete. Starting mailx...\n");
|
|
577
576
|
return true;
|
|
@@ -709,10 +708,15 @@ async function registerClient(settings) {
|
|
|
709
708
|
ip: localIp,
|
|
710
709
|
version: JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version,
|
|
711
710
|
};
|
|
712
|
-
// Write back
|
|
713
|
-
|
|
714
|
-
|
|
711
|
+
// Write back. cloudWrite now throws on failure (and sets lastCloudError),
|
|
712
|
+
// so swallow here — registerClient is fire-and-forget from the caller.
|
|
713
|
+
try {
|
|
714
|
+
await cloudWrite("clients.jsonc", JSON.stringify(clients, null, 2));
|
|
715
715
|
console.log(` [client] Registered device: ${deviceId}`);
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
console.error(` [client] Failed to register device: ${e.message}`);
|
|
719
|
+
}
|
|
716
720
|
}
|
|
717
721
|
async function main() {
|
|
718
722
|
log(`Platform: ${process.platform} ${process.arch}`);
|
|
@@ -746,7 +750,12 @@ async function main() {
|
|
|
746
750
|
}
|
|
747
751
|
process.exit(0);
|
|
748
752
|
}
|
|
749
|
-
// Auto-detect first run — enter setup if no config exists
|
|
753
|
+
// Auto-detect first run — enter setup if no config exists.
|
|
754
|
+
// Skip CLI prompts entirely when stdin isn't a TTY (auto-detached daemon
|
|
755
|
+
// has stdio:"ignore", so prompt() returns "" instantly and the user never
|
|
756
|
+
// gets to type their email — silent no-setup). The in-browser setup form
|
|
757
|
+
// takes over in that case.
|
|
758
|
+
const hasTty = setupMode ? !!process.stdin.isTTY : (process.stdin.isTTY === true);
|
|
750
759
|
if (setupMode || !hasConfig()) {
|
|
751
760
|
if (!setupMode)
|
|
752
761
|
console.log("No mailx configuration found.");
|
|
@@ -754,7 +763,15 @@ async function main() {
|
|
|
754
763
|
const emailFlag = args.findIndex(a => a === "-email" || a === "--email" || a === "-mail" || a === "--mail");
|
|
755
764
|
const emailArg = args.find(a => a.startsWith("-email=") || a.startsWith("--email=") || a.startsWith("-mail=") || a.startsWith("--mail="))?.split("=")[1]
|
|
756
765
|
|| (emailFlag >= 0 ? args[emailFlag + 1] : undefined);
|
|
757
|
-
|
|
766
|
+
if (hasTty || emailArg) {
|
|
767
|
+
await runSetup(emailArg);
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
console.log("No TTY and no -email flag — skipping CLI setup; in-browser setup form will appear.");
|
|
771
|
+
// Ensure the data dir exists so the UI can write its config.
|
|
772
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
773
|
+
fs.mkdirSync(path.join(home, ".mailx"), { recursive: true });
|
|
774
|
+
}
|
|
758
775
|
}
|
|
759
776
|
// Redirect console to log file — keep terminal clean
|
|
760
777
|
if (!verbose) {
|
|
@@ -906,6 +923,39 @@ async function main() {
|
|
|
906
923
|
imapManager.on("configChanged", (filename) => {
|
|
907
924
|
handle.send({ _event: "configChanged", type: "configChanged", filename });
|
|
908
925
|
});
|
|
926
|
+
// syncComplete drives the folder-tree refresh that picks up newly-discovered
|
|
927
|
+
// folders on first run (Gmail accounts have no folders in the DB until the
|
|
928
|
+
// first sync fetches the labels). Without this forward, the UI shows the
|
|
929
|
+
// account but no folders, and never auto-selects the inbox.
|
|
930
|
+
imapManager.on("syncComplete", (accountId) => {
|
|
931
|
+
handle.send({ _event: "syncComplete", type: "syncComplete", accountId });
|
|
932
|
+
});
|
|
933
|
+
// Cloud-write/read failures from mailx-settings → push to UI as a banner so
|
|
934
|
+
// silent fall-back-to-local can no longer swallow Drive errors.
|
|
935
|
+
const { onCloudError } = await import("@bobfrankston/mailx-settings");
|
|
936
|
+
onCloudError((error, ctx) => {
|
|
937
|
+
if (error) {
|
|
938
|
+
handle.send({ _event: "cloudError", type: "cloudError", error, op: ctx?.op, filename: ctx?.filename });
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
handle.send({ _event: "cloudError", type: "cloudError", error: null });
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
// Coalesce bodyCached into batches so a prefetch storm doesn't flood stdin
|
|
945
|
+
// with one IPC write per message — lets the UI flip many rows at once.
|
|
946
|
+
let pendingCached = [];
|
|
947
|
+
let cachedTimer = null;
|
|
948
|
+
imapManager.on("bodyCached", (accountId, uid) => {
|
|
949
|
+
pendingCached.push({ accountId, uid });
|
|
950
|
+
if (!cachedTimer) {
|
|
951
|
+
cachedTimer = setTimeout(() => {
|
|
952
|
+
cachedTimer = null;
|
|
953
|
+
const batch = pendingCached;
|
|
954
|
+
pendingCached = [];
|
|
955
|
+
handle.send({ _event: "bodyCached", type: "bodyCached", items: batch });
|
|
956
|
+
}, 500);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
909
959
|
// Brief pause for WebView2 to initialize before starting IMAP (avoids stdin writes during init)
|
|
910
960
|
await new Promise(r => setTimeout(r, 500));
|
|
911
961
|
// Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
|
|
@@ -920,11 +970,61 @@ async function main() {
|
|
|
920
970
|
console.error(` Failed: ${account.id}: ${e.message}`);
|
|
921
971
|
}
|
|
922
972
|
}
|
|
973
|
+
// After OAuth has completed, resolve missing display names for Google
|
|
974
|
+
// accounts via the People API (contacts.readonly is in the Gmail scope).
|
|
975
|
+
// "Missing" = empty or matches the email local-part default.
|
|
976
|
+
try {
|
|
977
|
+
const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
978
|
+
const { saveAccounts } = await import("@bobfrankston/mailx-settings");
|
|
979
|
+
let updated = false;
|
|
980
|
+
for (const acct of settings.accounts) {
|
|
981
|
+
if (!acct.enabled)
|
|
982
|
+
continue;
|
|
983
|
+
const isGoogle = acct.email.endsWith("@gmail.com")
|
|
984
|
+
|| acct.email.endsWith("@googlemail.com")
|
|
985
|
+
|| acct.imap?.host?.includes("gmail");
|
|
986
|
+
if (!isGoogle)
|
|
987
|
+
continue;
|
|
988
|
+
const local = acct.email.split("@")[0];
|
|
989
|
+
const looksDefault = !acct.name || acct.name === local;
|
|
990
|
+
if (!looksDefault)
|
|
991
|
+
continue;
|
|
992
|
+
try {
|
|
993
|
+
const tok = await imapManager.getOAuthToken(acct.id);
|
|
994
|
+
if (!tok)
|
|
995
|
+
continue;
|
|
996
|
+
const profile = await getGoogleProfile(tok);
|
|
997
|
+
if (profile?.name && profile.name !== acct.name) {
|
|
998
|
+
console.log(` [name-resolve] ${acct.id}: '${acct.name || "(empty)"}' → '${profile.name}'`);
|
|
999
|
+
acct.name = profile.name;
|
|
1000
|
+
updated = true;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
catch (e) {
|
|
1004
|
+
console.error(` [name-resolve] ${acct.id}: ${e.message}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (updated) {
|
|
1008
|
+
try {
|
|
1009
|
+
await saveAccounts(settings.accounts);
|
|
1010
|
+
}
|
|
1011
|
+
catch (e) {
|
|
1012
|
+
console.error(` [name-resolve] saveAccounts failed: ${e.message}`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
catch (e) {
|
|
1017
|
+
console.error(` [name-resolve] init failed: ${e.message}`);
|
|
1018
|
+
}
|
|
923
1019
|
// Register this client device on GDrive (fire-and-forget)
|
|
924
1020
|
registerClient(settings).catch(() => { });
|
|
925
|
-
// Start sync in background — don't block
|
|
1021
|
+
// Start sync in background — don't block. Kick off IDLE watchers once the
|
|
1022
|
+
// initial sync finishes so IMAP accounts get instant-push new-mail (the
|
|
1023
|
+
// 5-min STATUS poll is only a safety net).
|
|
926
1024
|
if (settings.accounts.some(a => a.enabled)) {
|
|
927
|
-
imapManager.syncAll()
|
|
1025
|
+
imapManager.syncAll()
|
|
1026
|
+
.then(() => imapManager.startWatching())
|
|
1027
|
+
.catch(e => console.error(` Sync error: ${e.message}`));
|
|
928
1028
|
}
|
|
929
1029
|
imapManager.startPeriodicSync(settings.sync.intervalMinutes);
|
|
930
1030
|
imapManager.startOutboxWorker();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":
|
|
1
|
+
{"height":1421,"width":2151,"x":228,"y":93}
|
package/client/android.html
CHANGED
|
@@ -148,6 +148,7 @@
|
|
|
148
148
|
<button class="tb-btn" id="btn-reply-all" title="Reply All">↩↩</button>
|
|
149
149
|
<button class="tb-btn" id="btn-forward" title="Forward">→</button>
|
|
150
150
|
<button class="tb-btn" id="btn-delete" title="Delete">🗑</button>
|
|
151
|
+
<button class="tb-btn" id="btn-spam" title="Mark as spam" hidden>⚠</button>
|
|
151
152
|
<button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
|
|
152
153
|
<span style="flex:1"></span>
|
|
153
154
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
package/client/app.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Wires together all UI components and WebSocket connection.
|
|
4
4
|
*/
|
|
5
5
|
import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
|
|
6
|
-
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
|
|
6
|
+
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
|
|
8
|
-
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages } from "./lib/api-client.js";
|
|
9
9
|
import * as messageState from "./lib/message-state.js";
|
|
10
10
|
// ── New message badge (favicon + title) ──
|
|
11
11
|
let baseTitle = "mailx";
|
|
@@ -203,6 +203,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
203
203
|
loadMessages(accountId, folderId, 1, specialUse);
|
|
204
204
|
setTitle(`mailx - ${folderName}`);
|
|
205
205
|
setNarrowFolderTitle(folderName);
|
|
206
|
+
document.dispatchEvent(new CustomEvent("mailx-folder-changed", { detail: { accountId, folderId } }));
|
|
206
207
|
}, () => {
|
|
207
208
|
// Unified inbox handler
|
|
208
209
|
currentFolderSpecialUse = "inbox";
|
|
@@ -690,6 +691,58 @@ document.addEventListener("mailx-moved", (e) => {
|
|
|
690
691
|
undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
|
|
691
692
|
});
|
|
692
693
|
document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
|
|
694
|
+
async function spamSelectedMessages() {
|
|
695
|
+
const selected = getSelectedMessages();
|
|
696
|
+
if (selected.length === 0) {
|
|
697
|
+
const current = getCurrentMessage();
|
|
698
|
+
if (!current)
|
|
699
|
+
return;
|
|
700
|
+
selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
|
|
701
|
+
}
|
|
702
|
+
const statusSync = document.getElementById("status-sync");
|
|
703
|
+
try {
|
|
704
|
+
const byAccount = new Map();
|
|
705
|
+
for (const msg of selected) {
|
|
706
|
+
const uids = byAccount.get(msg.accountId) || [];
|
|
707
|
+
uids.push(msg.uid);
|
|
708
|
+
byAccount.set(msg.accountId, uids);
|
|
709
|
+
}
|
|
710
|
+
for (const [accountId, uids] of byAccount) {
|
|
711
|
+
await markAsSpamMessages(accountId, uids);
|
|
712
|
+
}
|
|
713
|
+
if (statusSync)
|
|
714
|
+
statusSync.textContent = `Marked ${selected.length} as spam`;
|
|
715
|
+
messageState.removeMessages(selected);
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
if (statusSync)
|
|
719
|
+
statusSync.textContent = `Spam failed: ${e.message}`;
|
|
720
|
+
console.error(`Spam failed: ${e.message}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
document.getElementById("btn-spam")?.addEventListener("click", spamSelectedMessages);
|
|
724
|
+
/** Show/hide the Spam button based on whether the current account has "spam" configured. */
|
|
725
|
+
async function refreshSpamButtonVisibility() {
|
|
726
|
+
const btn = document.getElementById("btn-spam");
|
|
727
|
+
if (!btn)
|
|
728
|
+
return;
|
|
729
|
+
const current = getCurrentMessage();
|
|
730
|
+
const accountId = current?.accountId || currentAccountId;
|
|
731
|
+
if (!accountId) {
|
|
732
|
+
btn.hidden = true;
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const accounts = await getAccounts();
|
|
737
|
+
const acct = accounts.find((a) => a.id === accountId);
|
|
738
|
+
btn.hidden = !acct?.spam;
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
btn.hidden = true;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
|
|
745
|
+
document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
|
|
693
746
|
document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
|
|
694
747
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
695
748
|
document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
|
|
@@ -831,18 +884,54 @@ window.addEventListener("message", (e) => {
|
|
|
831
884
|
}
|
|
832
885
|
}
|
|
833
886
|
if (e.data?.type === "linkHover") {
|
|
834
|
-
|
|
835
|
-
if (
|
|
836
|
-
if (
|
|
837
|
-
|
|
838
|
-
|
|
887
|
+
let pop = document.getElementById("link-hover-popover");
|
|
888
|
+
if (!e.data.url) {
|
|
889
|
+
if (pop)
|
|
890
|
+
pop.style.display = "none";
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
if (!pop) {
|
|
894
|
+
pop = document.createElement("div");
|
|
895
|
+
pop.id = "link-hover-popover";
|
|
896
|
+
pop.style.cssText = "position:fixed;z-index:10000;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;";
|
|
897
|
+
document.body.appendChild(pop);
|
|
839
898
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
899
|
+
pop.textContent = e.data.url;
|
|
900
|
+
pop.style.display = "block";
|
|
901
|
+
// Locate the iframe whose contentWindow matches e.source so we can
|
|
902
|
+
// translate iframe-local rect coords into viewport coords.
|
|
903
|
+
let iframeRect = null;
|
|
904
|
+
for (const f of Array.from(document.querySelectorAll("iframe"))) {
|
|
905
|
+
if (f.contentWindow === e.source) {
|
|
906
|
+
iframeRect = f.getBoundingClientRect();
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const r = e.data.rect;
|
|
911
|
+
if (iframeRect && r) {
|
|
912
|
+
const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));
|
|
913
|
+
let y = iframeRect.top + r.bottom + 4;
|
|
914
|
+
// If it would clip bottom, flip above the link
|
|
915
|
+
if (y + 60 > window.innerHeight)
|
|
916
|
+
y = Math.max(4, iframeRect.top + r.top - 60);
|
|
917
|
+
pop.style.left = x + "px";
|
|
918
|
+
pop.style.top = y + "px";
|
|
843
919
|
}
|
|
844
920
|
}
|
|
845
921
|
}
|
|
922
|
+
if (e.data?.type === "previewKey" && typeof e.data.key === "string") {
|
|
923
|
+
// Re-dispatch as a real keydown on document so the hotkey handler
|
|
924
|
+
// below runs the same code path as a list-focused keypress. Used
|
|
925
|
+
// when focus is inside the sandboxed preview iframe — works on
|
|
926
|
+
// platforms where parent-side contentDocument listeners don't.
|
|
927
|
+
const ev = new KeyboardEvent("keydown", {
|
|
928
|
+
key: e.data.key, code: e.data.code || "",
|
|
929
|
+
ctrlKey: !!e.data.ctrlKey, shiftKey: !!e.data.shiftKey,
|
|
930
|
+
altKey: !!e.data.altKey, metaKey: !!e.data.metaKey,
|
|
931
|
+
bubbles: true, cancelable: true,
|
|
932
|
+
});
|
|
933
|
+
document.dispatchEvent(ev);
|
|
934
|
+
}
|
|
846
935
|
});
|
|
847
936
|
// ── Splitter drag ──
|
|
848
937
|
const splitter = document.getElementById("splitter-h");
|
|
@@ -954,6 +1043,11 @@ onWsEvent((event) => {
|
|
|
954
1043
|
case "reload":
|
|
955
1044
|
location.reload();
|
|
956
1045
|
break;
|
|
1046
|
+
case "bodyCached":
|
|
1047
|
+
// Prefetch (or on-demand fetch) downloaded a body — flip the
|
|
1048
|
+
// "not-downloaded" indicator to the teal dot for any rows in view.
|
|
1049
|
+
markBodiesCached(event.items || []);
|
|
1050
|
+
break;
|
|
957
1051
|
case "configChanged":
|
|
958
1052
|
// A watched config file was modified — could be user edit via the
|
|
959
1053
|
// JSONC editor, a GDrive sync, or mailx itself saving (e.g.
|
|
@@ -967,6 +1061,22 @@ onWsEvent((event) => {
|
|
|
967
1061
|
}, 8000);
|
|
968
1062
|
}
|
|
969
1063
|
break;
|
|
1064
|
+
case "cloudError":
|
|
1065
|
+
// Cloud read/write failed (Google Drive auth/network/etc.). Show a
|
|
1066
|
+
// sticky banner so the user knows the change wasn't synced. When
|
|
1067
|
+
// error is null, the next successful op cleared it — hide it.
|
|
1068
|
+
if (event.error) {
|
|
1069
|
+
const where = event.filename ? ` (${event.op || "sync"} ${event.filename})` : "";
|
|
1070
|
+
showAlert(`Cloud sync error${where}: ${event.error}`, "cloud-error");
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
// Only hide if the visible banner is the cloud-error one
|
|
1074
|
+
if (alertBanner && alertBanner.dataset.key === "cloud-error") {
|
|
1075
|
+
alertBanner.hidden = true;
|
|
1076
|
+
dismissedAlerts.delete("cloud-error");
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
break;
|
|
970
1080
|
case "error":
|
|
971
1081
|
if (statusSync)
|
|
972
1082
|
statusSync.textContent = `Error: ${event.message}`;
|
|
@@ -1340,16 +1450,17 @@ optThreaded?.addEventListener("change", () => {
|
|
|
1340
1450
|
localStorage.setItem("mailx-threaded", String(optThreaded.checked));
|
|
1341
1451
|
reloadCurrentFolder();
|
|
1342
1452
|
});
|
|
1343
|
-
// Flagged-only filter
|
|
1453
|
+
// Flagged-only filter — keeps the CSS-level hiding for instant feedback on
|
|
1454
|
+
// the current page AND re-queries the folder so flagged messages that live
|
|
1455
|
+
// outside the currently-loaded page show up.
|
|
1344
1456
|
optFlagged?.addEventListener("change", () => {
|
|
1345
1457
|
const body = document.getElementById("ml-body");
|
|
1346
|
-
if (optFlagged.checked)
|
|
1458
|
+
if (optFlagged.checked)
|
|
1347
1459
|
body?.classList.add("flagged-only");
|
|
1348
|
-
|
|
1349
|
-
else {
|
|
1460
|
+
else
|
|
1350
1461
|
body?.classList.remove("flagged-only");
|
|
1351
|
-
}
|
|
1352
1462
|
localStorage.setItem("mailx-flagged", String(optFlagged.checked));
|
|
1463
|
+
reloadCurrentFolder();
|
|
1353
1464
|
});
|
|
1354
1465
|
// Folder counts toggle
|
|
1355
1466
|
optFolderCounts?.addEventListener("change", () => {
|
|
@@ -450,8 +450,8 @@ async function loadFolderTree(container) {
|
|
|
450
450
|
<p id="setup-form-intro">${introText}</p>
|
|
451
451
|
<form id="setup-form" style="margin-top:1rem;${formDisplay}">
|
|
452
452
|
<label style="display:block;margin-bottom:0.5rem">
|
|
453
|
-
Your name
|
|
454
|
-
<input id="setup-name" type="text" placeholder="Your Name" 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">
|
|
453
|
+
Your name <span id="setup-name-hint" style="color:var(--color-text-muted);font-size:0.85rem">(optional — auto-detected from Google)</span>
|
|
454
|
+
<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">
|
|
455
455
|
</label>
|
|
456
456
|
<label style="display:block;margin-bottom:0.5rem">
|
|
457
457
|
Email address
|
|
@@ -642,11 +642,15 @@ async function loadFolderTree(container) {
|
|
|
642
642
|
const savedScroll = container.scrollTop;
|
|
643
643
|
// Build entire new tree into a DocumentFragment (off-screen, no reflows)
|
|
644
644
|
const fragment = document.createDocumentFragment();
|
|
645
|
-
// Unified Inbox
|
|
646
|
-
|
|
645
|
+
// Unified Inbox — always shown so startup auto-selects it consistently
|
|
646
|
+
// (with one account it's effectively that account's INBOX, but the UI
|
|
647
|
+
// stays uniform so the auto-select path doesn't fork on account count)
|
|
648
|
+
if (accounts.length >= 1) {
|
|
647
649
|
const unifiedEl = document.createElement("div");
|
|
648
650
|
unifiedEl.className = "ft-folder ft-unified";
|
|
649
|
-
unifiedEl.title =
|
|
651
|
+
unifiedEl.title = accounts.length > 1
|
|
652
|
+
? "Merged inbox view of all accounts — click to see messages from every account's INBOX sorted by date"
|
|
653
|
+
: "Inbox view across all your accounts";
|
|
650
654
|
unifiedEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">All Inboxes</span>`;
|
|
651
655
|
unifiedEl.addEventListener("click", () => {
|
|
652
656
|
if (selectedElement)
|
|
@@ -16,6 +16,21 @@ let unifiedMode = false;
|
|
|
16
16
|
let searchMode = false;
|
|
17
17
|
let currentSearchQuery = "";
|
|
18
18
|
let showToInsteadOfFrom = false;
|
|
19
|
+
let touchWasScroll = false;
|
|
20
|
+
/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
|
|
21
|
+
* Called from the bodyCached service event — covers both background prefetch
|
|
22
|
+
* and on-demand fetch. No-op for rows not currently rendered. */
|
|
23
|
+
export function markBodiesCached(items) {
|
|
24
|
+
const body = document.getElementById("ml-body");
|
|
25
|
+
if (!body || items.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
for (const { accountId, uid } of items) {
|
|
28
|
+
const row = body.querySelector(`.ml-row[data-uid="${uid}"][data-account-id="${CSS.escape(accountId)}"]`)
|
|
29
|
+
|| body.querySelector(`.ml-row[data-uid="${uid}"]`);
|
|
30
|
+
if (row)
|
|
31
|
+
row.classList.remove("not-downloaded");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
19
34
|
/** Get all selected message rows */
|
|
20
35
|
export function getSelectedMessages() {
|
|
21
36
|
const body = document.getElementById("ml-body");
|
|
@@ -55,6 +70,25 @@ export function initMessageList(handler) {
|
|
|
55
70
|
// Infinite scroll
|
|
56
71
|
const body = document.getElementById("ml-body");
|
|
57
72
|
if (body) {
|
|
73
|
+
// Touch scroll vs tap: track finger movement at the container level and
|
|
74
|
+
// flag "we were scrolling" so row click handlers can bail out. WebView
|
|
75
|
+
// sometimes fires click on touchend even when the user dragged — which
|
|
76
|
+
// was opening a message just from scrolling the list.
|
|
77
|
+
let touchStartY = 0;
|
|
78
|
+
let touchStartX = 0;
|
|
79
|
+
const TAP_SLOP = 10;
|
|
80
|
+
body.addEventListener("touchstart", (e) => {
|
|
81
|
+
const t = e.touches[0];
|
|
82
|
+
touchStartY = t.clientY;
|
|
83
|
+
touchStartX = t.clientX;
|
|
84
|
+
touchWasScroll = false;
|
|
85
|
+
}, { passive: true });
|
|
86
|
+
body.addEventListener("touchmove", (e) => {
|
|
87
|
+
const t = e.touches[0];
|
|
88
|
+
if (Math.abs(t.clientY - touchStartY) > TAP_SLOP || Math.abs(t.clientX - touchStartX) > TAP_SLOP) {
|
|
89
|
+
touchWasScroll = true;
|
|
90
|
+
}
|
|
91
|
+
}, { passive: true });
|
|
58
92
|
body.addEventListener("scroll", () => {
|
|
59
93
|
if (loading)
|
|
60
94
|
return;
|
|
@@ -236,11 +270,12 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
|
|
|
236
270
|
body.innerHTML = `<div class="ml-empty">Loading...</div>`;
|
|
237
271
|
}
|
|
238
272
|
try {
|
|
239
|
-
const
|
|
273
|
+
const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
|
|
274
|
+
const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly);
|
|
240
275
|
totalMessages = result.total;
|
|
241
276
|
if (result.items.length === 0) {
|
|
242
277
|
state.setMessages([]);
|
|
243
|
-
body.innerHTML = `<div class="ml-empty"
|
|
278
|
+
body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
|
|
244
279
|
return;
|
|
245
280
|
}
|
|
246
281
|
state.setMessages(result.items);
|
|
@@ -268,11 +303,12 @@ async function loadMoreMessages() {
|
|
|
268
303
|
loading = true;
|
|
269
304
|
currentPage++;
|
|
270
305
|
try {
|
|
306
|
+
const flaggedOnly = body.classList.contains("flagged-only");
|
|
271
307
|
const result = searchMode
|
|
272
308
|
? await searchMessages(currentSearchQuery, currentPage)
|
|
273
309
|
: unifiedMode
|
|
274
310
|
? await apiGetUnifiedInbox(currentPage)
|
|
275
|
-
: await apiGetMessages(currentAccountId, currentFolderId, currentPage);
|
|
311
|
+
: await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);
|
|
276
312
|
// Append to state
|
|
277
313
|
const current = state.getMessages();
|
|
278
314
|
state.setMessages([...current, ...result.items]);
|
|
@@ -479,6 +515,10 @@ function appendMessages(body, accountId, items) {
|
|
|
479
515
|
row.appendChild(date);
|
|
480
516
|
row.appendChild(subject);
|
|
481
517
|
row.addEventListener("click", (e) => {
|
|
518
|
+
if (touchWasScroll) {
|
|
519
|
+
touchWasScroll = false;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
482
522
|
if (e.shiftKey && lastClickedRow) {
|
|
483
523
|
clearSelection();
|
|
484
524
|
selectRange(lastClickedRow, row);
|